sanook-cli 0.5.7 → 0.5.8
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.
- package/CHANGELOG.md +34 -0
- package/README.md +392 -42
- package/README.th.md +15 -8
- package/dist/auto-maintain.js +113 -0
- package/dist/bin.js +63 -15
- package/dist/brain-final.js +8 -4
- package/dist/brain-new.js +9 -5
- package/dist/brain-repair.js +7 -4
- package/dist/brand.js +17 -0
- package/dist/commands.js +3 -0
- package/dist/config.js +5 -0
- package/dist/dashboard/api-helpers.js +112 -3
- package/dist/dashboard/server.js +85 -1
- package/dist/dashboard/static/app.js +381 -0
- package/dist/dashboard/static/styles.css +36 -0
- package/dist/dashboard/terminal.js +214 -0
- package/dist/diff.js +22 -8
- package/dist/install-info.js +91 -0
- package/dist/memory.js +236 -16
- package/dist/persona.js +300 -0
- package/dist/project-scaffold.js +4 -2
- package/dist/self-improve-synth.js +86 -0
- package/dist/self-improve.js +203 -0
- package/dist/session-brain.js +10 -1
- package/dist/slash-completion.js +1 -0
- package/dist/ui/app.js +118 -27
- package/dist/ui/input-view.js +104 -0
- package/dist/ui/persona-wizard.js +89 -0
- package/dist/ui/render.js +78 -5
- package/dist/ui/tool-activity.js +118 -0
- package/dist/ui/tool-trail.js +15 -3
- package/package.json +9 -1
|
@@ -4,9 +4,15 @@ const I18N = {
|
|
|
4
4
|
tagline: 'Configure models, sessions, MCP, gateway, and your second brain',
|
|
5
5
|
nav: {
|
|
6
6
|
home: 'Home',
|
|
7
|
+
terminal: 'Terminal',
|
|
7
8
|
chat: 'Chat',
|
|
8
9
|
models: 'Models',
|
|
9
10
|
sessions: 'Sessions',
|
|
11
|
+
skills: 'Skills',
|
|
12
|
+
memory: 'Memory',
|
|
13
|
+
persona: 'Persona',
|
|
14
|
+
usage: 'Usage',
|
|
15
|
+
selfimprove: 'Self-improve',
|
|
10
16
|
files: 'Files',
|
|
11
17
|
logs: 'Logs',
|
|
12
18
|
cron: 'Cron',
|
|
@@ -14,7 +20,25 @@ const I18N = {
|
|
|
14
20
|
config: 'Config',
|
|
15
21
|
mcp: 'MCP',
|
|
16
22
|
brain: 'Brain',
|
|
23
|
+
install: 'Install',
|
|
17
24
|
},
|
|
25
|
+
terminal: {
|
|
26
|
+
title: 'Web terminal',
|
|
27
|
+
hint: 'Run Sanook AI right here — streams text, tools, 🧠 memory and ✨ skills like the real REPL.',
|
|
28
|
+
agent: 'Agent console',
|
|
29
|
+
shell: 'Raw shell',
|
|
30
|
+
run: 'Run',
|
|
31
|
+
stop: 'Stop',
|
|
32
|
+
placeholder: 'Type a prompt and press Enter (Shift+Enter = newline)…',
|
|
33
|
+
shellOff: 'Raw shell is disabled. Install optional deps to enable: npm i node-pty ws',
|
|
34
|
+
thinking: 'thinking…',
|
|
35
|
+
},
|
|
36
|
+
skills: { title: 'Skills', empty: 'No skills yet — Sanook will auto-create them from repeated tasks.', auto: 'auto', when: 'When to use' },
|
|
37
|
+
memory: { title: 'Memory', empty: 'No remembered facts yet. Ask Sanook to remember something.', brain: 'Also synced to second brain', importance: 'importance' },
|
|
38
|
+
persona: { title: 'Persona', empty: 'No persona yet — run sanook persona in your terminal.', edit: 'Update in terminal', profile: 'Profile path', rows: 'Your answers' },
|
|
39
|
+
usage: { title: 'Usage & cost', empty: 'No usage recorded yet.', turns: 'turns', tokens: 'tokens', cost: 'cost', daily: 'Daily breakdown' },
|
|
40
|
+
selfimprove: { title: 'Self-improvement', empty: 'No recurring tasks detected yet.', enabled: 'enabled', disabled: 'disabled', threshold: 'threshold', repeats: 'repeats', skill: 'skill' },
|
|
41
|
+
install: { title: 'Install Sanook CLI', ready: 'Ready', soon: 'Needs infra' },
|
|
18
42
|
home: {
|
|
19
43
|
title: 'System status',
|
|
20
44
|
cliVersion: 'CLI version',
|
|
@@ -42,9 +66,15 @@ const I18N = {
|
|
|
42
66
|
tagline: 'จัดการ model, session, MCP, gateway และ second brain',
|
|
43
67
|
nav: {
|
|
44
68
|
home: 'หน้าแรก',
|
|
69
|
+
terminal: 'เทอร์มินอล',
|
|
45
70
|
chat: 'Chat',
|
|
46
71
|
models: 'Models',
|
|
47
72
|
sessions: 'Sessions',
|
|
73
|
+
skills: 'Skills',
|
|
74
|
+
memory: 'Memory',
|
|
75
|
+
persona: 'Persona',
|
|
76
|
+
usage: 'การใช้งาน',
|
|
77
|
+
selfimprove: 'เรียนรู้เอง',
|
|
48
78
|
files: 'Files',
|
|
49
79
|
logs: 'Logs',
|
|
50
80
|
cron: 'Cron',
|
|
@@ -52,7 +82,25 @@ const I18N = {
|
|
|
52
82
|
config: 'Config',
|
|
53
83
|
mcp: 'MCP',
|
|
54
84
|
brain: 'Brain',
|
|
85
|
+
install: 'ติดตั้ง',
|
|
86
|
+
},
|
|
87
|
+
terminal: {
|
|
88
|
+
title: 'เทอร์มินอลในเว็บ',
|
|
89
|
+
hint: 'รัน Sanook AI ได้เลยในเว็บ — สตรีมข้อความ, tool, 🧠 ความจำ และ ✨ skill เหมือน REPL จริง',
|
|
90
|
+
agent: 'Agent console',
|
|
91
|
+
shell: 'Raw shell',
|
|
92
|
+
run: 'รัน',
|
|
93
|
+
stop: 'หยุด',
|
|
94
|
+
placeholder: 'พิมพ์คำสั่งแล้วกด Enter (Shift+Enter = ขึ้นบรรทัดใหม่)…',
|
|
95
|
+
shellOff: 'Raw shell ปิดอยู่ — ติดตั้ง dependency เสริมเพื่อเปิด: npm i node-pty ws',
|
|
96
|
+
thinking: 'กำลังคิด…',
|
|
55
97
|
},
|
|
98
|
+
skills: { title: 'Skills', empty: 'ยังไม่มี skill — Sanook จะสร้างให้อัตโนมัติจากงานที่ทำซ้ำ', auto: 'อัตโนมัติ', when: 'ใช้เมื่อ' },
|
|
99
|
+
memory: { title: 'ความจำ', empty: 'ยังไม่มีสิ่งที่จำไว้ — ลองสั่งให้ Sanook จำอะไรดู', brain: 'sync เข้า second brain ด้วย', importance: 'ความสำคัญ' },
|
|
100
|
+
persona: { title: 'Persona — โปรไฟล์เจ้าของ', empty: 'ยังไม่มี persona — รัน sanook persona ใน terminal', edit: 'อัปเดตใน terminal', profile: 'ไฟล์โปรไฟล์', rows: 'คำตอบของคุณ' },
|
|
101
|
+
usage: { title: 'การใช้งาน & ค่าใช้จ่าย', empty: 'ยังไม่มีข้อมูลการใช้งาน', turns: 'turn', tokens: 'tokens', cost: 'ค่าใช้จ่าย', daily: 'แยกตามวัน' },
|
|
102
|
+
selfimprove: { title: 'การเรียนรู้เอง (Self-improvement)', empty: 'ยังไม่เจองานที่ทำซ้ำ', enabled: 'เปิด', disabled: 'ปิด', threshold: 'เกณฑ์', repeats: 'ครั้ง', skill: 'skill' },
|
|
103
|
+
install: { title: 'ติดตั้ง Sanook CLI', ready: 'พร้อมใช้', soon: 'ต้องตั้ง infra' },
|
|
56
104
|
home: {
|
|
57
105
|
title: 'สถานะระบบ',
|
|
58
106
|
cliVersion: 'เวอร์ชัน CLI',
|
|
@@ -76,9 +124,15 @@ const I18N = {
|
|
|
76
124
|
|
|
77
125
|
const routes = [
|
|
78
126
|
{ id: 'home', path: '#/' },
|
|
127
|
+
{ id: 'terminal', path: '#/terminal' },
|
|
79
128
|
{ id: 'chat', path: '#/chat' },
|
|
80
129
|
{ id: 'models', path: '#/models' },
|
|
81
130
|
{ id: 'sessions', path: '#/sessions' },
|
|
131
|
+
{ id: 'skills', path: '#/skills' },
|
|
132
|
+
{ id: 'memory', path: '#/memory' },
|
|
133
|
+
{ id: 'persona', path: '#/persona' },
|
|
134
|
+
{ id: 'usage', path: '#/usage' },
|
|
135
|
+
{ id: 'selfimprove', path: '#/selfimprove' },
|
|
82
136
|
{ id: 'files', path: '#/files' },
|
|
83
137
|
{ id: 'logs', path: '#/logs' },
|
|
84
138
|
{ id: 'cron', path: '#/cron' },
|
|
@@ -86,9 +140,17 @@ const routes = [
|
|
|
86
140
|
{ id: 'config', path: '#/config' },
|
|
87
141
|
{ id: 'mcp', path: '#/mcp' },
|
|
88
142
|
{ id: 'brain', path: '#/brain' },
|
|
143
|
+
{ id: 'install', path: '#/install' },
|
|
89
144
|
];
|
|
90
145
|
|
|
146
|
+
function escapeHtml(s) {
|
|
147
|
+
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
148
|
+
}
|
|
149
|
+
|
|
91
150
|
let filesPath = '';
|
|
151
|
+
let termSessionId = localStorage.getItem('sanook-term-session') || `web-${Math.random().toString(36).slice(2, 10)}`;
|
|
152
|
+
localStorage.setItem('sanook-term-session', termSessionId);
|
|
153
|
+
let termAbort = null;
|
|
92
154
|
|
|
93
155
|
function locale() {
|
|
94
156
|
return localStorage.getItem('sanook-dashboard-locale') || 'en';
|
|
@@ -239,6 +301,318 @@ async function renderBrain(page) {
|
|
|
239
301
|
<p>${brainPath ?? `<span class="hint">${t('brain.empty')}</span>`}</p></div>`;
|
|
240
302
|
}
|
|
241
303
|
|
|
304
|
+
async function renderSkills(page) {
|
|
305
|
+
const { skills } = await api('/api/skills');
|
|
306
|
+
if (!skills?.length) {
|
|
307
|
+
page.innerHTML = `<div class="card"><h2>${t('skills.title')}</h2><p class="hint">${t('skills.empty')}</p></div>`;
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const cards = skills
|
|
311
|
+
.map((s) => `<div class="card" style="margin-bottom:10px;">
|
|
312
|
+
<h3 style="margin:0 0 4px;">${escapeHtml(s.name)} ${s.auto ? `<span class="pill" style="background:#1e3a2f;color:#7ee0a8;">✨ ${t('skills.auto')}</span>` : ''}</h3>
|
|
313
|
+
<p style="margin:0 0 6px;">${escapeHtml(s.description)}</p>
|
|
314
|
+
${s.whenToUse ? `<p class="hint" style="margin:0;">${t('skills.when')}: ${escapeHtml(s.whenToUse)}</p>` : ''}
|
|
315
|
+
</div>`)
|
|
316
|
+
.join('');
|
|
317
|
+
page.innerHTML = `<h2 style="margin:0 0 12px;">${t('skills.title')} <span class="pill">${skills.length}</span></h2>${cards}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function renderMemory(page) {
|
|
321
|
+
const { facts, brainPath } = await api('/api/memory');
|
|
322
|
+
if (!facts?.length) {
|
|
323
|
+
page.innerHTML = `<div class="card"><h2>🧠 ${t('memory.title')}</h2><p class="hint">${t('memory.empty')}</p></div>`;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const rows = facts
|
|
327
|
+
.map((f) => `<tr>
|
|
328
|
+
<td>${escapeHtml(f.text)}</td>
|
|
329
|
+
<td><span class="pill">${escapeHtml(f.noteType)}</span></td>
|
|
330
|
+
<td>${escapeHtml(f.trust)}${f.tier === 'protected' ? ' 🔒' : ''}</td>
|
|
331
|
+
<td>${f.importance}</td>
|
|
332
|
+
</tr>`)
|
|
333
|
+
.join('');
|
|
334
|
+
page.innerHTML = `<div class="card"><h2>🧠 ${t('memory.title')} <span class="pill">${facts.length}</span></h2>
|
|
335
|
+
${brainPath ? `<p class="hint">${t('memory.brain')}: ${escapeHtml(brainPath)}</p>` : ''}
|
|
336
|
+
<table class="table"><thead><tr><th>fact</th><th>type</th><th>trust</th><th>${t('memory.importance')}</th></tr></thead>
|
|
337
|
+
<tbody>${rows}</tbody></table></div>`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function renderPersona(page) {
|
|
341
|
+
const data = await api('/api/persona');
|
|
342
|
+
if (!data.hasProfile) {
|
|
343
|
+
page.innerHTML = `<div class="card"><h2>🪪 ${t('persona.title')}</h2>
|
|
344
|
+
<p class="hint">${t('persona.empty')}</p>
|
|
345
|
+
<p style="margin-top:12px;"><code>${escapeHtml(data.cliCommand)}</code></p>
|
|
346
|
+
<p class="hint" style="margin-top:8px;">หรือพิมพ์ <code>/persona</code> ใน REPL</p></div>`;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const rows = data.rows
|
|
350
|
+
.filter((r) => r.value)
|
|
351
|
+
.map((r) => `<tr><td>${escapeHtml(r.label)}</td><td>${escapeHtml(r.display)}</td></tr>`)
|
|
352
|
+
.join('');
|
|
353
|
+
page.innerHTML = `<div class="card"><h2>🪪 ${t('persona.title')}</h2>
|
|
354
|
+
${data.profilePath ? `<p class="hint">${t('persona.profile')}: ${escapeHtml(data.profilePath)}</p>` : ''}
|
|
355
|
+
<p class="hint">${t('persona.edit')}: <code>${escapeHtml(data.cliCommand)}</code> · <code>/persona</code></p>
|
|
356
|
+
<h3 style="margin:16px 0 8px;">${t('persona.rows')}</h3>
|
|
357
|
+
<table class="table"><thead><tr><th>หัวข้อ</th><th>ค่า</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function renderUsage(page) {
|
|
361
|
+
const { totals, daily } = await api('/api/usage');
|
|
362
|
+
if (!totals || totals.turns === 0) {
|
|
363
|
+
page.innerHTML = `<div class="card"><h2>💰 ${t('usage.title')}</h2><p class="hint">${t('usage.empty')}</p></div>`;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const fmt = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
|
|
367
|
+
const rows = daily
|
|
368
|
+
.map((d) => `<tr><td>${escapeHtml(d.label)}</td><td>${d.turns}</td><td>${fmt(d.totalTokens)}</td><td>$${d.costUsd.toFixed(4)}</td></tr>`)
|
|
369
|
+
.reverse()
|
|
370
|
+
.join('');
|
|
371
|
+
page.innerHTML = `<div class="card"><h2>💰 ${t('usage.title')}</h2>
|
|
372
|
+
<dl class="kv">
|
|
373
|
+
<dt>${t('usage.turns')}</dt><dd><span class="pill">${totals.turns}</span></dd>
|
|
374
|
+
<dt>${t('usage.tokens')}</dt><dd>${fmt(totals.totalTokens)}</dd>
|
|
375
|
+
<dt>${t('usage.cost')}</dt><dd><strong>$${totals.costUsd.toFixed(4)}</strong></dd>
|
|
376
|
+
</dl></div>
|
|
377
|
+
<div class="card"><h3>${t('usage.daily')}</h3>
|
|
378
|
+
<table class="table"><thead><tr><th>date</th><th>${t('usage.turns')}</th><th>${t('usage.tokens')}</th><th>${t('usage.cost')}</th></tr></thead>
|
|
379
|
+
<tbody>${rows}</tbody></table></div>`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function renderSelfimprove(page) {
|
|
383
|
+
const data = await api('/api/self-improve');
|
|
384
|
+
const head = `<div class="card"><h2>📈 ${t('selfimprove.title')}</h2>
|
|
385
|
+
<dl class="kv">
|
|
386
|
+
<dt>status</dt><dd><span class="pill" style="background:${data.enabled ? '#1e3a2f' : '#3a1e1e'};color:${data.enabled ? '#7ee0a8' : '#e0a8a8'};">${data.enabled ? t('selfimprove.enabled') : t('selfimprove.disabled')}</span></dd>
|
|
387
|
+
<dt>${t('selfimprove.threshold')}</dt><dd>${data.threshold} ${t('selfimprove.repeats')}</dd>
|
|
388
|
+
</dl></div>`;
|
|
389
|
+
if (!data.families?.length) {
|
|
390
|
+
page.innerHTML = `${head}<div class="card"><p class="hint">${t('selfimprove.empty')}</p></div>`;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const rows = data.families
|
|
394
|
+
.map((f) => {
|
|
395
|
+
const pct = Math.min(100, Math.round((f.count / data.threshold) * 100));
|
|
396
|
+
const bar = `<div style="background:#1a2236;border-radius:6px;overflow:hidden;height:8px;width:120px;"><div style="background:${f.skillCreated ? '#7ee0a8' : '#38bdf8'};height:8px;width:${pct}%;"></div></div>`;
|
|
397
|
+
return `<tr>
|
|
398
|
+
<td>${escapeHtml(f.sample)}</td>
|
|
399
|
+
<td>${f.count}/${data.threshold} ${bar}</td>
|
|
400
|
+
<td>${f.skillCreated ? `✨ ${escapeHtml(f.skillName ?? '')}` : '—'}</td>
|
|
401
|
+
</tr>`;
|
|
402
|
+
})
|
|
403
|
+
.join('');
|
|
404
|
+
page.innerHTML = `${head}<div class="card">
|
|
405
|
+
<table class="table"><thead><tr><th>task</th><th>${t('selfimprove.repeats')}</th><th>${t('selfimprove.skill')}</th></tr></thead>
|
|
406
|
+
<tbody>${rows}</tbody></table></div>`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function renderInstall(page) {
|
|
410
|
+
const data = await api('/api/install');
|
|
411
|
+
const blocks = data.methods
|
|
412
|
+
.map((m) => {
|
|
413
|
+
const badge = m.ready
|
|
414
|
+
? `<span class="pill" style="background:#1e3a2f;color:#7ee0a8;">${t('install.ready')}</span>`
|
|
415
|
+
: `<span class="pill" style="background:#3a341e;color:#e0d08a;">${t('install.soon')}</span>`;
|
|
416
|
+
const cmds = m.commands
|
|
417
|
+
.map((c) => `<div style="margin:6px 0;"><div class="hint" style="margin:0 0 2px;">${escapeHtml(c.os)}</div>
|
|
418
|
+
<div style="display:flex;gap:8px;align-items:center;"><code style="flex:1;background:#0a1020;border:1px solid #2a3550;border-radius:8px;padding:8px 10px;display:block;overflow-x:auto;">${escapeHtml(c.cmd)}</code>
|
|
419
|
+
<button class="copy-btn" data-cmd="${escapeHtml(c.cmd)}" style="padding:6px 10px;border-radius:8px;border:0;background:#243049;color:#cfe0ff;cursor:pointer;">copy</button></div></div>`)
|
|
420
|
+
.join('');
|
|
421
|
+
return `<div class="card" style="margin-bottom:12px;">
|
|
422
|
+
<h3 style="margin:0 0 8px;">${escapeHtml(m.label)} ${m.recommended ? '<span class="pill">recommended</span>' : ''} ${badge}</h3>
|
|
423
|
+
${cmds}
|
|
424
|
+
${m.note ? `<p class="hint" style="margin:8px 0 0;">${escapeHtml(m.note)}</p>` : ''}
|
|
425
|
+
</div>`;
|
|
426
|
+
})
|
|
427
|
+
.join('');
|
|
428
|
+
page.innerHTML = `<h2 style="margin:0 0 12px;">${t('install.title')}</h2>${blocks}`;
|
|
429
|
+
page.querySelectorAll('.copy-btn').forEach((el) => {
|
|
430
|
+
el.onclick = async () => {
|
|
431
|
+
try {
|
|
432
|
+
await navigator.clipboard.writeText(el.getAttribute('data-cmd') ?? '');
|
|
433
|
+
const prev = el.textContent;
|
|
434
|
+
el.textContent = '✓';
|
|
435
|
+
setTimeout(() => (el.textContent = prev), 1200);
|
|
436
|
+
} catch {
|
|
437
|
+
/* clipboard may be blocked on http */
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function appendConsole(out, html) {
|
|
444
|
+
const atBottom = out.scrollHeight - out.scrollTop - out.clientHeight < 40;
|
|
445
|
+
out.insertAdjacentHTML('beforeend', html);
|
|
446
|
+
if (atBottom) out.scrollTop = out.scrollHeight;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function streamAgentRun(prompt, out, opts) {
|
|
450
|
+
const ctrl = new AbortController();
|
|
451
|
+
termAbort = ctrl;
|
|
452
|
+
appendConsole(out, `<div class="term-line term-user">❯ ${escapeHtml(prompt)}</div>`);
|
|
453
|
+
const answerEl = document.createElement('div');
|
|
454
|
+
answerEl.className = 'term-line term-assistant';
|
|
455
|
+
out.appendChild(answerEl);
|
|
456
|
+
let answer = '';
|
|
457
|
+
try {
|
|
458
|
+
const res = await fetch('/api/terminal/run', {
|
|
459
|
+
method: 'POST',
|
|
460
|
+
headers: { 'Content-Type': 'application/json' },
|
|
461
|
+
body: JSON.stringify({ prompt, sessionId: termSessionId, autoApprove: opts.autoApprove }),
|
|
462
|
+
signal: ctrl.signal,
|
|
463
|
+
});
|
|
464
|
+
const reader = res.body.getReader();
|
|
465
|
+
const decoder = new TextDecoder();
|
|
466
|
+
let buffer = '';
|
|
467
|
+
for (;;) {
|
|
468
|
+
const { value, done } = await reader.read();
|
|
469
|
+
if (done) break;
|
|
470
|
+
buffer += decoder.decode(value, { stream: true });
|
|
471
|
+
const parts = buffer.split('\n\n');
|
|
472
|
+
buffer = parts.pop() ?? '';
|
|
473
|
+
for (const part of parts) {
|
|
474
|
+
const line = part.split('\n').find((l) => l.startsWith('data: '));
|
|
475
|
+
if (!line) continue;
|
|
476
|
+
let ev;
|
|
477
|
+
try {
|
|
478
|
+
ev = JSON.parse(line.slice(6));
|
|
479
|
+
} catch {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
if (ev.type === 'text') {
|
|
483
|
+
answer += ev.text;
|
|
484
|
+
answerEl.textContent = answer;
|
|
485
|
+
if (out.scrollHeight - out.scrollTop - out.clientHeight < 80) out.scrollTop = out.scrollHeight;
|
|
486
|
+
} else if (ev.type === 'status') {
|
|
487
|
+
// lightweight status, skip noisy
|
|
488
|
+
} else if (ev.type === 'tool-call') {
|
|
489
|
+
appendConsole(out, `<div class="term-line term-tool">› ${escapeHtml(ev.title || ev.tool || '')}</div>`);
|
|
490
|
+
if (Array.isArray(ev.diff)) {
|
|
491
|
+
for (const d of ev.diff) {
|
|
492
|
+
const cls = d.sign === '+' ? 'term-add' : d.sign === '-' ? 'term-del' : 'term-ctx';
|
|
493
|
+
appendConsole(out, `<div class="term-line ${cls}"> ${d.sign === ' ' ? ' ' : escapeHtml(d.sign)} ${escapeHtml(d.text)}</div>`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
} else if (ev.type === 'memory') {
|
|
497
|
+
appendConsole(out, `<div class="term-line term-memory">🧠 ${escapeHtml(ev.fact)}</div>`);
|
|
498
|
+
} else if (ev.type === 'skill') {
|
|
499
|
+
appendConsole(out, `<div class="term-line term-skill">✨ Self-improvement: สร้าง skill \`${escapeHtml(ev.name)}\` จากงานที่ทำซ้ำ ${ev.count ?? ''} ครั้ง</div>`);
|
|
500
|
+
} else if (ev.type === 'error') {
|
|
501
|
+
appendConsole(out, `<div class="term-line term-error">⚠ ${escapeHtml(ev.message)}</div>`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
} catch (e) {
|
|
506
|
+
if (e.name !== 'AbortError') appendConsole(out, `<div class="term-line term-error">⚠ ${escapeHtml(e.message)}</div>`);
|
|
507
|
+
} finally {
|
|
508
|
+
termAbort = null;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function renderTerminal(page) {
|
|
513
|
+
const tab = localStorage.getItem('sanook-term-tab') || 'agent';
|
|
514
|
+
page.innerHTML = `<div class="card">
|
|
515
|
+
<h2>${t('terminal.title')}</h2>
|
|
516
|
+
<p class="hint">${t('terminal.hint')}</p>
|
|
517
|
+
<div class="term-tabs">
|
|
518
|
+
<button class="term-tab${tab === 'agent' ? ' active' : ''}" data-tab="agent">${t('terminal.agent')}</button>
|
|
519
|
+
<button class="term-tab${tab === 'shell' ? ' active' : ''}" data-tab="shell">${t('terminal.shell')}</button>
|
|
520
|
+
</div>
|
|
521
|
+
<div id="term-body"></div>
|
|
522
|
+
</div>`;
|
|
523
|
+
page.querySelectorAll('.term-tab').forEach((el) => {
|
|
524
|
+
el.onclick = () => {
|
|
525
|
+
localStorage.setItem('sanook-term-tab', el.getAttribute('data-tab'));
|
|
526
|
+
renderTerminal(page);
|
|
527
|
+
};
|
|
528
|
+
});
|
|
529
|
+
const body = page.querySelector('#term-body');
|
|
530
|
+
if (tab === 'agent') renderAgentConsole(body);
|
|
531
|
+
else renderRawShell(body);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function renderAgentConsole(body) {
|
|
535
|
+
body.innerHTML = `
|
|
536
|
+
<div id="term-out" class="term-out"></div>
|
|
537
|
+
<div class="term-input-row">
|
|
538
|
+
<textarea id="term-input" rows="1" placeholder="${t('terminal.placeholder')}"></textarea>
|
|
539
|
+
<button id="term-run" class="term-run">${t('terminal.run')}</button>
|
|
540
|
+
</div>
|
|
541
|
+
<label class="term-approve"><input type="checkbox" id="term-auto" checked /> auto-approve tools</label>`;
|
|
542
|
+
const out = body.querySelector('#term-out');
|
|
543
|
+
const input = body.querySelector('#term-input');
|
|
544
|
+
const runBtn = body.querySelector('#term-run');
|
|
545
|
+
const auto = body.querySelector('#term-auto');
|
|
546
|
+
const submit = async () => {
|
|
547
|
+
const prompt = input.value.trim();
|
|
548
|
+
if (!prompt || termAbort) return;
|
|
549
|
+
input.value = '';
|
|
550
|
+
runBtn.textContent = t('terminal.stop');
|
|
551
|
+
await streamAgentRun(prompt, out, { autoApprove: auto.checked });
|
|
552
|
+
runBtn.textContent = t('terminal.run');
|
|
553
|
+
};
|
|
554
|
+
runBtn.onclick = () => {
|
|
555
|
+
if (termAbort) {
|
|
556
|
+
termAbort.abort();
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
void submit();
|
|
560
|
+
};
|
|
561
|
+
input.onkeydown = (e) => {
|
|
562
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
563
|
+
e.preventDefault();
|
|
564
|
+
void submit();
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
input.focus();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function renderRawShell(body) {
|
|
571
|
+
body.innerHTML = `<p class="hint">Loading…</p>`;
|
|
572
|
+
const status = await api('/api/terminal/shell-status');
|
|
573
|
+
if (!status.available) {
|
|
574
|
+
body.innerHTML = `<div class="term-out term-disabled"><p>${t('terminal.shellOff')}</p><p class="hint">${escapeHtml(status.reason)}</p></div>`;
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
body.innerHTML = `<div id="xterm" class="term-xterm"></div>`;
|
|
578
|
+
try {
|
|
579
|
+
await loadXterm();
|
|
580
|
+
} catch {
|
|
581
|
+
body.innerHTML = `<div class="term-out term-disabled"><p>${t('terminal.shellOff')}</p><p class="hint">xterm.js failed to load (offline?)</p></div>`;
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const term = new window.Terminal({ cursorBlink: true, fontSize: 13, theme: { background: '#0a1020' } });
|
|
585
|
+
term.open(body.querySelector('#xterm'));
|
|
586
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
587
|
+
const ws = new WebSocket(`${proto}://${location.host}/api/terminal/shell`);
|
|
588
|
+
ws.onmessage = (ev) => {
|
|
589
|
+
try {
|
|
590
|
+
const msg = JSON.parse(ev.data);
|
|
591
|
+
if (msg.type === 'data') term.write(msg.data);
|
|
592
|
+
} catch {
|
|
593
|
+
/* ignore */
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
term.onData((d) => ws.readyState === 1 && ws.send(JSON.stringify({ type: 'data', data: d })));
|
|
597
|
+
term.onResize(({ cols, rows }) => ws.readyState === 1 && ws.send(JSON.stringify({ type: 'resize', cols, rows })));
|
|
598
|
+
ws.onclose = () => term.write('\r\n\x1b[31m[shell closed]\x1b[0m\r\n');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function loadXterm() {
|
|
602
|
+
if (window.Terminal) return Promise.resolve();
|
|
603
|
+
return new Promise((resolve, reject) => {
|
|
604
|
+
const css = document.createElement('link');
|
|
605
|
+
css.rel = 'stylesheet';
|
|
606
|
+
css.href = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css';
|
|
607
|
+
document.head.appendChild(css);
|
|
608
|
+
const s = document.createElement('script');
|
|
609
|
+
s.src = 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js';
|
|
610
|
+
s.onload = () => resolve();
|
|
611
|
+
s.onerror = () => reject(new Error('xterm load failed'));
|
|
612
|
+
document.head.appendChild(s);
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
242
616
|
async function renderRoute() {
|
|
243
617
|
const hash = location.hash.replace(/^#/, '') || '/';
|
|
244
618
|
const route = routes.find((r) => r.path === `#${hash}`) ?? routes[0];
|
|
@@ -251,9 +625,15 @@ async function renderRoute() {
|
|
|
251
625
|
try {
|
|
252
626
|
const map = {
|
|
253
627
|
home: renderHome,
|
|
628
|
+
terminal: renderTerminal,
|
|
254
629
|
chat: renderChat,
|
|
255
630
|
models: renderModels,
|
|
256
631
|
sessions: renderSessions,
|
|
632
|
+
skills: renderSkills,
|
|
633
|
+
memory: renderMemory,
|
|
634
|
+
persona: renderPersona,
|
|
635
|
+
usage: renderUsage,
|
|
636
|
+
selfimprove: renderSelfimprove,
|
|
257
637
|
files: renderFiles,
|
|
258
638
|
logs: renderLogs,
|
|
259
639
|
cron: renderCron,
|
|
@@ -261,6 +641,7 @@ async function renderRoute() {
|
|
|
261
641
|
config: renderConfig,
|
|
262
642
|
mcp: renderMcp,
|
|
263
643
|
brain: renderBrain,
|
|
644
|
+
install: renderInstall,
|
|
264
645
|
};
|
|
265
646
|
await (map[route.id] ?? renderHome)(page);
|
|
266
647
|
} catch (e) {
|
|
@@ -83,3 +83,39 @@ nav { display: flex; flex-direction: column; gap: 0.35rem; flex: 1; }
|
|
|
83
83
|
.shell { grid-template-columns: 1fr; }
|
|
84
84
|
.sidebar { border-right: none; border-bottom: 1px solid var(--border); }
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/* ---- Web terminal ---- */
|
|
88
|
+
.term-tabs { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
|
|
89
|
+
.term-tab {
|
|
90
|
+
background: var(--panel-2); color: var(--muted); border: 1px solid var(--border);
|
|
91
|
+
border-radius: 10px; padding: 0.4rem 0.9rem; cursor: pointer; font-size: 0.9rem;
|
|
92
|
+
}
|
|
93
|
+
.term-tab.active { color: #041018; background: linear-gradient(135deg, var(--accent), var(--accent-2)); border-color: transparent; font-weight: 600; }
|
|
94
|
+
.term-out {
|
|
95
|
+
background: #0a1020; border: 1px solid var(--border); border-radius: 12px;
|
|
96
|
+
padding: 12px; height: 52vh; min-height: 280px; overflow-y: auto;
|
|
97
|
+
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 13px; line-height: 1.5;
|
|
98
|
+
}
|
|
99
|
+
.term-line { white-space: pre-wrap; word-break: break-word; margin: 2px 0; }
|
|
100
|
+
.term-user { color: var(--accent); font-weight: 600; margin-top: 10px; }
|
|
101
|
+
.term-assistant { color: var(--text); }
|
|
102
|
+
.term-tool { color: #c7a3ff; }
|
|
103
|
+
.term-add { color: #7ee0a8; background: rgba(74, 222, 128, 0.08); }
|
|
104
|
+
.term-del { color: #ff9b9b; background: rgba(248, 113, 113, 0.08); }
|
|
105
|
+
.term-ctx { color: var(--muted); }
|
|
106
|
+
.term-memory { color: var(--good); }
|
|
107
|
+
.term-skill { color: #ffd479; }
|
|
108
|
+
.term-error { color: #ff8a8a; }
|
|
109
|
+
.term-disabled { display: grid; place-items: center; text-align: center; gap: 6px; }
|
|
110
|
+
.term-input-row { display: flex; gap: 8px; margin-top: 10px; align-items: flex-end; }
|
|
111
|
+
.term-input-row textarea {
|
|
112
|
+
flex: 1; resize: none; background: #0a1020; color: var(--text);
|
|
113
|
+
border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px;
|
|
114
|
+
font-family: ui-monospace, monospace; font-size: 13px; max-height: 160px;
|
|
115
|
+
}
|
|
116
|
+
.term-run {
|
|
117
|
+
padding: 10px 18px; border-radius: 10px; border: 0; cursor: pointer; font-weight: 600;
|
|
118
|
+
background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #041018;
|
|
119
|
+
}
|
|
120
|
+
.term-approve { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 0.82rem; margin-top: 8px; }
|
|
121
|
+
.term-xterm { height: 56vh; min-height: 300px; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; padding: 6px; background: #0a1020; }
|