scout-ai 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +80 -15
  3. data/README.md +296 -0
  4. data/Rakefile +2 -0
  5. data/VERSION +1 -1
  6. data/doc/Agent.md +279 -0
  7. data/doc/Chat.md +258 -0
  8. data/doc/LLM.md +446 -0
  9. data/doc/Model.md +513 -0
  10. data/doc/RAG.md +129 -0
  11. data/lib/scout/llm/agent/chat.rb +51 -1
  12. data/lib/scout/llm/agent/delegate.rb +39 -0
  13. data/lib/scout/llm/agent/iterate.rb +44 -0
  14. data/lib/scout/llm/agent.rb +42 -21
  15. data/lib/scout/llm/ask.rb +38 -6
  16. data/lib/scout/llm/backends/anthropic.rb +147 -0
  17. data/lib/scout/llm/backends/bedrock.rb +1 -1
  18. data/lib/scout/llm/backends/ollama.rb +23 -29
  19. data/lib/scout/llm/backends/openai.rb +34 -40
  20. data/lib/scout/llm/backends/responses.rb +158 -110
  21. data/lib/scout/llm/chat.rb +250 -94
  22. data/lib/scout/llm/embed.rb +4 -4
  23. data/lib/scout/llm/mcp.rb +28 -0
  24. data/lib/scout/llm/parse.rb +1 -0
  25. data/lib/scout/llm/rag.rb +9 -0
  26. data/lib/scout/llm/tools/call.rb +66 -0
  27. data/lib/scout/llm/tools/knowledge_base.rb +158 -0
  28. data/lib/scout/llm/tools/mcp.rb +59 -0
  29. data/lib/scout/llm/tools/workflow.rb +69 -0
  30. data/lib/scout/llm/tools.rb +58 -143
  31. data/lib/scout-ai.rb +1 -0
  32. data/scout-ai.gemspec +31 -18
  33. data/scout_commands/agent/ask +28 -71
  34. data/scout_commands/documenter +148 -0
  35. data/scout_commands/llm/ask +2 -2
  36. data/scout_commands/llm/server +319 -0
  37. data/share/server/chat.html +138 -0
  38. data/share/server/chat.js +468 -0
  39. data/test/scout/llm/backends/test_anthropic.rb +134 -0
  40. data/test/scout/llm/backends/test_openai.rb +45 -6
  41. data/test/scout/llm/backends/test_responses.rb +124 -0
  42. data/test/scout/llm/test_agent.rb +0 -70
  43. data/test/scout/llm/test_ask.rb +3 -1
  44. data/test/scout/llm/test_chat.rb +43 -1
  45. data/test/scout/llm/test_mcp.rb +29 -0
  46. data/test/scout/llm/tools/test_knowledge_base.rb +22 -0
  47. data/test/scout/llm/tools/test_mcp.rb +11 -0
  48. data/test/scout/llm/tools/test_workflow.rb +39 -0
  49. metadata +56 -17
  50. data/README.rdoc +0 -18
  51. data/python/scout_ai/__pycache__/__init__.cpython-310.pyc +0 -0
  52. data/python/scout_ai/__pycache__/__init__.cpython-311.pyc +0 -0
  53. data/python/scout_ai/__pycache__/huggingface.cpython-310.pyc +0 -0
  54. data/python/scout_ai/__pycache__/huggingface.cpython-311.pyc +0 -0
  55. data/python/scout_ai/__pycache__/util.cpython-310.pyc +0 -0
  56. data/python/scout_ai/__pycache__/util.cpython-311.pyc +0 -0
  57. data/python/scout_ai/atcold/plot_lib.py +0 -141
  58. data/python/scout_ai/atcold/spiral.py +0 -27
  59. data/python/scout_ai/huggingface/train/__pycache__/__init__.cpython-310.pyc +0 -0
  60. data/python/scout_ai/huggingface/train/__pycache__/next_token.cpython-310.pyc +0 -0
  61. data/python/scout_ai/language_model.py +0 -70
  62. /data/{python/scout_ai/atcold/__init__.py → test/scout/llm/tools/test_call.rb} +0 -0
