sanook-cli 0.5.1 → 0.5.5
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/.env.example +161 -3
- package/CHANGELOG.md +148 -10
- package/README.md +255 -26
- package/README.th.md +95 -7
- package/dist/approval.js +13 -0
- package/dist/bin.js +3552 -155
- package/dist/brain-consolidate.js +335 -0
- package/dist/brain-context.js +262 -0
- package/dist/brain-doctor.js +318 -0
- package/dist/brain-eval.js +186 -0
- package/dist/brain-final.js +377 -0
- package/dist/brain-metrics.js +277 -0
- package/dist/brain-new.js +402 -0
- package/dist/brain-pack.js +210 -0
- package/dist/brain-repair.js +280 -0
- package/dist/brain-review.js +382 -0
- package/dist/brain.js +15 -1
- package/dist/brand.js +1 -1
- package/dist/cli-args.js +190 -0
- package/dist/cli-option-values.js +16 -0
- package/dist/clipboard.js +65 -0
- package/dist/commands.js +266 -27
- package/dist/compaction.js +96 -11
- package/dist/config.js +149 -33
- package/dist/context-compression.js +191 -0
- package/dist/context-pack.js +145 -0
- package/dist/cost.js +49 -15
- package/dist/dashboard/api-helpers.js +87 -0
- package/dist/dashboard/server.js +179 -0
- package/dist/dashboard/static/app.js +277 -0
- package/dist/dashboard/static/index.html +39 -0
- package/dist/dashboard/static/styles.css +85 -0
- package/dist/diff.js +10 -2
- package/dist/first-run.js +21 -0
- package/dist/gateway/auth.js +49 -9
- package/dist/gateway/bluebubbles.js +205 -0
- package/dist/gateway/config.js +929 -0
- package/dist/gateway/deliver.js +399 -0
- package/dist/gateway/discord.js +124 -0
- package/dist/gateway/doctor.js +456 -0
- package/dist/gateway/email.js +501 -0
- package/dist/gateway/googlechat.js +207 -0
- package/dist/gateway/homeassistant.js +256 -0
- package/dist/gateway/ledger.js +38 -1
- package/dist/gateway/line.js +171 -0
- package/dist/gateway/lock.js +3 -1
- package/dist/gateway/matrix.js +366 -0
- package/dist/gateway/mattermost.js +322 -0
- package/dist/gateway/ntfy.js +218 -0
- package/dist/gateway/schedule.js +31 -4
- package/dist/gateway/serve.js +267 -7
- package/dist/gateway/server.js +253 -19
- package/dist/gateway/service.js +224 -0
- package/dist/gateway/session.js +362 -0
- package/dist/gateway/signal.js +351 -0
- package/dist/gateway/slack.js +124 -0
- package/dist/gateway/sms.js +169 -0
- package/dist/gateway/targets.js +576 -0
- package/dist/gateway/teams.js +106 -0
- package/dist/gateway/telegram.js +38 -15
- package/dist/gateway/webhooks.js +220 -0
- package/dist/gateway/whatsapp.js +230 -0
- package/dist/hooks.js +13 -2
- package/dist/hotkeys.js +21 -0
- package/dist/i18n/en.js +98 -0
- package/dist/i18n/index.js +19 -0
- package/dist/i18n/th.js +98 -0
- package/dist/i18n/types.js +1 -0
- package/dist/insights-args.js +55 -0
- package/dist/insights.js +86 -0
- package/dist/knowledge.js +55 -29
- package/dist/loop.js +157 -29
- package/dist/lsp/index.js +23 -5
- package/dist/mcp-hub.js +33 -0
- package/dist/mcp-registry.js +494 -0
- package/dist/mcp-risk.js +71 -0
- package/dist/mcp-server.js +1 -1
- package/dist/mcp.js +120 -10
- package/dist/memory-log.js +90 -0
- package/dist/memory-store.js +37 -1
- package/dist/memory.js +148 -37
- package/dist/model-picker.js +58 -0
- package/dist/orchestrate.js +51 -19
- package/dist/personality.js +58 -0
- package/dist/plan-handoff.js +17 -0
- package/dist/polyglot.js +162 -0
- package/dist/process-runner.js +96 -0
- package/dist/project-init.js +91 -0
- package/dist/project-registry.js +143 -0
- package/dist/project-scaffold.js +124 -0
- package/dist/prompt-size.js +155 -0
- package/dist/providers/codex-login.js +138 -0
- package/dist/providers/codex.js +89 -43
- package/dist/providers/keys.js +22 -1
- package/dist/providers/models.js +2 -2
- package/dist/providers/registry.js +14 -47
- package/dist/search/chunk.js +7 -8
- package/dist/search/cli.js +83 -0
- package/dist/search/embed-store.js +3 -0
- package/dist/search/embedding-config.js +22 -0
- package/dist/search/engine.js +2 -13
- package/dist/search/indexer.js +44 -1
- package/dist/search/store.js +23 -1
- package/dist/session-distill.js +84 -0
- package/dist/session.js +92 -16
- package/dist/skill-install.js +53 -13
- package/dist/skills.js +33 -0
- package/dist/slash-completion.js +155 -0
- package/dist/support-dump.js +206 -0
- package/dist/tool-catalog.js +59 -0
- package/dist/tools/edit.js +45 -15
- package/dist/tools/git.js +10 -5
- package/dist/tools/homeassistant.js +106 -0
- package/dist/tools/index.js +10 -0
- package/dist/tools/list.js +19 -6
- package/dist/tools/permission.js +992 -12
- package/dist/tools/polyglot.js +126 -0
- package/dist/tools/read.js +16 -4
- package/dist/tools/sandbox.js +38 -13
- package/dist/tools/schedule.js +19 -3
- package/dist/tools/search.js +226 -15
- package/dist/tools/task.js +40 -9
- package/dist/tools/timeout.js +23 -3
- package/dist/tools/web-fetch-tool.js +33 -0
- package/dist/trust.js +11 -1
- package/dist/turn-retrieval.js +83 -0
- package/dist/ui/app.js +878 -32
- package/dist/ui/banner.js +78 -4
- package/dist/ui/history.js +37 -5
- package/dist/ui/markdown.js +122 -0
- package/dist/ui/mentions.js +3 -2
- package/dist/ui/overlay.js +496 -0
- package/dist/ui/queue.js +23 -0
- package/dist/ui/render.js +20 -1
- package/dist/ui/session-panel.js +115 -0
- package/dist/ui/setup-providers.js +40 -0
- package/dist/ui/setup.js +172 -46
- package/dist/ui/status.js +142 -0
- package/dist/ui/thinking-panel.js +36 -0
- package/dist/ui/tool-trail.js +97 -0
- package/dist/ui/transcript.js +26 -0
- package/dist/ui/useBusyElapsed.js +19 -0
- package/dist/ui/useEditor.js +144 -5
- package/dist/ui/useGitBranch.js +57 -0
- package/dist/update.js +56 -17
- package/dist/web-fetch.js +637 -0
- package/dist/web-surface.js +190 -0
- package/dist/worktree.js +175 -4
- package/package.json +5 -5
- package/second-brain/AGENTS.md +6 -4
- package/second-brain/CLAUDE.md +7 -1
- package/second-brain/Evals/_Index.md +10 -2
- package/second-brain/Evals/quality-ledger.md +9 -1
- package/second-brain/Evals/second-brain-benchmarks.md +62 -0
- package/second-brain/GEMINI.md +5 -4
- package/second-brain/Home.md +1 -1
- package/second-brain/Projects/_Index.md +19 -4
- package/second-brain/Projects/sanook-cli/_Index.md +30 -0
- package/second-brain/Projects/sanook-cli/context.md +35 -0
- package/second-brain/Projects/sanook-cli/current-state.md +32 -0
- package/second-brain/Projects/sanook-cli/overview.md +41 -0
- package/second-brain/Projects/sanook-cli/repo.md +34 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
- package/second-brain/README.md +1 -1
- package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
- package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
- package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
- package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
- package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
- package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
- package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
- package/second-brain/Research/_Index.md +8 -1
- package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
- package/second-brain/Reviews/_Index.md +1 -1
- package/second-brain/Runbooks/_Index.md +6 -1
- package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
- package/second-brain/SANOOK.md +45 -0
- package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
- package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
- package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
- package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
- package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
- package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
- package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
- package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
- package/second-brain/Sessions/_Index.md +15 -1
- package/second-brain/Shared/AI-Context-Index.md +22 -0
- package/second-brain/Shared/Context-Packs/_Index.md +9 -1
- package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
- package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
- package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
- package/second-brain/Shared/Operating-State/current-state.md +14 -4
- package/second-brain/Shared/Scripts/_Index.md +3 -1
- package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
- package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
- package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
- package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
- package/second-brain/Shared/User-Memory/_Index.md +4 -1
- package/second-brain/Shared/User-Memory/response-examples.md +98 -0
- package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
- package/second-brain/Templates/_Index.md +9 -0
- package/second-brain/Templates/final-lite.md +111 -0
- package/second-brain/Templates/final.md +231 -0
- package/second-brain/Templates/project-workspace/_Index.md +31 -0
- package/second-brain/Templates/project-workspace/context.md +28 -0
- package/second-brain/Templates/project-workspace/current-state.md +29 -0
- package/second-brain/Templates/project-workspace/overview.md +39 -0
- package/second-brain/Templates/project-workspace/repo.md +33 -0
- package/second-brain/Vault Structure Map.md +2 -1
- package/skills/structured-output-llm/SKILL.md +1 -1
package/dist/cost.js
CHANGED
|
@@ -15,9 +15,6 @@ export const PRICING = {
|
|
|
15
15
|
// Google Gemini (≤200k context tier)
|
|
16
16
|
'google:gemini-2.5-pro': { input: 1.25, output: 10, cacheWrite: 1.25, cacheRead: 0.31 },
|
|
17
17
|
'google:gemini-2.5-flash': { input: 0.3, output: 2.5, cacheWrite: 0.3, cacheRead: 0.075 },
|
|
18
|
-
// DeepSeek V4
|
|
19
|
-
'deepseek:deepseek-v4-flash': { input: 0.28, output: 0.42, cacheWrite: 0.28, cacheRead: 0.028 },
|
|
20
|
-
'deepseek:deepseek-v4-pro': { input: 0.55, output: 2.19, cacheWrite: 0.55, cacheRead: 0.055 },
|
|
21
18
|
// xAI Grok
|
|
22
19
|
'xai:grok-4.3': { input: 3, output: 15, cacheWrite: 3, cacheRead: 0.75 },
|
|
23
20
|
// Mistral
|
|
@@ -30,6 +27,9 @@ export const PRICING = {
|
|
|
30
27
|
export function hasPricingForKey(specKey) {
|
|
31
28
|
return specKey in PRICING;
|
|
32
29
|
}
|
|
30
|
+
function isPricingKey(key) {
|
|
31
|
+
return /^[^:\s]+:\S+$/.test(key);
|
|
32
|
+
}
|
|
33
33
|
/**
|
|
34
34
|
* merge pricing เพิ่ม/override (จาก config `pricing` หรือ env SANOOK_PRICING)
|
|
35
35
|
* — ให้ budget cap ใช้ได้กับ provider ที่ยังไม่มีในตาราง โดยไม่ต้องแก้โค้ด
|
|
@@ -38,6 +38,8 @@ export function registerPricing(extra) {
|
|
|
38
38
|
if (!extra)
|
|
39
39
|
return;
|
|
40
40
|
for (const [key, p] of Object.entries(extra)) {
|
|
41
|
+
if (!isPricingKey(key))
|
|
42
|
+
continue;
|
|
41
43
|
if (p == null || typeof p !== 'object')
|
|
42
44
|
continue;
|
|
43
45
|
const base = PRICING[key] ?? { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
|
|
@@ -54,17 +56,41 @@ export function registerPricing(extra) {
|
|
|
54
56
|
PRICING[key] = next;
|
|
55
57
|
}
|
|
56
58
|
}
|
|
59
|
+
function safeTokenCount(value) {
|
|
60
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
|
|
61
|
+
return 0;
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
export class SharedBudget {
|
|
65
|
+
budgetUsd;
|
|
66
|
+
spent = 0;
|
|
67
|
+
constructor(budgetUsd) {
|
|
68
|
+
this.budgetUsd = budgetUsd;
|
|
69
|
+
}
|
|
70
|
+
add(usd) {
|
|
71
|
+
if (Number.isFinite(usd) && usd > 0)
|
|
72
|
+
this.spent += usd;
|
|
73
|
+
}
|
|
74
|
+
get totalUsd() {
|
|
75
|
+
return this.spent;
|
|
76
|
+
}
|
|
77
|
+
get overBudget() {
|
|
78
|
+
return this.budgetUsd != null && this.spent >= this.budgetUsd;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
57
81
|
export class CostMeter {
|
|
58
82
|
specKey;
|
|
59
83
|
budgetUsd;
|
|
84
|
+
sharedBudget;
|
|
60
85
|
inTok = 0;
|
|
61
86
|
outTok = 0;
|
|
62
87
|
cacheReadTok = 0;
|
|
63
88
|
cacheWriteTok = 0;
|
|
64
89
|
spent = 0;
|
|
65
|
-
constructor(specKey, budgetUsd) {
|
|
90
|
+
constructor(specKey, budgetUsd, sharedBudget) {
|
|
66
91
|
this.specKey = specKey;
|
|
67
92
|
this.budgetUsd = budgetUsd;
|
|
93
|
+
this.sharedBudget = sharedBudget;
|
|
68
94
|
}
|
|
69
95
|
/**
|
|
70
96
|
* บวก usage ของ 1 step. cacheWriteTokens ดึงจาก providerMetadata แยก (default 0)
|
|
@@ -72,21 +98,23 @@ export class CostMeter {
|
|
|
72
98
|
* ไม่งั้น double-count cacheRead (cache hit จะกลายเป็นแพงกว่า no-cache)
|
|
73
99
|
*/
|
|
74
100
|
add(usage, cacheWriteTokens = 0) {
|
|
75
|
-
const totalInput = usage.inputTokens
|
|
76
|
-
const output = usage.outputTokens
|
|
77
|
-
const cacheRead = usage.cachedInputTokens
|
|
78
|
-
const
|
|
101
|
+
const totalInput = safeTokenCount(usage.inputTokens);
|
|
102
|
+
const output = safeTokenCount(usage.outputTokens);
|
|
103
|
+
const cacheRead = safeTokenCount(usage.cachedInputTokens);
|
|
104
|
+
const cacheWrite = safeTokenCount(cacheWriteTokens);
|
|
105
|
+
const noCacheInput = Math.max(0, totalInput - cacheRead - cacheWrite);
|
|
79
106
|
this.inTok += noCacheInput;
|
|
80
107
|
this.outTok += output;
|
|
81
108
|
this.cacheReadTok += cacheRead;
|
|
82
|
-
this.cacheWriteTok +=
|
|
109
|
+
this.cacheWriteTok += cacheWrite;
|
|
83
110
|
const p = PRICING[this.specKey];
|
|
84
111
|
if (p) {
|
|
85
|
-
|
|
86
|
-
(
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
112
|
+
const delta = (noCacheInput / 1e6) * p.input +
|
|
113
|
+
(output / 1e6) * p.output +
|
|
114
|
+
(cacheRead / 1e6) * p.cacheRead +
|
|
115
|
+
(cacheWrite / 1e6) * p.cacheWrite;
|
|
116
|
+
this.spent += delta;
|
|
117
|
+
this.sharedBudget?.add(delta);
|
|
90
118
|
}
|
|
91
119
|
}
|
|
92
120
|
/** รวม token + cost จาก meter อีกตัว (เช่น primary model ก่อน fallback) — กัน usage หาย/budget reset */
|
|
@@ -96,6 +124,8 @@ export class CostMeter {
|
|
|
96
124
|
this.cacheReadTok += other.cacheReadTok;
|
|
97
125
|
this.cacheWriteTok += other.cacheWriteTok;
|
|
98
126
|
this.spent += other.spent;
|
|
127
|
+
if (this.sharedBudget && this.sharedBudget !== other.sharedBudget)
|
|
128
|
+
this.sharedBudget.add(other.spent);
|
|
99
129
|
}
|
|
100
130
|
get totalUsd() {
|
|
101
131
|
return this.spent;
|
|
@@ -105,7 +135,11 @@ export class CostMeter {
|
|
|
105
135
|
}
|
|
106
136
|
/** true เมื่อใช้เกิน budget (เช็คก่อนยิง request ถัดไป) — no-op ถ้าไม่มี pricing (เตือนที่ entry point) */
|
|
107
137
|
get overBudget() {
|
|
108
|
-
|
|
138
|
+
if (this.sharedBudget?.overBudget)
|
|
139
|
+
return true;
|
|
140
|
+
if (!this.hasPricing)
|
|
141
|
+
return false;
|
|
142
|
+
return this.budgetUsd != null && this.spent >= this.budgetUsd;
|
|
109
143
|
}
|
|
110
144
|
summary() {
|
|
111
145
|
const total = this.inTok + this.outTok + this.cacheReadTok + this.cacheWriteTok;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve, relative } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { appHomePath, BRAND } from '../brand.js';
|
|
5
|
+
import { loadConfig } from '../config.js';
|
|
6
|
+
import { listTasks } from '../gateway/ledger.js';
|
|
7
|
+
import { gatewayServiceLogPath, gatewayServiceStatus } from '../gateway/service.js';
|
|
8
|
+
import { readGatewayConfig, resolveDiscordConfig, resolveSlackConfig, resolveTelegramConfig, resolveWebhookConfig, } from '../gateway/config.js';
|
|
9
|
+
export async function dashboardChannels() {
|
|
10
|
+
const cfg = await readGatewayConfig();
|
|
11
|
+
const service = await gatewayServiceStatus();
|
|
12
|
+
const channels = [
|
|
13
|
+
{
|
|
14
|
+
id: 'telegram',
|
|
15
|
+
label: 'Telegram',
|
|
16
|
+
configured: Boolean(resolveTelegramConfig(cfg).token),
|
|
17
|
+
setupCommand: `${BRAND.cliName} gateway setup telegram`,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: 'discord',
|
|
21
|
+
label: 'Discord',
|
|
22
|
+
configured: Boolean(resolveDiscordConfig(cfg).token),
|
|
23
|
+
setupCommand: `${BRAND.cliName} gateway setup discord`,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'slack',
|
|
27
|
+
label: 'Slack',
|
|
28
|
+
configured: Boolean(resolveSlackConfig(cfg).botToken),
|
|
29
|
+
setupCommand: `${BRAND.cliName} gateway setup slack`,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'webhooks',
|
|
33
|
+
label: 'Webhooks',
|
|
34
|
+
configured: Object.keys(resolveWebhookConfig(cfg).routes ?? {}).length > 0,
|
|
35
|
+
setupCommand: `${BRAND.cliName} webhook setup`,
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
return { channels, serviceRunning: service.running };
|
|
39
|
+
}
|
|
40
|
+
export async function dashboardCronTasks() {
|
|
41
|
+
return { tasks: await listTasks() };
|
|
42
|
+
}
|
|
43
|
+
export async function dashboardLogsTail(maxLines = 200) {
|
|
44
|
+
const path = gatewayServiceLogPath();
|
|
45
|
+
try {
|
|
46
|
+
const raw = await readFile(path, 'utf8');
|
|
47
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
48
|
+
return { path, lines: lines.slice(-maxLines) };
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return { path, lines: [`(no log yet — run ${BRAND.cliName} serve)`] };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function safeRoot(root) {
|
|
55
|
+
return resolve(root);
|
|
56
|
+
}
|
|
57
|
+
export async function dashboardListFiles(subpath = '') {
|
|
58
|
+
const config = await loadConfig({});
|
|
59
|
+
const roots = [appHomePath(), config.brainPath ? resolve(config.brainPath) : null].filter(Boolean);
|
|
60
|
+
const root = safeRoot(roots[0] ?? appHomePath());
|
|
61
|
+
const target = safeRoot(join(root, subpath.replace(/^\/+/, '')));
|
|
62
|
+
if (!target.startsWith(root) && !roots.some((r) => target.startsWith(safeRoot(r)))) {
|
|
63
|
+
throw new Error('path not allowed');
|
|
64
|
+
}
|
|
65
|
+
const entries = await readdir(target, { withFileTypes: true });
|
|
66
|
+
return {
|
|
67
|
+
root,
|
|
68
|
+
entries: entries
|
|
69
|
+
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name))
|
|
70
|
+
.slice(0, 200)
|
|
71
|
+
.map((e) => ({ name: e.name, dir: e.isDirectory() })),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export async function dashboardReadFile(subpath) {
|
|
75
|
+
const config = await loadConfig({});
|
|
76
|
+
const allowedRoots = [appHomePath(), config.brainPath ? resolve(config.brainPath) : null].filter(Boolean);
|
|
77
|
+
const target = safeRoot(subpath.startsWith('/') ? subpath : join(appHomePath(), subpath));
|
|
78
|
+
if (!allowedRoots.some((root) => target.startsWith(safeRoot(root))))
|
|
79
|
+
throw new Error('path not allowed');
|
|
80
|
+
const info = await stat(target);
|
|
81
|
+
if (!info.isFile())
|
|
82
|
+
throw new Error('not a file');
|
|
83
|
+
if (info.size > 512_000)
|
|
84
|
+
throw new Error('file too large');
|
|
85
|
+
const content = await readFile(target, 'utf8');
|
|
86
|
+
return { path: relative(homedir(), target) || target, content };
|
|
87
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, extname } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { BRAND } from '../brand.js';
|
|
6
|
+
import { loadConfig, readGlobalConfigRaw } from '../config.js';
|
|
7
|
+
import { listSessions } from '../session.js';
|
|
8
|
+
import { loadMcpConfig } from '../mcp.js';
|
|
9
|
+
const MIME = {
|
|
10
|
+
'.html': 'text/html; charset=utf-8',
|
|
11
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
12
|
+
'.css': 'text/css; charset=utf-8',
|
|
13
|
+
'.json': 'application/json; charset=utf-8',
|
|
14
|
+
'.svg': 'image/svg+xml',
|
|
15
|
+
'.png': 'image/png',
|
|
16
|
+
'.ico': 'image/x-icon',
|
|
17
|
+
};
|
|
18
|
+
function dashboardStaticDir() {
|
|
19
|
+
const here = fileURLToPath(new URL('.', import.meta.url));
|
|
20
|
+
return join(here, 'static');
|
|
21
|
+
}
|
|
22
|
+
async function readBody(req) {
|
|
23
|
+
const chunks = [];
|
|
24
|
+
for await (const chunk of req)
|
|
25
|
+
chunks.push(Buffer.from(chunk));
|
|
26
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
27
|
+
}
|
|
28
|
+
function json(res, status, body) {
|
|
29
|
+
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
30
|
+
res.end(`${JSON.stringify(body)}\n`);
|
|
31
|
+
}
|
|
32
|
+
async function handleApi(req, res, pathname) {
|
|
33
|
+
if (req.method === 'GET' && pathname === '/api/status') {
|
|
34
|
+
const config = await loadConfig({});
|
|
35
|
+
const raw = await readGlobalConfigRaw();
|
|
36
|
+
json(res, 200, {
|
|
37
|
+
product: 'Sanook Dashboard',
|
|
38
|
+
cli: BRAND.cliName,
|
|
39
|
+
version: process.env.npm_package_version ?? 'dev',
|
|
40
|
+
model: config.model,
|
|
41
|
+
locale: config.locale,
|
|
42
|
+
brainPath: config.brainPath ?? null,
|
|
43
|
+
permissionMode: config.permissionMode,
|
|
44
|
+
gatewayHint: `${BRAND.cliName} serve`,
|
|
45
|
+
});
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (req.method === 'GET' && pathname === '/api/config') {
|
|
49
|
+
json(res, 200, await readGlobalConfigRaw());
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (req.method === 'GET' && pathname === '/api/sessions') {
|
|
53
|
+
const sessions = await listSessions({});
|
|
54
|
+
json(res, 200, { sessions });
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (req.method === 'GET' && pathname === '/api/mcp') {
|
|
58
|
+
const servers = await loadMcpConfig();
|
|
59
|
+
json(res, 200, { servers });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
if (req.method === 'GET' && pathname === '/api/brain') {
|
|
63
|
+
const config = await loadConfig({});
|
|
64
|
+
json(res, 200, { brainPath: config.brainPath ?? null });
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (req.method === 'GET' && pathname === '/api/cron') {
|
|
68
|
+
const { dashboardCronTasks } = await import('./api-helpers.js');
|
|
69
|
+
json(res, 200, await dashboardCronTasks());
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (req.method === 'GET' && pathname === '/api/channels') {
|
|
73
|
+
const { dashboardChannels } = await import('./api-helpers.js');
|
|
74
|
+
json(res, 200, await dashboardChannels());
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
if (req.method === 'GET' && pathname === '/api/logs') {
|
|
78
|
+
const { dashboardLogsTail } = await import('./api-helpers.js');
|
|
79
|
+
json(res, 200, await dashboardLogsTail());
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (req.method === 'GET' && pathname.startsWith('/api/files')) {
|
|
83
|
+
const url = new URL(req.url ?? '/', 'http://local');
|
|
84
|
+
const sub = url.searchParams.get('path') ?? '';
|
|
85
|
+
if (pathname === '/api/files/read') {
|
|
86
|
+
const { dashboardReadFile } = await import('./api-helpers.js');
|
|
87
|
+
json(res, 200, await dashboardReadFile(sub));
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
const { dashboardListFiles } = await import('./api-helpers.js');
|
|
91
|
+
json(res, 200, await dashboardListFiles(sub));
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (req.method === 'GET' && pathname === '/api/chat/status') {
|
|
95
|
+
json(res, 200, {
|
|
96
|
+
hint: `Use ${BRAND.cliName} in terminal, or start ${BRAND.cliName} serve for HTTP chat`,
|
|
97
|
+
gateway: `${BRAND.cliName} serve`,
|
|
98
|
+
});
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
if (req.method === 'POST' && pathname === '/api/config') {
|
|
102
|
+
const raw = await readBody(req);
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(raw || '{}');
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
json(res, 400, { error: 'invalid JSON' });
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
112
|
+
json(res, 400, { error: 'body must be an object' });
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
const { saveGlobalConfig } = await import('../config.js');
|
|
116
|
+
await saveGlobalConfig(parsed);
|
|
117
|
+
json(res, 200, { ok: true });
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
async function serveStatic(res, staticDir, pathname) {
|
|
123
|
+
const safe = pathname === '/' ? '/index.html' : pathname;
|
|
124
|
+
const filePath = join(staticDir, safe.replace(/^\/+/, ''));
|
|
125
|
+
try {
|
|
126
|
+
const info = await stat(filePath);
|
|
127
|
+
if (!info.isFile()) {
|
|
128
|
+
res.writeHead(404);
|
|
129
|
+
res.end('Not found');
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const ext = extname(filePath);
|
|
133
|
+
const body = await readFile(filePath);
|
|
134
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] ?? 'application/octet-stream' });
|
|
135
|
+
res.end(body);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
try {
|
|
139
|
+
const fallback = await readFile(join(staticDir, 'index.html'));
|
|
140
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
141
|
+
res.end(fallback);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
res.writeHead(503);
|
|
145
|
+
res.end('Sanook Dashboard assets missing — run npm run build:dashboard');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
export async function startDashboardServer(opts = {}) {
|
|
150
|
+
const port = opts.port ?? 9119;
|
|
151
|
+
const host = opts.host ?? '127.0.0.1';
|
|
152
|
+
const staticDir = opts.staticDir ?? dashboardStaticDir();
|
|
153
|
+
const log = opts.onLog ?? (() => { });
|
|
154
|
+
const server = createServer(async (req, res) => {
|
|
155
|
+
try {
|
|
156
|
+
const url = new URL(req.url ?? '/', `http://${host}`);
|
|
157
|
+
if (url.pathname.startsWith('/api/')) {
|
|
158
|
+
const handled = await handleApi(req, res, url.pathname);
|
|
159
|
+
if (handled)
|
|
160
|
+
return;
|
|
161
|
+
json(res, 404, { error: 'not found' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
await serveStatic(res, staticDir, url.pathname);
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
json(res, 500, { error: e.message });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
await new Promise((resolve, reject) => {
|
|
171
|
+
server.once('error', reject);
|
|
172
|
+
server.listen(port, host, () => resolve());
|
|
173
|
+
});
|
|
174
|
+
log(`Sanook Dashboard — http://${host}:${port}`);
|
|
175
|
+
return () => server.close();
|
|
176
|
+
}
|
|
177
|
+
export function dashboardStaticRoot() {
|
|
178
|
+
return dashboardStaticDir();
|
|
179
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
const I18N = {
|
|
2
|
+
en: {
|
|
3
|
+
productName: 'Sanook Dashboard',
|
|
4
|
+
tagline: 'Configure models, sessions, MCP, gateway, and your second brain',
|
|
5
|
+
nav: {
|
|
6
|
+
home: 'Home',
|
|
7
|
+
chat: 'Chat',
|
|
8
|
+
models: 'Models',
|
|
9
|
+
sessions: 'Sessions',
|
|
10
|
+
files: 'Files',
|
|
11
|
+
logs: 'Logs',
|
|
12
|
+
cron: 'Cron',
|
|
13
|
+
channels: 'Channels',
|
|
14
|
+
config: 'Config',
|
|
15
|
+
mcp: 'MCP',
|
|
16
|
+
brain: 'Brain',
|
|
17
|
+
},
|
|
18
|
+
home: {
|
|
19
|
+
title: 'System status',
|
|
20
|
+
cliVersion: 'CLI version',
|
|
21
|
+
model: 'Default model',
|
|
22
|
+
brainPath: 'Second brain',
|
|
23
|
+
gateway: 'Gateway hint',
|
|
24
|
+
openRepl: 'Run sanook in your terminal to chat',
|
|
25
|
+
},
|
|
26
|
+
chat: {
|
|
27
|
+
title: 'Chat',
|
|
28
|
+
hint: 'Primary chat runs in the terminal REPL. Start the gateway for HTTP/mobile access.',
|
|
29
|
+
},
|
|
30
|
+
models: { title: 'Models', hint: 'Change with sanook config set model <spec> or /model in REPL.' },
|
|
31
|
+
sessions: { title: 'Sessions', empty: 'No resumable sessions yet.' },
|
|
32
|
+
files: { title: 'Files', open: 'Open' },
|
|
33
|
+
logs: { title: 'Gateway logs', empty: 'No log file yet.' },
|
|
34
|
+
cron: { title: 'Scheduled tasks', empty: 'No cron tasks — sanook cron add "every 1h" "task"' },
|
|
35
|
+
channels: { title: 'Messaging channels', configured: 'configured', setup: 'Setup command' },
|
|
36
|
+
config: { title: 'Configuration', save: 'Save JSON' },
|
|
37
|
+
mcp: { title: 'MCP servers', empty: 'No MCP servers configured.' },
|
|
38
|
+
brain: { title: 'Second brain', empty: 'Not configured — run sanook brain init' },
|
|
39
|
+
},
|
|
40
|
+
th: {
|
|
41
|
+
productName: 'Sanook Dashboard',
|
|
42
|
+
tagline: 'จัดการ model, session, MCP, gateway และ second brain',
|
|
43
|
+
nav: {
|
|
44
|
+
home: 'หน้าแรก',
|
|
45
|
+
chat: 'Chat',
|
|
46
|
+
models: 'Models',
|
|
47
|
+
sessions: 'Sessions',
|
|
48
|
+
files: 'Files',
|
|
49
|
+
logs: 'Logs',
|
|
50
|
+
cron: 'Cron',
|
|
51
|
+
channels: 'Channels',
|
|
52
|
+
config: 'Config',
|
|
53
|
+
mcp: 'MCP',
|
|
54
|
+
brain: 'Brain',
|
|
55
|
+
},
|
|
56
|
+
home: {
|
|
57
|
+
title: 'สถานะระบบ',
|
|
58
|
+
cliVersion: 'เวอร์ชัน CLI',
|
|
59
|
+
model: 'Model หลัก',
|
|
60
|
+
brainPath: 'Second brain',
|
|
61
|
+
gateway: 'คำสั่ง Gateway',
|
|
62
|
+
openRepl: 'รัน sanook ใน terminal เพื่อแชท',
|
|
63
|
+
},
|
|
64
|
+
chat: { title: 'Chat', hint: 'แชทหลักอยู่ใน terminal · รัน sanook serve สำหรับ HTTP/mobile' },
|
|
65
|
+
models: { title: 'Models', hint: 'เปลี่ยนด้วย sanook config set model หรือ /model ใน REPL' },
|
|
66
|
+
sessions: { title: 'Sessions', empty: 'ยังไม่มี session ที่ resume ได้' },
|
|
67
|
+
files: { title: 'Files', open: 'เปิด' },
|
|
68
|
+
logs: { title: 'Gateway logs', empty: 'ยังไม่มี log' },
|
|
69
|
+
cron: { title: 'Scheduled tasks', empty: 'ยังไม่มี cron — sanook cron add "every 1h" "task"' },
|
|
70
|
+
channels: { title: 'Messaging channels', configured: 'ตั้งแล้ว', setup: 'คำสั่ง setup' },
|
|
71
|
+
config: { title: 'Configuration', save: 'บันทึก JSON' },
|
|
72
|
+
mcp: { title: 'MCP servers', empty: 'ยังไม่ได้ตั้ง MCP server' },
|
|
73
|
+
brain: { title: 'Second brain', empty: 'ยังไม่ตั้ง — รัน sanook brain init' },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const routes = [
|
|
78
|
+
{ id: 'home', path: '#/' },
|
|
79
|
+
{ id: 'chat', path: '#/chat' },
|
|
80
|
+
{ id: 'models', path: '#/models' },
|
|
81
|
+
{ id: 'sessions', path: '#/sessions' },
|
|
82
|
+
{ id: 'files', path: '#/files' },
|
|
83
|
+
{ id: 'logs', path: '#/logs' },
|
|
84
|
+
{ id: 'cron', path: '#/cron' },
|
|
85
|
+
{ id: 'channels', path: '#/channels' },
|
|
86
|
+
{ id: 'config', path: '#/config' },
|
|
87
|
+
{ id: 'mcp', path: '#/mcp' },
|
|
88
|
+
{ id: 'brain', path: '#/brain' },
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
let filesPath = '';
|
|
92
|
+
|
|
93
|
+
function locale() {
|
|
94
|
+
return localStorage.getItem('sanook-dashboard-locale') || 'en';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function t(key) {
|
|
98
|
+
const loc = I18N[locale()] ?? I18N.en;
|
|
99
|
+
return key.split('.').reduce((o, k) => o?.[k], loc) ?? key;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function api(path) {
|
|
103
|
+
const res = await fetch(path);
|
|
104
|
+
if (!res.ok) throw new Error(`${path} ${res.status}`);
|
|
105
|
+
return res.json();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function renderNav(active) {
|
|
109
|
+
document.getElementById('nav').innerHTML = routes
|
|
110
|
+
.map((r) => `<a class="nav-link${active === r.id ? ' active' : ''}" href="${r.path}">${t(`nav.${r.id}`)}</a>`)
|
|
111
|
+
.join('');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function renderHome(page) {
|
|
115
|
+
const status = await api('/api/status');
|
|
116
|
+
page.innerHTML = `<div class="card"><h2>${t('home.title')}</h2>
|
|
117
|
+
<dl class="kv">
|
|
118
|
+
<dt>${t('home.cliVersion')}</dt><dd><span class="pill">${status.version ?? 'dev'}</span></dd>
|
|
119
|
+
<dt>${t('home.model')}</dt><dd>${status.model ?? '(not set)'}</dd>
|
|
120
|
+
<dt>${t('home.brainPath')}</dt><dd>${status.brainPath ?? '(not set)'}</dd>
|
|
121
|
+
<dt>${t('home.gateway')}</dt><dd><code>${status.gatewayHint ?? 'sanook serve'}</code></dd>
|
|
122
|
+
</dl>
|
|
123
|
+
<p class="hint">${t('home.openRepl')}</p></div>`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function renderChat(page) {
|
|
127
|
+
const status = await api('/api/chat/status');
|
|
128
|
+
page.innerHTML = `<div class="card"><h2>${t('chat.title')}</h2>
|
|
129
|
+
<p class="hint">${t('chat.hint')}</p>
|
|
130
|
+
<dl class="kv"><dt>gateway</dt><dd><code>${status.gateway ?? 'sanook serve'}</code></dd></dl>
|
|
131
|
+
<textarea id="chat-draft" placeholder="Draft a prompt to copy into terminal…" style="width:100%;min-height:120px;background:#0a1020;color:#e8edf7;border:1px solid #2a3550;border-radius:12px;padding:12px;"></textarea>
|
|
132
|
+
</div>`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function renderModels(page) {
|
|
136
|
+
const status = await api('/api/status');
|
|
137
|
+
page.innerHTML = `<div class="card"><h2>${t('models.title')}</h2>
|
|
138
|
+
<dl class="kv"><dt>model</dt><dd>${status.model ?? '(not set)'}</dd></dl>
|
|
139
|
+
<p class="hint">${t('models.hint')}</p></div>`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function renderSessions(page) {
|
|
143
|
+
const { sessions } = await api('/api/sessions');
|
|
144
|
+
if (!sessions?.length) {
|
|
145
|
+
page.innerHTML = `<div class="card"><h2>${t('sessions.title')}</h2><p class="hint">${t('sessions.empty')}</p></div>`;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
page.innerHTML = `<div class="card"><h2>${t('sessions.title')}</h2>
|
|
149
|
+
<table class="table"><thead><tr><th>id</th><th>model</th><th>updated</th></tr></thead><tbody>
|
|
150
|
+
${sessions.slice(0, 50).map((s) => `<tr><td>${s.id ?? ''}</td><td>${s.model ?? ''}</td><td>${s.updated ?? ''}</td></tr>`).join('')}
|
|
151
|
+
</tbody></table></div>`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function renderFiles(page) {
|
|
155
|
+
const data = await api(`/api/files?path=${encodeURIComponent(filesPath)}`);
|
|
156
|
+
const entries = (data.entries ?? [])
|
|
157
|
+
.map((e) => {
|
|
158
|
+
const next = filesPath ? `${filesPath}/${e.name}` : e.name;
|
|
159
|
+
if (e.dir) return `<tr><td>📁 ${e.name}</td><td><a href="#" data-path="${next}">${t('files.open')}</a></td></tr>`;
|
|
160
|
+
return `<tr><td>${e.name}</td><td><a href="#" data-read="${next}">${t('files.open')}</a></td></tr>`;
|
|
161
|
+
})
|
|
162
|
+
.join('');
|
|
163
|
+
page.innerHTML = `<div class="card"><h2>${t('files.title')}</h2>
|
|
164
|
+
<p class="hint">~/.sanook · path: ${filesPath || '/'}</p>
|
|
165
|
+
<table class="table"><thead><tr><th>name</th><th></th></tr></thead><tbody>${entries}</tbody></table>
|
|
166
|
+
<pre id="file-preview" class="hint" style="white-space:pre-wrap;margin-top:12px;"></pre></div>`;
|
|
167
|
+
page.querySelectorAll('[data-path]').forEach((el) => {
|
|
168
|
+
el.onclick = (ev) => {
|
|
169
|
+
ev.preventDefault();
|
|
170
|
+
filesPath = el.getAttribute('data-path') ?? '';
|
|
171
|
+
void renderFiles(page);
|
|
172
|
+
};
|
|
173
|
+
});
|
|
174
|
+
page.querySelectorAll('[data-read]').forEach((el) => {
|
|
175
|
+
el.onclick = async (ev) => {
|
|
176
|
+
ev.preventDefault();
|
|
177
|
+
const file = await api(`/api/files/read?path=${encodeURIComponent(el.getAttribute('data-read') ?? '')}`);
|
|
178
|
+
document.getElementById('file-preview').textContent = file.content ?? '';
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function renderLogs(page) {
|
|
184
|
+
const data = await api('/api/logs');
|
|
185
|
+
const body = (data.lines ?? []).join('\n') || t('logs.empty');
|
|
186
|
+
page.innerHTML = `<div class="card"><h2>${t('logs.title')}</h2>
|
|
187
|
+
<p class="hint">${data.path ?? ''}</p>
|
|
188
|
+
<pre style="white-space:pre-wrap;max-height:480px;overflow:auto;background:#0a1020;padding:12px;border-radius:12px;border:1px solid #2a3550;">${body}</pre></div>`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function renderCron(page) {
|
|
192
|
+
const { tasks } = await api('/api/cron');
|
|
193
|
+
if (!tasks?.length) {
|
|
194
|
+
page.innerHTML = `<div class="card"><h2>${t('cron.title')}</h2><p class="hint">${t('cron.empty')}</p></div>`;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
page.innerHTML = `<div class="card"><h2>${t('cron.title')}</h2>
|
|
198
|
+
<table class="table"><thead><tr><th>id</th><th>status</th><th>schedule</th><th>spec</th></tr></thead><tbody>
|
|
199
|
+
${tasks.map((task) => `<tr><td>${task.id}</td><td>${task.status}</td><td>${task.schedule ?? 'once'}</td><td>${(task.spec ?? '').slice(0, 60)}</td></tr>`).join('')}
|
|
200
|
+
</tbody></table></div>`;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function renderChannels(page) {
|
|
204
|
+
const data = await api('/api/channels');
|
|
205
|
+
page.innerHTML = `<div class="card"><h2>${t('channels.title')}</h2>
|
|
206
|
+
<p class="hint">service: ${data.serviceRunning ? 'running' : 'stopped'}</p>
|
|
207
|
+
<table class="table"><thead><tr><th>platform</th><th>status</th><th>${t('channels.setup')}</th></tr></thead><tbody>
|
|
208
|
+
${(data.channels ?? []).map((c) => `<tr><td>${c.label}</td><td>${c.configured ? t('channels.configured') : '—'}</td><td><code>${c.setupCommand}</code></td></tr>`).join('')}
|
|
209
|
+
</tbody></table></div>`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function renderConfig(page) {
|
|
213
|
+
const config = await api('/api/config');
|
|
214
|
+
page.innerHTML = `<div class="card"><h2>${t('config.title')}</h2>
|
|
215
|
+
<textarea id="config-json" style="width:100%;min-height:320px;background:#0a1020;color:#e8edf7;border:1px solid #2a3550;border-radius:12px;padding:12px;font-family:ui-monospace,monospace;">${JSON.stringify(config, null, 2)}</textarea>
|
|
216
|
+
<p><button id="save-config" style="margin-top:12px;padding:8px 14px;border-radius:10px;border:0;background:#38bdf8;color:#041018;font-weight:600;cursor:pointer">${t('config.save')}</button></p></div>`;
|
|
217
|
+
document.getElementById('save-config').onclick = async () => {
|
|
218
|
+
await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: document.getElementById('config-json').value });
|
|
219
|
+
alert('Saved');
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function renderMcp(page) {
|
|
224
|
+
const { servers } = await api('/api/mcp');
|
|
225
|
+
const names = Object.keys(servers ?? {});
|
|
226
|
+
if (!names.length) {
|
|
227
|
+
page.innerHTML = `<div class="card"><h2>${t('mcp.title')}</h2><p class="hint">${t('mcp.empty')}</p></div>`;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
page.innerHTML = `<div class="card"><h2>${t('mcp.title')}</h2>
|
|
231
|
+
<table class="table"><thead><tr><th>name</th><th>command</th><th>enabled</th></tr></thead><tbody>
|
|
232
|
+
${names.map((name) => { const s = servers[name]; return `<tr><td>${name}</td><td>${s.command ?? s.url ?? ''}</td><td>${s.enabled === false ? 'no' : 'yes'}</td></tr>`; }).join('')}
|
|
233
|
+
</tbody></table></div>`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function renderBrain(page) {
|
|
237
|
+
const { brainPath } = await api('/api/brain');
|
|
238
|
+
page.innerHTML = `<div class="card"><h2>${t('brain.title')}</h2>
|
|
239
|
+
<p>${brainPath ?? `<span class="hint">${t('brain.empty')}</span>`}</p></div>`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function renderRoute() {
|
|
243
|
+
const hash = location.hash.replace(/^#/, '') || '/';
|
|
244
|
+
const route = routes.find((r) => r.path === `#${hash}`) ?? routes[0];
|
|
245
|
+
document.getElementById('page-title').textContent = t(`nav.${route.id}`);
|
|
246
|
+
document.querySelector('.brand-title').textContent = t('productName');
|
|
247
|
+
document.querySelector('.brand-tagline').textContent = t('tagline');
|
|
248
|
+
renderNav(route.id);
|
|
249
|
+
const page = document.getElementById('page');
|
|
250
|
+
page.innerHTML = '<p class="hint">Loading…</p>';
|
|
251
|
+
try {
|
|
252
|
+
const map = {
|
|
253
|
+
home: renderHome,
|
|
254
|
+
chat: renderChat,
|
|
255
|
+
models: renderModels,
|
|
256
|
+
sessions: renderSessions,
|
|
257
|
+
files: renderFiles,
|
|
258
|
+
logs: renderLogs,
|
|
259
|
+
cron: renderCron,
|
|
260
|
+
channels: renderChannels,
|
|
261
|
+
config: renderConfig,
|
|
262
|
+
mcp: renderMcp,
|
|
263
|
+
brain: renderBrain,
|
|
264
|
+
};
|
|
265
|
+
await (map[route.id] ?? renderHome)(page);
|
|
266
|
+
} catch (e) {
|
|
267
|
+
page.innerHTML = `<div class="card"><p class="hint">${e.message}</p></div>`;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
document.getElementById('locale-select').value = locale();
|
|
272
|
+
document.getElementById('locale-select').onchange = (e) => {
|
|
273
|
+
localStorage.setItem('sanook-dashboard-locale', e.target.value);
|
|
274
|
+
renderRoute();
|
|
275
|
+
};
|
|
276
|
+
window.addEventListener('hashchange', renderRoute);
|
|
277
|
+
renderRoute();
|