@@ -0,0 +1,468 @@
1
+ (function(){
2
+ // Compute API base path so this script works behind a path-prefix proxy (e.g. /sandbox2)
3
+ function computeApiBase(){
4
+ // Use location.pathname to derive the directory this UI is served from.
5
+ // Examples:
6
+ // /sandbox2/ -> API base: /sandbox2
7
+ // /sandbox2/chat.html -> API base: /sandbox2
8
+ // / -> API base: "" (root)
9
+ const locPath = (window.location && window.location.pathname) ? window.location.pathname : '';
10
+ let base = locPath || '';
11
+ // remove trailing slash
12
+ if(base.length > 1 && base.endsWith('/')) base = base.slice(0, -1);
13
+ // if the last segment looks like a file (contains a dot), remove it
14
+ const segs = base.split('/');
15
+ const last = segs[segs.length - 1] || '';
16
+ if(last.includes('.')){
17
+ segs.pop();
18
+ base = segs.join('/') || '';
19
+ }
20
+ // ensure base is empty string for root so concatenation yields '/list' etc.
21
+ if(base === '/') base = '';
22
+ return base;
23
+ }
24
+
25
+ const API_BASE = computeApiBase(); // e.g. '/sandbox2' or ''
26
+
27
+ const PREDEFINED_KEYS = ['user','system','assistant','import','file','directory', 'continue', 'option','endpoint','model','backend','previous_response_id', 'format','websearch','tool','task','job','inline_job'];
28
+ const ROLE_ONLY = ['user','system','assistant'];
29
+ const FILE_KEYS = ['import','file','directory', 'continue'];
30
+ const MARKED_CDN = 'https://cdn.jsdelivr.net/npm/marked/marked.min.js';
31
+
32
+ // State
33
+ let cells = [];
34
+ let filesListCache = [];
35
+
36
+ // DOM
37
+ const pathEl = document.getElementById('path');
38
+ const cellsDiv = document.getElementById('cells');
39
+ const filesDiv = document.getElementById('files');
40
+ const logDiv = document.getElementById('log');
41
+ const cellCountEl = document.getElementById('cellCount');
42
+
43
+ const newFileBtn = document.getElementById('newFileBtn');
44
+ const loadBtn = document.getElementById('loadBtn');
45
+ const saveBtn = document.getElementById('saveBtn');
46
+ const runBtn = document.getElementById('runBtn');
47
+ const runBtnBottom = document.getElementById('runBtnBottom');
48
+ const exportBtn = document.getElementById('exportBtn');
49
+ const listBtn = document.getElementById('listBtn');
50
+ const deleteBtn = document.getElementById('deleteBtn');
51
+ const addUser = document.getElementById('addUser');
52
+ const addSystem = document.getElementById('addSystem');
53
+ const addAssistant = document.getElementById('addAssistant');
54
+ const addOption = document.getElementById('addOption');
55
+ const clearBtn = document.getElementById('clearBtn');
56
+ const loadingIndicator = document.getElementById('loadingIndicator');
57
+
58
+ // Modal elements
59
+ const mdModal = document.getElementById('mdModal');
60
+ const mdBody = document.getElementById('mdBody');
61
+ const mdTitle = document.getElementById('mdTitle');
62
+ const mdClose = document.getElementById('mdClose');
63
+ const mdCopy = document.getElementById('mdCopy');
64
+
65
+ function log(...args){ const line = document.createElement('div'); line.textContent = '['+new Date().toLocaleTimeString()+'] ' + args.join(' '); logDiv.appendChild(line); logDiv.scrollTop = logDiv.scrollHeight; console.log(...args); }
66
+
67
+ // HTTP helpers
68
+ async function fetchJSON(url, opts){
69
+ // If the url is relative and doesn't start with '/', respect it; otherwise ensure we don't accidentally
70
+ // strip our computed API_BASE if callers pass absolute URLs. Expect callers to use API_BASE + '/path'.
71
+ const res = await fetch(url, opts);
72
+ const text = await res.text();
73
+ let json = null;
74
+ try{ json = text ? JSON.parse(text) : {}; }catch(e){ throw new Error('Invalid JSON from ' + url + ': ' + text); }
75
+ if(!res.ok) throw Object.assign(new Error('HTTP ' + res.status), {status: res.status, body: json});
76
+ return json;
77
+ }
78
+
79
+ // Load marked library dynamically
80
+ function loadMarked(){
81
+ return new Promise((resolve, reject)=>{
82
+ if(window.marked) return resolve(window.marked);
83
+ const s = document.createElement('script');
84
+ s.src = MARKED_CDN;
85
+ s.async = true;
86
+ s.onload = ()=>{ try{ return resolve(window.marked); }catch(e){ return reject(e); } };
87
+ s.onerror = (e)=> reject(new Error('Failed to load marked.js'));
88
+ document.head.appendChild(s);
89
+ });
90
+ }
91
+
92
+ // Parse/serialize
93
+ function parseTextToCells(text){
94
+ const lines = (text||'').split(/\r?\n/);
95
+ const out = [];
96
+ const headerRe = /^([A-Za-z][\w-]*):\s*(.*)$/;
97
+ let i = 0;
98
+ while(i < lines.length){
99
+ const ln = lines[i];
100
+ const m = ln.match(headerRe);
101
+ if(m){
102
+ const key = m[1].toLowerCase();
103
+ const rest = m[2] || '';
104
+ // collect all lines until the next header (exclusive)
105
+ const bodyLines = [];
106
+ // include the same-line rest (may be empty string)
107
+ bodyLines.push(rest);
108
+ let j = i + 1;
109
+ for(; j < lines.length; j++){
110
+ if(headerRe.test(lines[j])) break;
111
+ bodyLines.push(lines[j]);
112
+ }
113
+ // count non-empty lines in the block
114
+ const nonEmptyCount = bodyLines.reduce((acc, l) => acc + (l && l.trim().length>0 ? 1 : 0), 0);
115
+ const isInline = (nonEmptyCount <= 1);
116
+ let content;
117
+ if(isInline){
118
+ // inline blocks: remove line jumps and join non-empty pieces with a single space
119
+ const parts = bodyLines.map(l => l ? l.trim() : '').filter(s => s.length>0);
120
+ content = parts.join(' ');
121
+ } else {
122
+ // block: preserve lines (including empty lines)
123
+ content = bodyLines.join('\n');
124
+ // trim trailing newlines that come from join
125
+ content = content.replace(/\n+$/,'');
126
+ }
127
+
128
+ if(ROLE_ONLY.includes(key)){
129
+ out.push({type:'role', role:key, inline:isInline, content: content});
130
+ } else if(PREDEFINED_KEYS.includes(key)){
131
+ out.push({type:'option', key:key, inline:isInline, content: content});
132
+ } else {
133
+ // unknown headers treated as option-like blocks
134
+ out.push({type:'option', key:key, inline:isInline, content: content});
135
+ }
136
+ i = j;
137
+ continue;
138
+ }
139
+
140
+ // Lines not starting with a header: collect until next header and treat as a note block
141
+ let j = i;
142
+ const body = [];
143
+ while(j < lines.length && !headerRe.test(lines[j])){ body.push(lines[j]); j++; }
144
+ const nonEmptyCount = body.reduce((acc, l) => acc + (l && l.trim().length>0 ? 1 : 0), 0);
145
+ if(nonEmptyCount <= 1){
146
+ const parts = body.map(l => l ? l.trim() : '').filter(s => s.length>0);
147
+ const content = parts.join(' ');
148
+ if(content.length>0) out.push({type:'role', role:'note', inline:true, content: content});
149
+ } else {
150
+ out.push({type:'role', role:'note', inline:false, content: body.join('\n')});
151
+ }
152
+ i = j;
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function cellsToText(cells){
158
+ let lines = [];
159
+ cells.forEach(c=>{
160
+ if(c.type==='role'){
161
+ const role = c.role || 'user';
162
+ if(c.inline) lines.push(role + ': ' + (c.content || ''));
163
+ else lines.push(role + ':', (c.content || ''), '');
164
+ } else if(c.type==='option'){
165
+ const k = c.key || 'import';
166
+ if(c.inline) lines.push(k + ': ' + (c.content || ''));
167
+ else lines.push(k + ':', (c.content || ''), '');
168
+ }
169
+ });
170
+ return lines.join('\n').replace(/\n{3,}/g,'\n\n');
171
+ }
172
+
173
+ // Safe markdown render (strip scripts)
174
+ function sanitizeHtml(html){
175
+ return html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '');
176
+ }
177
+
178
+ function renderMarkdownSafe(md){
179
+ let html = '';
180
+ if(window.marked){
181
+ try{ html = marked.parse(md || ''); }catch(e){ html = escapeHtml(md || ''); }
182
+ } else {
183
+ html = '<pre style="white-space:pre-wrap">' + escapeHtml(md || '') + '</pre>';
184
+ }
185
+ html = sanitizeHtml(html);
186
+ return html;
187
+ }
188
+
189
+ // Render
190
+ function render(){
191
+ // Ensure there's always an empty user cell at the end (but don't force it into editing/focus)
192
+ ensureTrailingEmptyCell();
193
+
194
+ cellsDiv.innerHTML = '';
195
+ cells.forEach((c, idx)=>{
196
+ const el = document.createElement('div'); el.className='cell panel';
197
+
198
+ const header = document.createElement('div'); header.className='cell-header';
199
+
200
+ const roleBadge = document.createElement('div'); roleBadge.className = 'role-badge ' + (c.role||c.key||'');
201
+ roleBadge.textContent = (c.type==='role' ? (c.role||'user') : (c.key||'option'));
202
+ roleBadge.onclick = (e)=>{ e.stopPropagation(); changeRolePrompt(c); };
203
+ header.appendChild(roleBadge);
204
+
205
+ const controls = document.createElement('div'); controls.className = 'cell-controls';
206
+ controls.innerHTML = '<div class="cell-actions"></div>';
207
+ const actionsContainer = controls.querySelector('.cell-actions');
208
+
209
+ const up = document.createElement('button'); up.textContent='↑'; up.title='Move up'; up.onclick = (e)=>{ e.stopPropagation(); if(idx>0){ const t = cells[idx-1]; cells[idx-1]=cells[idx]; cells[idx]=t; updateAndRender(); } };
210
+ const down = document.createElement('button'); down.textContent='↓'; down.title='Move down'; down.onclick = (e)=>{ e.stopPropagation(); if(idx<cells.length-1){ const t = cells[idx+1]; cells[idx+1]=cells[idx]; cells[idx]=t; updateAndRender(); } };
211
+ const del = document.createElement('button'); del.textContent='✕'; del.title='Delete'; del.onclick = (e)=>{ e.stopPropagation(); if(confirm('Delete this cell?')){ cells.splice(idx,1); updateAndRender(); } };
212
+ const convert = document.createElement('button'); convert.className='small'; convert.textContent = (c.type==='option' ? 'opt' : (c.inline ? '→block' : '→inline'));
213
+ convert.onclick = (e)=>{ e.stopPropagation(); if(c.type==='option') return; c.inline = !c.inline; updateAndRender(); };
214
+ const editBtn = document.createElement('button'); editBtn.className='small'; editBtn.textContent='edit'; editBtn.title='Edit'; editBtn.onclick = (e)=>{ e.stopPropagation(); startEditing(c, idx); };
215
+
216
+ actionsContainer.appendChild(up); actionsContainer.appendChild(down); actionsContainer.appendChild(convert); actionsContainer.appendChild(editBtn); actionsContainer.appendChild(del);
217
+
218
+ header.appendChild(controls);
219
+ el.appendChild(header);
220
+
221
+ // content area
222
+ const contentWrap = document.createElement('div'); contentWrap.style.width='100%';
223
+
224
+ // If not editing and this is an assistant role, render markdown as HTML; other roles show plain text preview
225
+ if(!c.editing){
226
+ if(c.type==='role' && (c.role||'') === 'assistant'){
227
+ const preview = document.createElement('div'); preview.className = 'assistant-bubble message-preview';
228
+ preview.innerHTML = renderMarkdownSafe(c.content || '');
229
+ // clicking preview starts editing that cell
230
+ preview.onclick = (e)=>{ e.stopPropagation(); startEditing(c, idx); };
231
+ contentWrap.appendChild(preview);
232
+ } else {
233
+ // render plain preview text (for user/system/note and options)
234
+ const preview = document.createElement('div'); preview.className = ((c.type==='role' && (c.role||'')==='user')? 'user-bubble message-preview' : 'message-preview');
235
+ preview.textContent = c.content || '';
236
+ preview.onclick = (e)=>{ e.stopPropagation(); startEditing(c, idx); };
237
+ contentWrap.appendChild(preview);
238
+ }
239
+ } else {
240
+ // editing mode: show input or textarea depending on inline
241
+ if(c.inline){
242
+ const input = document.createElement('input'); input.className='cell-input'; input.value = c.content || '';
243
+ if(c.type==='option' && FILE_KEYS.includes((c.key||'').toLowerCase())){ input.setAttribute('list','files_datalist'); }
244
+ input.oninput = (e)=>{ c.content = input.value; // when typing in an editing cell, maintain trailing empty cell
245
+ if(idx === cells.length - 1) ensureTrailingEmptyCell(); };
246
+ input.onblur = ()=>{ c.editing = false; updateAndRender(); };
247
+ input.onkeydown = (e)=>{ if(e.key === 'Enter' && (e.metaKey || e.ctrlKey)){ input.blur(); } };
248
+ contentWrap.appendChild(input);
249
+ // autofocus only if this cell was explicitly put into editing mode
250
+ if(c._shouldFocus){ setTimeout(()=>{ input.focus(); input.selectionStart = input.value.length; c._shouldFocus = false; }, 0); }
251
+ } else {
252
+ const ta = document.createElement('textarea'); ta.className='cell-text'; ta.value = c.content || '';
253
+ ta.oninput = (e)=>{ c.content = ta.value; if(idx === cells.length - 1) ensureTrailingEmptyCell(); };
254
+ ta.onblur = ()=>{ c.editing = false; updateAndRender(); };
255
+ ta.onkeydown = (e)=>{ if(e.key === 'Enter' && (e.metaKey || e.ctrlKey)){ ta.blur(); } };
256
+ contentWrap.appendChild(ta);
257
+ if(c._shouldFocus){ setTimeout(()=>{ ta.focus(); ta.selectionStart = ta.value.length; c._shouldFocus = false; }, 0); }
258
+ }
259
+ }
260
+
261
+ el.appendChild(contentWrap);
262
+ cellsDiv.appendChild(el);
263
+ });
264
+ cellCountEl.textContent = cells.length;
265
+ renderDatalists();
266
+ }
267
+
268
+ function startEditing(c, idx){ c.editing = true; c._shouldFocus = true; updateAndRender(); }
269
+
270
+ // Always keep a trailing empty user cell, but do not force it into editing/focus when unrelated actions occur
271
+ function ensureTrailingEmptyCell(){
272
+ if(cells.length === 0){
273
+ cells.push({type:'role', role:'user', inline:false, content:'', editing:false});
274
+ return;
275
+ }
276
+ const last = cells[cells.length - 1];
277
+ if(!(last.type === 'role' && (last.role || '') === 'user' && (!last.content || last.content.trim() === ''))){
278
+ // append a non-editing empty user cell
279
+ cells.push({type:'role', role:'user', inline:false, content:'', editing:false});
280
+ }
281
+ // do not change editing state of existing cells
282
+ }
283
+
284
+ function updateAndRender(){ render(); }
285
+
286
+ // Actions
287
+ function addCell(role){ if(ROLE_ONLY.includes(role)) cells.push({type:'role', role:role, inline:false, content:'', editing:false}); else cells.push({type:'option', key:role, inline:true, content:'', editing:false}); updateAndRender(); }
288
+ function addOptionCell(){ cells.push({type:'option', key:'import', inline:true, content:'', editing:false}); updateAndRender(); }
289
+ function clearCells(){ if(confirm('Clear all cells?')){ cells=[]; ensureTrailingEmptyCell(); updateAndRender(); } }
290
+
291
+ // role change prompt (simple)
292
+ function changeRolePrompt(c){ const ans = prompt('Set role (user, system, assistant, or option key):', (c.type==='role'? c.role : c.key)); if(ans===null) return; const v = ans.trim().toLowerCase(); if(ROLE_ONLY.includes(v)){ c.type='role'; c.role=v; c.key=undefined; } else if(v.length===0){ return; } else { c.type='option'; c.key=v; c.role=undefined; } updateAndRender(); }
293
+
294
+ // Server interactions
295
+ async function renderFileList(){
296
+ try{
297
+ const res = await fetchJSON((API_BASE || '') + '/list');
298
+ const wsFiles = res.files || [];
299
+ filesListCache = wsFiles.slice();
300
+ const prevScroll = filesDiv.scrollTop;
301
+ filesDiv.innerHTML = '';
302
+ if(wsFiles.length===0) filesDiv.innerHTML = '<div class="small muted">(no files)</div>';
303
+ wsFiles.forEach(k=>{
304
+ const div = document.createElement('div'); div.className='file-item';
305
+ const a = document.createElement('a'); a.href='#'; a.textContent = k; a.onclick = (e)=>{ e.preventDefault(); pathEl.value=k; loadFile(); };
306
+ const meta = document.createElement('div'); meta.className='small muted'; meta.textContent = '';
307
+ div.appendChild(a); div.appendChild(meta); filesDiv.appendChild(div);
308
+ });
309
+
310
+ // update datalist for files
311
+ renderDatalists(wsFiles);
312
+ filesDiv.scrollTop = prevScroll;
313
+ log('Listed', wsFiles.length, 'files');
314
+ }catch(e){ log('Error listing files:', e.message || e); filesDiv.innerHTML = '<div class="small muted">(error)</div>'; renderDatalists([]); }
315
+ }
316
+
317
+ function renderDatalists(filesList){
318
+ if(typeof filesList === 'undefined') filesList = filesListCache || [];
319
+
320
+ const oldKeys = document.getElementById('keys_datalist'); if(oldKeys) oldKeys.remove();
321
+ const oldFiles = document.getElementById('files_datalist'); if(oldFiles) oldFiles.remove();
322
+
323
+ const keys = document.createElement('datalist'); keys.id='keys_datalist';
324
+ PREDEFINED_KEYS.forEach(k=>{ const o=document.createElement('option'); o.value=k; keys.appendChild(o); });
325
+ document.body.appendChild(keys);
326
+
327
+ const files = document.createElement('datalist'); files.id='files_datalist';
328
+ (filesList || []).slice().sort().forEach(k=>{ const o=document.createElement('option'); o.value=k; files.appendChild(o); });
329
+ document.body.appendChild(files);
330
+
331
+ if(pathEl){ pathEl.setAttribute('list','files_datalist'); }
332
+ }
333
+
334
+ async function saveFile(){ const p = pathEl.value.trim(); if(!p){ alert('Enter path'); return; } const text = cellsToText(cells);
335
+ try{
336
+ const res = await fetchJSON((API_BASE || '') + '/save', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({path: p, content: text})});
337
+ await renderFileList(); log('Saved ' + p);
338
+ }catch(e){ log('Save failed:', e.message || e); alert('Save failed: ' + (e.message || e)); }
339
+ }
340
+
341
+ async function loadFile(){ const p = pathEl.value.trim(); if(!p){ alert('Enter path'); return; } try{
342
+ const res = await fetchJSON((API_BASE || '') + '/load?path=' + encodeURIComponent(p));
343
+ const txt = res.content || '';
344
+ cells = parseTextToCells(txt);
345
+ // after loading, ensure trailing empty cell
346
+ ensureTrailingEmptyCell();
347
+ updateAndRender(); log('Loaded ' + p);
348
+ }catch(e){ log('Load failed:', e.message || e); alert('Load failed: ' + (e.body && e.body.error) ? e.body.error : (e.message || 'error')); }
349
+ }
350
+
351
+ async function newFile(){ const p = pathEl.value.trim(); if(!p){ alert('Enter path'); return; } if(!confirm('Create new file at: ' + p + ' ?')) return; cells = []; try{ await saveFile(); }catch(e){} }
352
+
353
+ // Truncate file content (server has no delete endpoint in this simple server)
354
+ async function deletePath(){ const p = pathEl.value.trim(); if(!p){ alert('Enter path'); return; } if(!confirm('Truncate ' + p + ' ? This will clear the file content.')) return; try{
355
+ const res = await fetchJSON((API_BASE || '') + '/save', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({path: p, content: ''})});
356
+ await renderFileList(); log('Truncated ' + p);
357
+ }catch(e){ log('Truncate failed:', e.message || e); alert('Truncate failed: ' + (e.message || e)); }
358
+ }
359
+
360
+ async function runFile(){ const p = pathEl.value.trim(); if(!p){ alert('Enter path'); return; } const text = cellsToText(cells);
361
+ // show loading state
362
+ setLoading(true);
363
+ try{
364
+ const res = await fetchJSON((API_BASE || '') + '/run', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({path: p, content: text})});
365
+ const newText = res.content || '';
366
+ cells = parseTextToCells(newText);
367
+ // After running, ensure trailing empty cell
368
+ ensureTrailingEmptyCell();
369
+ updateAndRender(); await renderFileList(); log('Ran and appended assistant reply to ' + p);
370
+ }catch(e){ log('Run failed:', e.message || e); alert('Run failed: ' + (e.message || e)); }
371
+ finally{ setLoading(false); }
372
+ }
373
+
374
+ function exportText(){ const t = cellsToText(cells); const w = window.open(); w.document.body.style.background='#fff'; w.document.title='Exported Chat'; const pre = w.document.createElement('pre'); pre.textContent = t; pre.style.whiteSpace='pre-wrap'; pre.style.fontFamily='monospace'; pre.style.padding='16px'; w.document.body.appendChild(pre); }
375
+
376
+ // Use marked for markdown rendering. If not available, fallback to a safe escaped render.
377
+ function escapeHtml(s){ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
378
+
379
+ // Updated: showMarkdownModal now ensures the generated HTML is inserted as real DOM using marked if available
380
+ function showMarkdownModal(md, title){
381
+ mdTitle.textContent = title || 'Preview';
382
+ let html = '';
383
+ if(window.marked){
384
+ try{ html = marked.parse(md || ''); }catch(e){ html = escapeHtml(md || ''); }
385
+ } else {
386
+ html = '<pre style="white-space:pre-wrap">' + escapeHtml(md || '') + '</pre>';
387
+ loadMarked().then(m=>{ /* noop - next previews will use marked */ }).catch(e=>{ console.warn('Could not load marked:', e); });
388
+ }
389
+
390
+ html = html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, '');
391
+
392
+ mdBody.innerHTML = html;
393
+ mdBody.style.whiteSpace = 'normal';
394
+ mdModal.style.display = 'flex';
395
+ mdModal.setAttribute('aria-hidden','false');
396
+ }
397
+ function hideMarkdownModal(){ mdModal.style.display = 'none'; mdModal.setAttribute('aria-hidden','true'); }
398
+ mdClose.onclick = hideMarkdownModal;
399
+ mdModal.onclick = function(e){ if(e.target === mdModal) hideMarkdownModal(); };
400
+
401
+ // Copy modal content to clipboard (HTML + text) so it can be pasted into Word with formatting
402
+ async function copyMarkdownModal(){
403
+ const html = mdBody.innerHTML || '';
404
+ const text = mdBody.innerText || mdBody.textContent || '';
405
+ if(!navigator.clipboard){
406
+ try{
407
+ const temp = document.createElement('div'); temp.style.position='fixed'; temp.style.left='-10000px'; temp.innerText = text; document.body.appendChild(temp);
408
+ const range = document.createRange(); range.selectNodeContents(temp);
409
+ const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range);
410
+ document.execCommand('copy');
411
+ sel.removeAllRanges(); document.body.removeChild(temp);
412
+ log('Copied modal content as plain text (execCommand fallback)');
413
+ flashCopyBtn();
414
+ return;
415
+ }catch(e){ alert('Copy not supported: ' + e.message); return; }
416
+ }
417
+
418
+ if(window.ClipboardItem){
419
+ try{
420
+ const blobHtml = new Blob([html], {type: 'text/html'});
421
+ const blobText = new Blob([text], {type: 'text/plain'});
422
+ const item = new ClipboardItem({'text/html': blobHtml, 'text/plain': blobText});
423
+ await navigator.clipboard.write([item]);
424
+ log('Copied modal content (HTML + text)');
425
+ flashCopyBtn();
426
+ return;
427
+ }catch(e){ console.warn('ClipboardItem write failed, falling back to plain text:', e); }
428
+ }
429
+
430
+ try{
431
+ await navigator.clipboard.writeText(text);
432
+ log('Copied modal content as plain text (fallback)');
433
+ flashCopyBtn();
434
+ }catch(e){ alert('Copy failed: ' + e.message); }
435
+ }
436
+
437
+ function flashCopyBtn(){ if(!mdCopy) return; const old = mdCopy.textContent; mdCopy.textContent = 'Copied!'; mdCopy.disabled = true; setTimeout(()=>{ mdCopy.textContent = old; mdCopy.disabled = false; }, 1500); }
438
+
439
+ if(mdCopy) mdCopy.onclick = copyMarkdownModal;
440
+
441
+ function setLoading(on){ if(on){ loadingIndicator.style.display='inline-flex'; runBtn.disabled = true; runBtnBottom.disabled = true; saveBtn.disabled = true; } else { loadingIndicator.style.display='none'; runBtn.disabled = false; runBtnBottom.disabled = false; saveBtn.disabled = false; } }
442
+
443
+ // Wire events
444
+ newFileBtn.onclick = newFile;
445
+ loadBtn.onclick = loadFile;
446
+ saveBtn.onclick = saveFile;
447
+ runBtn.onclick = runFile;
448
+ runBtnBottom.onclick = runFile;
449
+ exportBtn.onclick = exportText;
450
+ listBtn && (listBtn.onclick = renderFileList);
451
+ deleteBtn && (deleteBtn.onclick = deletePath);
452
+ addUser.onclick = ()=>addCell('user');
453
+ addSystem.onclick = ()=>addCell('system');
454
+ addAssistant.onclick = ()=>addCell('assistant');
455
+ addOption.onclick = ()=>addOptionCell();
456
+ clearBtn.onclick = clearCells;
457
+
458
+ // Init
459
+ (async function init(){
460
+ await renderFileList();
461
+ try{ await loadMarked(); log('marked.js loaded'); }catch(e){ log('marked.js not available, falling back to plain preview'); }
462
+ cells = [{type:'role', role:'system', inline:false, content:'You are a helpful assistant.'}, {type:'role', role:'user', inline:false, content:'Tell me about genome editing.'}];
463
+ ensureTrailingEmptyCell();
464
+ updateAndRender();
465
+ log('Notebook editor ready (server-backed).');
466
+ })();
467
+
468
+ })();
@@ -0,0 +1,134 @@
1
+ require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
2
+ require File.expand_path(__FILE__).sub(%r(.*/test/), '').sub(/test_(.*)\.rb/,'\1')
3
+
4
+ class TestLLMAnthropic < Test::Unit::TestCase
5
+ def _test_say_hi
6
+ prompt =<<-EOF
7
+ user: say hi
8
+ EOF
9
+ sss 0
10
+ ppp LLM::Anthropic.ask prompt
11
+ end
12
+
13
+ def _test_ask
14
+ prompt =<<-EOF
15
+ user: write a script that sorts files in a directory
16
+ EOF
17
+ sss 0
18
+ ppp LLM::Anthropic.ask prompt
19
+ end
20
+
21
+ def __test_embeddings
22
+ Log.severity = 0
23
+ text =<<-EOF
24
+ Some text
25
+ EOF
26
+ emb = LLM::Anthropic.embed text, log_errors: true, model: 'embedding-model'
27
+
28
+ assert(Float === emb.first)
29
+ end
30
+
31
+ def _test_tool_call_output_2
32
+ Log.severity = 0
33
+ prompt =<<-EOF
34
+ function_call:
35
+
36
+ {"name":"get_current_temperature", "arguments":{"location":"London","unit":"Celsius"},"id":"tNTnsQq2s6jGh0npOh43AwDD"}
37
+
38
+ function_call_output:
39
+
40
+ {"id":"tNTnsQq2s6jGh0npOh43AwDD", "content":"It's 15 degrees and raining."}
41
+
42
+ user:
43
+
44
+ should i take an umbrella?
45
+ EOF
46
+ ppp LLM::Anthropic.ask prompt
47
+ end
48
+
49
+ def _test_tool_call_output_features
50
+ Log.severity = 0
51
+ prompt =<<-EOF
52
+ function_call:
53
+
54
+ {"name":"Baking-bake_muffin_tray","arguments":{},"id":"Baking_bake_muffin_tray_Default"}
55
+
56
+ function_call_output:
57
+
58
+ {"id":"Baking_bake_muffin_tray_Default","content":"Baking batter (Mixing base (Whisking eggs from share/pantry/eggs) with mixer (share/pantry/flour))"}
59
+
60
+ user:
61
+
62
+ How do you bake muffins, according to the tool I provided you. Don't
63
+ tell me the recipe you already know, use the tool call output. Let me
64
+ know if you didn't get it.
65
+ EOF
66
+ ppp LLM::Anthropic.ask prompt
67
+ end
68
+
69
+ def _test_tool_call_output_weather
70
+ Log.severity = 0
71
+ prompt =<<-EOF
72
+ function_call:
73
+
74
+ {"name":"get_current_temperature", "arguments":{"location":"London","unit":"Celsius"},"id":"tNTnsQq2s6jGh0npOh43AwDD"}
75
+
76
+ function_call_output:
77
+
78
+ {"id":"tNTnsQq2s6jGh0npOh43AwDD", "content":"It's 15 degrees and raining."}
79
+
80
+ user:
81
+
82
+ should i take an umbrella?
83
+ EOF
84
+ ppp LLM::Anthropic.ask prompt
85
+ end
86
+
87
+ def _test_tool
88
+ prompt =<<-EOF
89
+ user:
90
+ What is the weather in London. Should I take my umbrella?
91
+ EOF
92
+
93
+ tools = [
94
+ {
95
+ "type": "custom",
96
+ "name": "get_current_temperature",
97
+ "description": "Get the current temperature and raining conditions for a specific location",
98
+ "parameters": {
99
+ "type": "object",
100
+ "properties": {
101
+ "location": {
102
+ "type": "string",
103
+ "description": "The city and state, e.g., San Francisco, CA"
104
+ },
105
+ "unit": {
106
+ "type": "string",
107
+ "enum": ["Celsius", "Fahrenheit"],
108
+ "description": "The temperature unit to use. Infer this from the user's location."
109
+ }
110
+ },
111
+ "required": ["location", "unit"]
112
+ }
113
+ },
114
+ ]
115
+
116
+ sss 0
117
+ respose = LLM::Anthropic.ask prompt, tools: tools, log_errors: true do |name,arguments|
118
+ "It's 15 degrees and raining."
119
+ end
120
+
121
+ ppp respose
122
+ end
123
+
124
+ def test_json_output
125
+ prompt =<<-EOF
126
+ user:
127
+
128
+ What other movies have the protagonists of the original gost busters played on, just the top.
129
+ EOF
130
+ sss 0
131
+ ppp LLM::Anthropic.ask prompt, format: :json
132
+ end
133
+ end
134
+