zubo 0.1.0
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/.github/workflows/ci.yml +35 -0
- package/README.md +149 -0
- package/bun.lock +216 -0
- package/desktop/README.md +57 -0
- package/desktop/package.json +12 -0
- package/desktop/src-tauri/Cargo.toml +25 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/icons/README.md +17 -0
- package/desktop/src-tauri/icons/icon.png +0 -0
- package/desktop/src-tauri/src/main.rs +189 -0
- package/desktop/src-tauri/tauri.conf.json +68 -0
- package/docs/ROADMAP.md +490 -0
- package/migrations/001_init.sql +9 -0
- package/migrations/002_memory.sql +33 -0
- package/migrations/003_cron.sql +24 -0
- package/migrations/004_usage.sql +12 -0
- package/migrations/005_secrets.sql +8 -0
- package/migrations/006_agents.sql +1 -0
- package/migrations/007_workflows.sql +22 -0
- package/migrations/008_proactive.sql +24 -0
- package/migrations/009_uploads.sql +9 -0
- package/migrations/010_observability.sql +22 -0
- package/migrations/011_api_keys.sql +7 -0
- package/migrations/012_indexes.sql +5 -0
- package/migrations/013_budget.sql +11 -0
- package/migrations/014_usage_session_idx.sql +2 -0
- package/package.json +39 -0
- package/site/404.html +156 -0
- package/site/CNAME +1 -0
- package/site/docs/agents.html +294 -0
- package/site/docs/api.html +446 -0
- package/site/docs/channels.html +345 -0
- package/site/docs/cli.html +238 -0
- package/site/docs/config.html +1034 -0
- package/site/docs/index.html +433 -0
- package/site/docs/integrations.html +381 -0
- package/site/docs/memory.html +254 -0
- package/site/docs/security.html +375 -0
- package/site/docs/skills.html +322 -0
- package/site/docs.css +412 -0
- package/site/index.html +638 -0
- package/site/install.sh +98 -0
- package/site/logo.svg +1 -0
- package/site/og-image.png +0 -0
- package/site/robots.txt +4 -0
- package/site/script.js +361 -0
- package/site/sitemap.xml +63 -0
- package/site/skills.html +532 -0
- package/site/style.css +1686 -0
- package/src/agent/agents.ts +159 -0
- package/src/agent/compaction.ts +53 -0
- package/src/agent/context.ts +18 -0
- package/src/agent/delegate.ts +118 -0
- package/src/agent/loop.ts +318 -0
- package/src/agent/prompts.ts +111 -0
- package/src/agent/session.ts +87 -0
- package/src/agent/teams.ts +116 -0
- package/src/agent/workflow-executor.ts +192 -0
- package/src/agent/workflow.ts +175 -0
- package/src/channels/adapter.ts +21 -0
- package/src/channels/dashboard.html.ts +2969 -0
- package/src/channels/discord.ts +137 -0
- package/src/channels/optional-deps.d.ts +17 -0
- package/src/channels/router.ts +199 -0
- package/src/channels/signal.ts +133 -0
- package/src/channels/slack.ts +101 -0
- package/src/channels/telegram.ts +102 -0
- package/src/channels/utils.ts +18 -0
- package/src/channels/webchat.ts +1797 -0
- package/src/channels/whatsapp.ts +119 -0
- package/src/config/loader.ts +22 -0
- package/src/config/paths.ts +43 -0
- package/src/config/schema.ts +121 -0
- package/src/db/connection.ts +20 -0
- package/src/db/export.ts +148 -0
- package/src/db/migrations.ts +42 -0
- package/src/index.ts +261 -0
- package/src/llm/claude.ts +193 -0
- package/src/llm/factory.ts +115 -0
- package/src/llm/failover.ts +101 -0
- package/src/llm/openai-compat.ts +409 -0
- package/src/llm/provider.ts +83 -0
- package/src/llm/smart-router.ts +241 -0
- package/src/logs.ts +53 -0
- package/src/memory/chunker.ts +58 -0
- package/src/memory/document-parser.ts +115 -0
- package/src/memory/embedder.ts +235 -0
- package/src/memory/engine.ts +170 -0
- package/src/memory/fts-index.ts +55 -0
- package/src/memory/hybrid-search.ts +72 -0
- package/src/memory/store.ts +56 -0
- package/src/memory/vector-index.ts +72 -0
- package/src/model.ts +118 -0
- package/src/registry/cli.ts +43 -0
- package/src/registry/client.ts +54 -0
- package/src/registry/installer.ts +67 -0
- package/src/scheduler/briefing.ts +71 -0
- package/src/scheduler/cron.ts +258 -0
- package/src/scheduler/heartbeat.ts +58 -0
- package/src/scheduler/memory-triggers.ts +100 -0
- package/src/scheduler/natural-cron.ts +163 -0
- package/src/scheduler/proactive.ts +25 -0
- package/src/scheduler/recipes.ts +110 -0
- package/src/secrets/store.ts +64 -0
- package/src/setup.ts +413 -0
- package/src/skills.ts +293 -0
- package/src/start.ts +373 -0
- package/src/status.ts +165 -0
- package/src/tools/builtin/connect-service.ts +205 -0
- package/src/tools/builtin/cron.ts +126 -0
- package/src/tools/builtin/datetime.ts +36 -0
- package/src/tools/builtin/delegate-task.ts +81 -0
- package/src/tools/builtin/delegate.ts +42 -0
- package/src/tools/builtin/diagnose.ts +41 -0
- package/src/tools/builtin/google-oauth.ts +379 -0
- package/src/tools/builtin/manage-agents.ts +149 -0
- package/src/tools/builtin/manage-skills.ts +294 -0
- package/src/tools/builtin/manage-teams.ts +89 -0
- package/src/tools/builtin/manage-triggers.ts +94 -0
- package/src/tools/builtin/manage-workflows.ts +119 -0
- package/src/tools/builtin/memory-search.ts +38 -0
- package/src/tools/builtin/memory-write.ts +30 -0
- package/src/tools/builtin/run-workflow.ts +36 -0
- package/src/tools/builtin/secrets.ts +122 -0
- package/src/tools/builtin/skill-registry.ts +75 -0
- package/src/tools/builtin-integrations/api-helpers.ts +26 -0
- package/src/tools/builtin-integrations/github/github_issues/SKILL.md +56 -0
- package/src/tools/builtin-integrations/github/github_issues/handler.ts +108 -0
- package/src/tools/builtin-integrations/github/github_prs/SKILL.md +57 -0
- package/src/tools/builtin-integrations/github/github_prs/handler.ts +113 -0
- package/src/tools/builtin-integrations/github/github_repos/SKILL.md +37 -0
- package/src/tools/builtin-integrations/github/github_repos/handler.ts +88 -0
- package/src/tools/builtin-integrations/google/gmail/SKILL.md +51 -0
- package/src/tools/builtin-integrations/google/gmail/handler.ts +125 -0
- package/src/tools/builtin-integrations/google/google_calendar/SKILL.md +35 -0
- package/src/tools/builtin-integrations/google/google_calendar/handler.ts +105 -0
- package/src/tools/builtin-integrations/google/google_docs/SKILL.md +35 -0
- package/src/tools/builtin-integrations/google/google_docs/handler.ts +108 -0
- package/src/tools/builtin-integrations/google/google_drive/SKILL.md +39 -0
- package/src/tools/builtin-integrations/google/google_drive/handler.ts +106 -0
- package/src/tools/builtin-integrations/google/google_sheets/SKILL.md +36 -0
- package/src/tools/builtin-integrations/google/google_sheets/handler.ts +116 -0
- package/src/tools/builtin-integrations/jira/jira_boards/SKILL.md +21 -0
- package/src/tools/builtin-integrations/jira/jira_boards/handler.ts +74 -0
- package/src/tools/builtin-integrations/jira/jira_issues/SKILL.md +28 -0
- package/src/tools/builtin-integrations/jira/jira_issues/handler.ts +140 -0
- package/src/tools/builtin-integrations/linear/linear_issues/SKILL.md +30 -0
- package/src/tools/builtin-integrations/linear/linear_issues/handler.ts +75 -0
- package/src/tools/builtin-integrations/linear/linear_projects/SKILL.md +21 -0
- package/src/tools/builtin-integrations/linear/linear_projects/handler.ts +43 -0
- package/src/tools/builtin-integrations/notion/notion_databases/SKILL.md +39 -0
- package/src/tools/builtin-integrations/notion/notion_databases/handler.ts +83 -0
- package/src/tools/builtin-integrations/notion/notion_pages/SKILL.md +43 -0
- package/src/tools/builtin-integrations/notion/notion_pages/handler.ts +130 -0
- package/src/tools/builtin-integrations/notion/notion_search/SKILL.md +27 -0
- package/src/tools/builtin-integrations/notion/notion_search/handler.ts +69 -0
- package/src/tools/builtin-integrations/slack/slack_messages/SKILL.md +42 -0
- package/src/tools/builtin-integrations/slack/slack_messages/handler.ts +72 -0
- package/src/tools/builtin-integrations/twitter/twitter_posts/SKILL.md +24 -0
- package/src/tools/builtin-integrations/twitter/twitter_posts/handler.ts +133 -0
- package/src/tools/builtin-skills/file-read/SKILL.md +26 -0
- package/src/tools/builtin-skills/file-read/handler.ts +66 -0
- package/src/tools/builtin-skills/file-write/SKILL.md +30 -0
- package/src/tools/builtin-skills/file-write/handler.ts +64 -0
- package/src/tools/builtin-skills/http-request/SKILL.md +34 -0
- package/src/tools/builtin-skills/http-request/handler.ts +87 -0
- package/src/tools/builtin-skills/shell/SKILL.md +26 -0
- package/src/tools/builtin-skills/shell/handler.ts +96 -0
- package/src/tools/builtin-skills/url-fetch/SKILL.md +26 -0
- package/src/tools/builtin-skills/url-fetch/handler.ts +37 -0
- package/src/tools/builtin-skills/web-search/SKILL.md +26 -0
- package/src/tools/builtin-skills/web-search/handler.ts +50 -0
- package/src/tools/executor.ts +205 -0
- package/src/tools/integration-installer.ts +106 -0
- package/src/tools/permissions.ts +45 -0
- package/src/tools/registry.ts +39 -0
- package/src/tools/sandbox-runner.ts +56 -0
- package/src/tools/sandbox.ts +82 -0
- package/src/tools/skill-installer.ts +52 -0
- package/src/tools/skill-loader.ts +259 -0
- package/src/types/optional-deps.d.ts +23 -0
- package/src/util/auth.ts +121 -0
- package/src/util/costs.ts +59 -0
- package/src/util/error-buffer.ts +32 -0
- package/src/util/google-tokens.ts +180 -0
- package/src/util/logger.ts +73 -0
- package/src/util/perf-collector.ts +35 -0
- package/src/util/rate-limiter.ts +70 -0
- package/src/util/tokens.ts +17 -0
- package/src/voice/stt.ts +57 -0
- package/src/voice/tts.ts +103 -0
- package/tests/agent/session.test.ts +109 -0
- package/tests/agent-loop.test.ts +54 -0
- package/tests/auth.test.ts +89 -0
- package/tests/channels.test.ts +67 -0
- package/tests/compaction.test.ts +44 -0
- package/tests/config.test.ts +51 -0
- package/tests/costs.test.ts +19 -0
- package/tests/cron.test.ts +55 -0
- package/tests/db/export.test.ts +219 -0
- package/tests/executor.test.ts +144 -0
- package/tests/export.test.ts +137 -0
- package/tests/helpers/mock-llm.ts +34 -0
- package/tests/helpers/test-db.ts +74 -0
- package/tests/integration/chat-flow.test.ts +48 -0
- package/tests/integrations.test.ts +97 -0
- package/tests/memory/engine.test.ts +114 -0
- package/tests/memory-engine.test.ts +57 -0
- package/tests/permissions.test.ts +21 -0
- package/tests/rate-limiter.test.ts +70 -0
- package/tests/registry.test.ts +67 -0
- package/tests/router.test.ts +36 -0
- package/tests/session.test.ts +58 -0
- package/tests/skill-loader.test.ts +44 -0
- package/tests/tokens.test.ts +30 -0
- package/tests/tools/executor.test.ts +130 -0
- package/tests/util/auth.test.ts +75 -0
- package/tests/util/rate-limiter.test.ts +73 -0
- package/tests/voice.test.ts +60 -0
- package/tests/webchat.test.ts +88 -0
- package/tests/workflow.test.ts +38 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,1797 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import type { ChannelAdapter, InboundMessage } from "./adapter";
|
|
4
|
+
import type { MessageRouter } from "./router";
|
|
5
|
+
import { paths } from "../config/paths";
|
|
6
|
+
import { getDb } from "../db/connection";
|
|
7
|
+
import { getHeartbeatMinutes, restartHeartbeat } from "../scheduler/heartbeat";
|
|
8
|
+
import { logger } from "../util/logger";
|
|
9
|
+
import { DASHBOARD_HTML } from "./dashboard.html";
|
|
10
|
+
import { parseSkillMd, parseSkillExport } from "../tools/skill-loader";
|
|
11
|
+
import { RateLimiter } from "../util/rate-limiter";
|
|
12
|
+
import { initAuth, validateRequest, createApiKey, listApiKeys, deleteApiKey, generateSessionToken } from "../util/auth";
|
|
13
|
+
import { exportDatabase, backupDatabase, importDatabase, getDbStats, getDbSizeBytes } from "../db/export";
|
|
14
|
+
|
|
15
|
+
// Dashboard API helpers
|
|
16
|
+
function readFileOr(path: string, fallback: string): string {
|
|
17
|
+
try {
|
|
18
|
+
if (existsSync(path)) return readFileSync(path, "utf-8");
|
|
19
|
+
} catch (err: any) {
|
|
20
|
+
logger.warn("Failed to read file", { path, error: (err as Error).message });
|
|
21
|
+
}
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getStatusData(): Record<string, string> {
|
|
26
|
+
const data: Record<string, string> = {};
|
|
27
|
+
|
|
28
|
+
// Provider
|
|
29
|
+
try {
|
|
30
|
+
const config = JSON.parse(readFileSync(paths.config, "utf-8"));
|
|
31
|
+
if (config.providers && config.activeProvider) {
|
|
32
|
+
const p = config.providers[config.activeProvider];
|
|
33
|
+
data["Provider"] = `${config.activeProvider}/${p?.model ?? "?"}`;
|
|
34
|
+
} else if (config.anthropicApiKey) {
|
|
35
|
+
data["Provider"] = `anthropic/${config.model ?? "claude-sonnet-4-5"}`;
|
|
36
|
+
}
|
|
37
|
+
// Channels
|
|
38
|
+
const ch: string[] = [];
|
|
39
|
+
if (config.channels?.telegram?.botToken || config.telegramBotToken) ch.push("telegram");
|
|
40
|
+
if (config.channels?.discord?.botToken) ch.push("discord");
|
|
41
|
+
if (config.channels?.webchat) ch.push("webchat");
|
|
42
|
+
data["Channels"] = ch.join(", ") || "none";
|
|
43
|
+
} catch (err: any) {
|
|
44
|
+
logger.warn("Failed to read config for status data", { error: (err as Error).message });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// DB stats
|
|
48
|
+
try {
|
|
49
|
+
const db = getDb();
|
|
50
|
+
const msgs = (db.query("SELECT COUNT(*) as c FROM messages").get() as any)?.c ?? 0;
|
|
51
|
+
const mems = (db.query("SELECT COUNT(*) as c FROM memory_chunks").get() as any)?.c ?? 0;
|
|
52
|
+
data["Messages"] = String(msgs);
|
|
53
|
+
data["Memories"] = String(mems);
|
|
54
|
+
} catch (err: any) {
|
|
55
|
+
logger.warn("Failed to read DB stats", { error: (err as Error).message });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Daemon
|
|
59
|
+
try {
|
|
60
|
+
if (existsSync(paths.pidFile)) {
|
|
61
|
+
const pid = parseInt(readFileSync(paths.pidFile, "utf-8").trim(), 10);
|
|
62
|
+
process.kill(pid, 0);
|
|
63
|
+
data["Status"] = "running";
|
|
64
|
+
} else {
|
|
65
|
+
data["Status"] = "running"; // if we're serving this, we're running
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
data["Status"] = "running";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return data;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getCronJobs(): any[] {
|
|
75
|
+
try {
|
|
76
|
+
const db = getDb();
|
|
77
|
+
return db.query("SELECT * FROM cron_jobs ORDER BY id").all() as any[];
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getRecentMemoryChunks(): any[] {
|
|
84
|
+
try {
|
|
85
|
+
const db = getDb();
|
|
86
|
+
return db
|
|
87
|
+
.query(
|
|
88
|
+
"SELECT source_file as source, content FROM memory_chunks ORDER BY id DESC LIMIT 20"
|
|
89
|
+
)
|
|
90
|
+
.all() as any[];
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function searchMemoryChunks(query: string): any[] {
|
|
97
|
+
try {
|
|
98
|
+
// Sanitize FTS5 input — strip special operators to prevent query injection
|
|
99
|
+
const sanitized = query.replace(/['"*()[\]{}:^~+\-!/\\]/g, " ").replace(/\b(AND|OR|NOT|NEAR)\b/gi, "").trim();
|
|
100
|
+
const terms = sanitized.split(/\s+/).filter(Boolean);
|
|
101
|
+
if (!terms.length) return [];
|
|
102
|
+
const ftsQuery = terms.join(" OR ");
|
|
103
|
+
const db = getDb();
|
|
104
|
+
return db
|
|
105
|
+
.query(
|
|
106
|
+
"SELECT mc.source_file as source, mc.content FROM memory_fts f JOIN memory_chunks mc ON mc.id = f.rowid WHERE memory_fts MATCH ? ORDER BY rank LIMIT 20"
|
|
107
|
+
)
|
|
108
|
+
.all(ftsQuery) as any[];
|
|
109
|
+
} catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getSkillsData(): { name: string; description: string; status: string; path: string }[] {
|
|
115
|
+
const skillsDir = paths.skills;
|
|
116
|
+
const results: { name: string; description: string; status: string; path: string }[] = [];
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
if (!existsSync(skillsDir)) return results;
|
|
120
|
+
const entries = readdirSync(skillsDir) as string[];
|
|
121
|
+
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
const dirPath = join(skillsDir, entry);
|
|
124
|
+
const skillMdPath = join(dirPath, "SKILL.md");
|
|
125
|
+
const handlerPath = join(dirPath, "handler.ts");
|
|
126
|
+
|
|
127
|
+
if (!existsSync(handlerPath)) continue;
|
|
128
|
+
|
|
129
|
+
let parsed = null;
|
|
130
|
+
if (existsSync(skillMdPath)) {
|
|
131
|
+
const mdContent = readFileSync(skillMdPath, "utf-8");
|
|
132
|
+
parsed = parseSkillMd(mdContent, dirPath);
|
|
133
|
+
} else {
|
|
134
|
+
// Try single-file format: check for exported skill config via regex
|
|
135
|
+
try {
|
|
136
|
+
const handlerContent = readFileSync(handlerPath, "utf-8");
|
|
137
|
+
const nameMatch = handlerContent.match(/name\s*:\s*["']([^"']+)["']/);
|
|
138
|
+
const descMatch = handlerContent.match(/description\s*:\s*["']([^"']+)["']/);
|
|
139
|
+
if (nameMatch && descMatch) {
|
|
140
|
+
parsed = { name: nameMatch[1], description: descMatch[1] };
|
|
141
|
+
}
|
|
142
|
+
} catch (err: any) {
|
|
143
|
+
logger.warn("Failed to parse skill handler", { error: (err as Error).message });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const status = parsed ? "ok" : "error";
|
|
148
|
+
const name = parsed?.name ?? entry;
|
|
149
|
+
const description = parsed?.description?.split("\n")[0].slice(0, 100) ?? "";
|
|
150
|
+
|
|
151
|
+
results.push({ name, description, status, path: dirPath });
|
|
152
|
+
}
|
|
153
|
+
} catch (err: any) {
|
|
154
|
+
logger.warn("Failed to read skills directory", { error: (err as Error).message });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return results;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getConfigInfo(): {
|
|
161
|
+
activeProvider: string;
|
|
162
|
+
model: string;
|
|
163
|
+
providers: { name: string; model: string }[];
|
|
164
|
+
} {
|
|
165
|
+
try {
|
|
166
|
+
const config = JSON.parse(readFileSync(paths.config, "utf-8"));
|
|
167
|
+
const providers: { name: string; model: string }[] = [];
|
|
168
|
+
|
|
169
|
+
if (config.providers) {
|
|
170
|
+
for (const [name, p] of Object.entries(config.providers) as [string, any][]) {
|
|
171
|
+
providers.push({ name, model: p.model ?? "" });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let activeProvider = config.activeProvider ?? "";
|
|
176
|
+
let model = "";
|
|
177
|
+
|
|
178
|
+
if (config.providers && config.activeProvider) {
|
|
179
|
+
const active = config.providers[config.activeProvider];
|
|
180
|
+
model = active?.model ?? "";
|
|
181
|
+
} else if (config.anthropicApiKey) {
|
|
182
|
+
activeProvider = "anthropic";
|
|
183
|
+
model = config.model ?? "claude-sonnet-4-5-20250929";
|
|
184
|
+
// Include legacy provider in list if not already there
|
|
185
|
+
if (!providers.find((p) => p.name === "anthropic")) {
|
|
186
|
+
providers.push({ name: "anthropic", model });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { activeProvider, model, providers };
|
|
191
|
+
} catch {
|
|
192
|
+
return { activeProvider: "", model: "", providers: [] };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function switchModelConfig(provider: string, model: string): { ok: boolean; error?: string } {
|
|
197
|
+
try {
|
|
198
|
+
if (!model) {
|
|
199
|
+
return { ok: false, error: "Model is required" };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const config = JSON.parse(readFileSync(paths.config, "utf-8"));
|
|
203
|
+
|
|
204
|
+
if (!config.providers) config.providers = {};
|
|
205
|
+
|
|
206
|
+
if (!config.providers[provider]) {
|
|
207
|
+
return { ok: false, error: `Provider "${provider}" is not configured. Add it via 'zubo setup' first.` };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
config.activeProvider = provider;
|
|
211
|
+
config.providers[provider].model = model;
|
|
212
|
+
// Keep legacy field in sync
|
|
213
|
+
config.model = model;
|
|
214
|
+
|
|
215
|
+
writeFileSync(paths.config, JSON.stringify(config, null, 2) + "\n");
|
|
216
|
+
return { ok: true };
|
|
217
|
+
} catch (err: any) {
|
|
218
|
+
return { ok: false, error: err.message };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function handleDashboardApi(url: URL, req: Request): Response | null {
|
|
223
|
+
const path = url.pathname.replace("/api/dashboard", "");
|
|
224
|
+
|
|
225
|
+
// GET /api/dashboard/status
|
|
226
|
+
if (path === "/status" && req.method === "GET") {
|
|
227
|
+
return Response.json(getStatusData());
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// GET /api/dashboard/system
|
|
231
|
+
if (path === "/system" && req.method === "GET") {
|
|
232
|
+
return Response.json({
|
|
233
|
+
content: readFileOr(paths.systemPrompt, ""),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// PUT /api/dashboard/system
|
|
238
|
+
if (path === "/system" && req.method === "PUT") {
|
|
239
|
+
return (async () => {
|
|
240
|
+
const body = (await req.json()) as { content?: string };
|
|
241
|
+
writeFileSync(paths.systemPrompt, body.content ?? "");
|
|
242
|
+
return Response.json({ ok: true });
|
|
243
|
+
})() as any;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// GET /api/dashboard/memory
|
|
247
|
+
if (path === "/memory" && req.method === "GET") {
|
|
248
|
+
return Response.json({
|
|
249
|
+
content: readFileOr(paths.memoryFile, ""),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// PUT /api/dashboard/memory
|
|
254
|
+
if (path === "/memory" && req.method === "PUT") {
|
|
255
|
+
return (async () => {
|
|
256
|
+
const body = (await req.json()) as { content?: string };
|
|
257
|
+
writeFileSync(paths.memoryFile, body.content ?? "");
|
|
258
|
+
return Response.json({ ok: true });
|
|
259
|
+
})() as any;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// GET /api/dashboard/memory/recent
|
|
263
|
+
if (path === "/memory/recent" && req.method === "GET") {
|
|
264
|
+
return Response.json({ results: getRecentMemoryChunks() });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// GET /api/dashboard/memory/search?q=...
|
|
268
|
+
if (path === "/memory/search" && req.method === "GET") {
|
|
269
|
+
const q = url.searchParams.get("q") ?? "";
|
|
270
|
+
return Response.json({ results: searchMemoryChunks(q) });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// GET /api/dashboard/cron
|
|
274
|
+
if (path === "/cron" && req.method === "GET") {
|
|
275
|
+
return Response.json({ jobs: getCronJobs() });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// GET /api/dashboard/logs
|
|
279
|
+
if (path === "/logs" && req.method === "GET") {
|
|
280
|
+
const content = readFileOr(paths.logFile, "");
|
|
281
|
+
const lines = content.trimEnd().split("\n");
|
|
282
|
+
return Response.json({ content: lines.slice(-100).join("\n") });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// GET /api/dashboard/skills
|
|
286
|
+
if (path === "/skills" && req.method === "GET") {
|
|
287
|
+
return Response.json({ skills: getSkillsData() });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// GET /api/dashboard/config
|
|
291
|
+
if (path === "/config" && req.method === "GET") {
|
|
292
|
+
return Response.json(getConfigInfo());
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// PUT /api/dashboard/config/model — switch provider/model
|
|
296
|
+
if (path === "/config/model" && req.method === "PUT") {
|
|
297
|
+
return (async () => {
|
|
298
|
+
const body = (await req.json()) as { provider?: string; model?: string };
|
|
299
|
+
if (!body.provider) {
|
|
300
|
+
return Response.json({ ok: false, error: "provider is required" }, { status: 400 });
|
|
301
|
+
}
|
|
302
|
+
const result = switchModelConfig(body.provider, body.model ?? "");
|
|
303
|
+
return Response.json(result, { status: result.ok ? 200 : 400 });
|
|
304
|
+
})() as any;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// GET /api/dashboard/settings/heartbeat
|
|
308
|
+
if (path === "/settings/heartbeat" && req.method === "GET") {
|
|
309
|
+
const minutes = getHeartbeatMinutes();
|
|
310
|
+
// Also read saved value from config
|
|
311
|
+
let configMinutes = 30;
|
|
312
|
+
try {
|
|
313
|
+
const config = JSON.parse(readFileSync(paths.config, "utf-8"));
|
|
314
|
+
configMinutes = config.heartbeatMinutes ?? 30;
|
|
315
|
+
} catch (err: any) {
|
|
316
|
+
logger.warn("Failed to read heartbeat config", { error: (err as Error).message });
|
|
317
|
+
}
|
|
318
|
+
return Response.json({ minutes, configMinutes });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// PUT /api/dashboard/settings/heartbeat
|
|
322
|
+
if (path === "/settings/heartbeat" && req.method === "PUT") {
|
|
323
|
+
return (async () => {
|
|
324
|
+
const body = (await req.json()) as { minutes?: number };
|
|
325
|
+
const mins = body.minutes;
|
|
326
|
+
if (!mins || typeof mins !== "number" || mins < 1 || mins > 1440) {
|
|
327
|
+
return Response.json(
|
|
328
|
+
{ ok: false, error: "minutes must be between 1 and 1440" },
|
|
329
|
+
{ status: 400 }
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
// Save to config
|
|
333
|
+
try {
|
|
334
|
+
const config = JSON.parse(readFileSync(paths.config, "utf-8"));
|
|
335
|
+
config.heartbeatMinutes = mins;
|
|
336
|
+
writeFileSync(paths.config, JSON.stringify(config, null, 2) + "\n");
|
|
337
|
+
} catch (err: any) {
|
|
338
|
+
return Response.json({ ok: false, error: "Failed to save config" }, { status: 500 });
|
|
339
|
+
}
|
|
340
|
+
// Apply immediately
|
|
341
|
+
restartHeartbeat(mins);
|
|
342
|
+
return Response.json({ ok: true, minutes: mins });
|
|
343
|
+
})() as any;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// GET /api/dashboard/smart-routing
|
|
347
|
+
if (path === "/smart-routing" && req.method === "GET") {
|
|
348
|
+
try {
|
|
349
|
+
const config = JSON.parse(readFileSync(paths.config, "utf-8"));
|
|
350
|
+
return Response.json({
|
|
351
|
+
enabled: config.smartRouting?.enabled ?? false,
|
|
352
|
+
fastProvider: config.smartRouting?.fastProvider ?? "",
|
|
353
|
+
fastModel: config.smartRouting?.fastModel ?? "",
|
|
354
|
+
});
|
|
355
|
+
} catch {
|
|
356
|
+
return Response.json({ enabled: false, fastProvider: "", fastModel: "" });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// PUT /api/dashboard/smart-routing
|
|
361
|
+
if (path === "/smart-routing" && req.method === "PUT") {
|
|
362
|
+
return (async () => {
|
|
363
|
+
try {
|
|
364
|
+
const body = (await req.json()) as {
|
|
365
|
+
enabled?: boolean;
|
|
366
|
+
fastProvider?: string;
|
|
367
|
+
fastModel?: string;
|
|
368
|
+
};
|
|
369
|
+
const config = JSON.parse(readFileSync(paths.config, "utf-8"));
|
|
370
|
+
if (!config.smartRouting) {
|
|
371
|
+
config.smartRouting = {};
|
|
372
|
+
}
|
|
373
|
+
if (typeof body.enabled === "boolean") {
|
|
374
|
+
config.smartRouting.enabled = body.enabled;
|
|
375
|
+
}
|
|
376
|
+
if (body.fastProvider !== undefined) {
|
|
377
|
+
config.smartRouting.fastProvider = body.fastProvider;
|
|
378
|
+
}
|
|
379
|
+
if (body.fastModel !== undefined) {
|
|
380
|
+
config.smartRouting.fastModel = body.fastModel;
|
|
381
|
+
}
|
|
382
|
+
writeFileSync(paths.config, JSON.stringify(config, null, 2) + "\n");
|
|
383
|
+
return Response.json({ ok: true });
|
|
384
|
+
} catch (err: any) {
|
|
385
|
+
return Response.json({ ok: false, error: err.message }, { status: 500 });
|
|
386
|
+
}
|
|
387
|
+
})() as any;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// GET /api/dashboard/analytics/summary
|
|
391
|
+
if (path === "/analytics/summary" && req.method === "GET") {
|
|
392
|
+
try {
|
|
393
|
+
const db = getDb();
|
|
394
|
+
const summary = db.query(`
|
|
395
|
+
SELECT
|
|
396
|
+
COALESCE(SUM(input_tokens + output_tokens), 0) as totalTokens,
|
|
397
|
+
COALESCE(SUM(cost_usd), 0) as totalCost,
|
|
398
|
+
COALESCE(AVG(CASE WHEN response_time_ms IS NOT NULL THEN response_time_ms END), 0) as avgResponse,
|
|
399
|
+
COUNT(DISTINCT session_id) as sessionCount
|
|
400
|
+
FROM usage
|
|
401
|
+
`).get() as any;
|
|
402
|
+
return Response.json({
|
|
403
|
+
totalTokens: summary.totalTokens,
|
|
404
|
+
estimatedCostUsd: Math.round(summary.totalCost * 10000) / 10000,
|
|
405
|
+
avgResponseTimeMs: Math.round(summary.avgResponse),
|
|
406
|
+
sessionCount: summary.sessionCount,
|
|
407
|
+
});
|
|
408
|
+
} catch {
|
|
409
|
+
return Response.json({ totalTokens: 0, estimatedCostUsd: 0, avgResponseTimeMs: 0, sessionCount: 0 });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// GET /api/dashboard/analytics/usage-over-time
|
|
414
|
+
if (path === "/analytics/usage-over-time" && req.method === "GET") {
|
|
415
|
+
try {
|
|
416
|
+
const db = getDb();
|
|
417
|
+
const rows = db.query(
|
|
418
|
+
`SELECT date(created_at) as day, SUM(input_tokens) as input, SUM(output_tokens) as output, COALESCE(SUM(cost_usd), 0) as cost
|
|
419
|
+
FROM usage WHERE created_at >= datetime('now', '-7 days') GROUP BY day ORDER BY day`
|
|
420
|
+
).all();
|
|
421
|
+
return Response.json({ days: rows });
|
|
422
|
+
} catch {
|
|
423
|
+
return Response.json({ days: [] });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// GET /api/dashboard/analytics/tools
|
|
428
|
+
if (path === "/analytics/tools" && req.method === "GET") {
|
|
429
|
+
try {
|
|
430
|
+
const db = getDb();
|
|
431
|
+
const tools = db.query(
|
|
432
|
+
`SELECT tool_name, COUNT(*) as calls, AVG(duration_ms) as avg_ms, SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as errors
|
|
433
|
+
FROM tool_metrics GROUP BY tool_name ORDER BY calls DESC LIMIT 20`
|
|
434
|
+
).all();
|
|
435
|
+
return Response.json({ tools });
|
|
436
|
+
} catch {
|
|
437
|
+
return Response.json({ tools: [] });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// GET /api/dashboard/analytics/sessions
|
|
442
|
+
if (path === "/analytics/sessions" && req.method === "GET") {
|
|
443
|
+
try {
|
|
444
|
+
const db = getDb();
|
|
445
|
+
const sessions = db.query(
|
|
446
|
+
`SELECT session_id, provider, model, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens,
|
|
447
|
+
COALESCE(SUM(cost_usd), 0) as cost, COUNT(*) as requests, MAX(created_at) as last_used
|
|
448
|
+
FROM usage GROUP BY session_id ORDER BY last_used DESC LIMIT 20`
|
|
449
|
+
).all();
|
|
450
|
+
return Response.json({ sessions });
|
|
451
|
+
} catch {
|
|
452
|
+
return Response.json({ sessions: [] });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// GET /api/dashboard/onboarding
|
|
457
|
+
if (path === "/onboarding" && req.method === "GET") {
|
|
458
|
+
try {
|
|
459
|
+
const onboardingPath = join(paths.workspace, ".onboarding.json");
|
|
460
|
+
if (existsSync(onboardingPath)) {
|
|
461
|
+
return Response.json(JSON.parse(readFileSync(onboardingPath, "utf-8")));
|
|
462
|
+
}
|
|
463
|
+
return Response.json({ completed: false, step: 0 });
|
|
464
|
+
} catch {
|
|
465
|
+
return Response.json({ completed: false, step: 0 });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// PUT /api/dashboard/onboarding
|
|
470
|
+
if (path === "/onboarding" && req.method === "PUT") {
|
|
471
|
+
return (async () => {
|
|
472
|
+
const body = await req.json() as any;
|
|
473
|
+
const onboardingPath = join(paths.workspace, ".onboarding.json");
|
|
474
|
+
writeFileSync(onboardingPath, JSON.stringify(body, null, 2));
|
|
475
|
+
return Response.json({ ok: true });
|
|
476
|
+
})() as any;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// POST /api/dashboard/test-llm
|
|
480
|
+
if (path === "/test-llm" && req.method === "POST") {
|
|
481
|
+
return (async () => {
|
|
482
|
+
try {
|
|
483
|
+
const { loadConfig } = await import("../config/loader");
|
|
484
|
+
const { createProvider } = await import("../llm/factory");
|
|
485
|
+
const config = await loadConfig();
|
|
486
|
+
const llm = createProvider(config);
|
|
487
|
+
const res = await llm.chat({
|
|
488
|
+
system: "You are a test.",
|
|
489
|
+
messages: [{ role: "user", content: [{ type: "text", text: "Say OK" }] }],
|
|
490
|
+
maxTokens: 10,
|
|
491
|
+
});
|
|
492
|
+
const text = res.content.find((b: any) => b.type === "text")?.text ?? "";
|
|
493
|
+
return Response.json({ ok: true, response: text, model: llm.model });
|
|
494
|
+
} catch (err: any) {
|
|
495
|
+
return Response.json({ ok: false, error: err.message });
|
|
496
|
+
}
|
|
497
|
+
})() as any;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// GET /api/dashboard/channel-status
|
|
501
|
+
if (path === "/channel-status" && req.method === "GET") {
|
|
502
|
+
try {
|
|
503
|
+
const config = JSON.parse(readFileSync(paths.config, "utf-8"));
|
|
504
|
+
const channels: Record<string, { configured: boolean; enabled: boolean }> = {};
|
|
505
|
+
|
|
506
|
+
channels.webchat = { configured: true, enabled: config.channels?.webchat?.enabled !== false };
|
|
507
|
+
channels.telegram = {
|
|
508
|
+
configured: !!(config.channels?.telegram?.botToken || config.telegramBotToken),
|
|
509
|
+
enabled: config.channels?.telegram?.enabled !== false && !!(config.channels?.telegram?.botToken || config.telegramBotToken),
|
|
510
|
+
};
|
|
511
|
+
channels.discord = {
|
|
512
|
+
configured: !!config.channels?.discord?.botToken,
|
|
513
|
+
enabled: config.channels?.discord?.enabled !== false && !!config.channels?.discord?.botToken,
|
|
514
|
+
};
|
|
515
|
+
channels.slack = {
|
|
516
|
+
configured: !!config.channels?.slack?.botToken,
|
|
517
|
+
enabled: config.channels?.slack?.enabled !== false && !!config.channels?.slack?.botToken,
|
|
518
|
+
};
|
|
519
|
+
channels.whatsapp = {
|
|
520
|
+
configured: !!config.channels?.whatsapp,
|
|
521
|
+
enabled: config.channels?.whatsapp?.enabled !== false && !!config.channels?.whatsapp,
|
|
522
|
+
};
|
|
523
|
+
channels.signal = {
|
|
524
|
+
configured: !!config.channels?.signal?.phoneNumber,
|
|
525
|
+
enabled: config.channels?.signal?.enabled !== false && !!config.channels?.signal?.phoneNumber,
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
return Response.json({ channels });
|
|
529
|
+
} catch {
|
|
530
|
+
return Response.json({ channels: {} });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// GET /api/dashboard/registry/search?q=...
|
|
535
|
+
if (path === "/registry/search" && req.method === "GET") {
|
|
536
|
+
return (async () => {
|
|
537
|
+
try {
|
|
538
|
+
const { searchRegistry } = await import("../registry/client");
|
|
539
|
+
const q = url.searchParams.get("q") ?? "";
|
|
540
|
+
const results = await searchRegistry(q);
|
|
541
|
+
return Response.json({ results });
|
|
542
|
+
} catch (err: any) {
|
|
543
|
+
return Response.json({ results: [], error: err.message });
|
|
544
|
+
}
|
|
545
|
+
})() as any;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// POST /api/dashboard/registry/install
|
|
549
|
+
if (path === "/registry/install" && req.method === "POST") {
|
|
550
|
+
return (async () => {
|
|
551
|
+
try {
|
|
552
|
+
const body = await req.json() as { name?: string };
|
|
553
|
+
if (!body.name) return Response.json({ ok: false, error: "name required" }, { status: 400 });
|
|
554
|
+
const { installFromRegistry } = await import("../registry/installer");
|
|
555
|
+
const result = await installFromRegistry(body.name);
|
|
556
|
+
return Response.json({ ok: true, ...result });
|
|
557
|
+
} catch (err: any) {
|
|
558
|
+
return Response.json({ ok: false, error: err.message });
|
|
559
|
+
}
|
|
560
|
+
})() as any;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// GET /api/dashboard/workflows
|
|
564
|
+
if (path === "/workflows" && req.method === "GET") {
|
|
565
|
+
return (async () => {
|
|
566
|
+
try {
|
|
567
|
+
const { loadWorkflowDefinitions } = await import("../agent/workflow");
|
|
568
|
+
const workflows = loadWorkflowDefinitions();
|
|
569
|
+
return Response.json({ workflows: workflows.map((w: any) => ({ name: w.name, description: w.description, agents: w.agents, steps: w.steps.length })) });
|
|
570
|
+
} catch {
|
|
571
|
+
return Response.json({ workflows: [] });
|
|
572
|
+
}
|
|
573
|
+
})() as any;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// GET /api/dashboard/recipes
|
|
577
|
+
if (path === "/recipes" && req.method === "GET") {
|
|
578
|
+
return (async () => {
|
|
579
|
+
try {
|
|
580
|
+
const { WORKFLOW_RECIPES } = await import("../scheduler/recipes");
|
|
581
|
+
const db = getDb();
|
|
582
|
+
// Check which recipes are already installed as cron jobs
|
|
583
|
+
const installedJobs = db.query("SELECT name FROM cron_jobs").all() as { name: string }[];
|
|
584
|
+
const installedNames = new Set(installedJobs.map(j => j.name));
|
|
585
|
+
|
|
586
|
+
const recipes = WORKFLOW_RECIPES.map(r => ({
|
|
587
|
+
...r,
|
|
588
|
+
installed: installedNames.has(r.id),
|
|
589
|
+
}));
|
|
590
|
+
return Response.json({ recipes });
|
|
591
|
+
} catch (err: any) {
|
|
592
|
+
return Response.json({ recipes: [], error: err.message });
|
|
593
|
+
}
|
|
594
|
+
})() as any;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// POST /api/dashboard/recipes/install
|
|
598
|
+
if (path === "/recipes/install" && req.method === "POST") {
|
|
599
|
+
return (async () => {
|
|
600
|
+
try {
|
|
601
|
+
const body = (await req.json()) as { id?: string };
|
|
602
|
+
if (!body.id) return Response.json({ ok: false, error: "Recipe ID required" }, { status: 400 });
|
|
603
|
+
|
|
604
|
+
const { getRecipeById } = await import("../scheduler/recipes");
|
|
605
|
+
const recipe = getRecipeById(body.id);
|
|
606
|
+
if (!recipe) return Response.json({ ok: false, error: "Recipe not found" }, { status: 404 });
|
|
607
|
+
|
|
608
|
+
const db = getDb();
|
|
609
|
+
// Check if already installed
|
|
610
|
+
const existing = db.query("SELECT id FROM cron_jobs WHERE name = ?").get(recipe.id);
|
|
611
|
+
if (existing) return Response.json({ ok: false, error: "Recipe already installed" }, { status: 409 });
|
|
612
|
+
|
|
613
|
+
// Insert as a cron job
|
|
614
|
+
db.prepare(
|
|
615
|
+
"INSERT INTO cron_jobs (name, schedule, task) VALUES (?, ?, ?)"
|
|
616
|
+
).run(recipe.id, recipe.schedule, recipe.task);
|
|
617
|
+
|
|
618
|
+
return Response.json({ ok: true, name: recipe.name });
|
|
619
|
+
} catch (err: any) {
|
|
620
|
+
return Response.json({ ok: false, error: err.message }, { status: 500 });
|
|
621
|
+
}
|
|
622
|
+
})() as any;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// POST /api/dashboard/recipes/uninstall
|
|
626
|
+
if (path === "/recipes/uninstall" && req.method === "POST") {
|
|
627
|
+
return (async () => {
|
|
628
|
+
try {
|
|
629
|
+
const body = (await req.json()) as { id?: string };
|
|
630
|
+
if (!body.id) return Response.json({ ok: false, error: "Recipe ID required" }, { status: 400 });
|
|
631
|
+
|
|
632
|
+
// Validate that this is a known recipe ID — prevent arbitrary cron job deletion
|
|
633
|
+
const { getRecipeById } = await import("../scheduler/recipes");
|
|
634
|
+
if (!getRecipeById(body.id)) {
|
|
635
|
+
return Response.json({ ok: false, error: "Unknown recipe ID" }, { status: 400 });
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const db = getDb();
|
|
639
|
+
const result = db.prepare("DELETE FROM cron_jobs WHERE name = ?").run(body.id);
|
|
640
|
+
return Response.json({ ok: true, deleted: result.changes > 0 });
|
|
641
|
+
} catch (err: any) {
|
|
642
|
+
return Response.json({ ok: false, error: err.message }, { status: 500 });
|
|
643
|
+
}
|
|
644
|
+
})() as any;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// POST /api/dashboard/export — JSON export
|
|
648
|
+
if (path === "/export" && req.method === "POST") {
|
|
649
|
+
try {
|
|
650
|
+
const db = getDb();
|
|
651
|
+
const outputPath = join(paths.workspace, `export-${Date.now()}.json`);
|
|
652
|
+
exportDatabase(db, outputPath);
|
|
653
|
+
const data = readFileSync(outputPath, "utf-8");
|
|
654
|
+
// Clean up temp file
|
|
655
|
+
try { const { unlinkSync } = require("fs"); unlinkSync(outputPath); } catch (err: any) { logger.warn("Failed to clean up export file", { error: (err as Error).message }); }
|
|
656
|
+
return new Response(data, {
|
|
657
|
+
headers: {
|
|
658
|
+
"Content-Type": "application/json",
|
|
659
|
+
"Content-Disposition": `attachment; filename="zubo-export.json"`,
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
} catch (err: any) {
|
|
663
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// POST /api/dashboard/backup — SQLite backup
|
|
668
|
+
if (path === "/backup" && req.method === "POST") {
|
|
669
|
+
try {
|
|
670
|
+
const backupPath = backupDatabase(paths.db, paths.workspace);
|
|
671
|
+
return Response.json({ ok: true, path: backupPath });
|
|
672
|
+
} catch (err: any) {
|
|
673
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// POST /api/dashboard/import — JSON import (max 100MB)
|
|
678
|
+
if (path === "/import" && req.method === "POST") {
|
|
679
|
+
return (async () => {
|
|
680
|
+
const tmpPath = join(paths.workspace, `import-${Date.now()}.json`);
|
|
681
|
+
try {
|
|
682
|
+
const contentLength = parseInt(req.headers.get("content-length") ?? "0", 10);
|
|
683
|
+
if (contentLength > 100 * 1024 * 1024) {
|
|
684
|
+
return Response.json({ error: "Import too large (max 100MB)" }, { status: 413 });
|
|
685
|
+
}
|
|
686
|
+
const body = await req.text();
|
|
687
|
+
if (body.length > 100 * 1024 * 1024) {
|
|
688
|
+
return Response.json({ error: "Import too large (max 100MB)" }, { status: 413 });
|
|
689
|
+
}
|
|
690
|
+
writeFileSync(tmpPath, body);
|
|
691
|
+
const db = getDb();
|
|
692
|
+
const result = importDatabase(db, tmpPath);
|
|
693
|
+
return Response.json({ ok: true, ...result });
|
|
694
|
+
} catch (err: any) {
|
|
695
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
696
|
+
} finally {
|
|
697
|
+
try { const { unlinkSync } = require("fs"); unlinkSync(tmpPath); } catch (err: any) { logger.warn("Failed to clean up import temp file", { error: (err as Error).message }); }
|
|
698
|
+
}
|
|
699
|
+
})() as any;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// GET /api/dashboard/db-stats
|
|
703
|
+
if (path === "/db-stats" && req.method === "GET") {
|
|
704
|
+
try {
|
|
705
|
+
const db = getDb();
|
|
706
|
+
const stats = getDbStats(db);
|
|
707
|
+
const sizeBytes = getDbSizeBytes(paths.db);
|
|
708
|
+
return Response.json({ ...stats, sizeBytes });
|
|
709
|
+
} catch {
|
|
710
|
+
return Response.json({ tables: {}, sizeBytes: 0 });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// GET /api/dashboard/agents
|
|
715
|
+
if (path === "/agents" && req.method === "GET") {
|
|
716
|
+
return (async () => {
|
|
717
|
+
try {
|
|
718
|
+
const { loadAgentDefinitions } = await import("../agent/agents");
|
|
719
|
+
const agents = loadAgentDefinitions();
|
|
720
|
+
return Response.json({ agents: agents.map((a: any) => ({ name: a.name, description: a.description, tools: a.tools?.length ?? 0 })) });
|
|
721
|
+
} catch {
|
|
722
|
+
return Response.json({ agents: [] });
|
|
723
|
+
}
|
|
724
|
+
})() as any;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// GET /api/dashboard/analytics/perf-snapshots
|
|
728
|
+
if (path === "/analytics/perf-snapshots" && req.method === "GET") {
|
|
729
|
+
try {
|
|
730
|
+
const db = getDb();
|
|
731
|
+
const rows = db.query(
|
|
732
|
+
`SELECT rss_mb, heap_mb, db_size_mb, created_at
|
|
733
|
+
FROM perf_snapshots WHERE created_at >= datetime('now', '-7 days')
|
|
734
|
+
ORDER BY created_at`
|
|
735
|
+
).all();
|
|
736
|
+
return Response.json({ snapshots: rows });
|
|
737
|
+
} catch {
|
|
738
|
+
return Response.json({ snapshots: [] });
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// GET /api/dashboard/analytics/cost-breakdown
|
|
743
|
+
if (path === "/analytics/cost-breakdown" && req.method === "GET") {
|
|
744
|
+
try {
|
|
745
|
+
const db = getDb();
|
|
746
|
+
const rows = db.query(
|
|
747
|
+
`SELECT provider, model,
|
|
748
|
+
SUM(input_tokens + output_tokens) as total_tokens,
|
|
749
|
+
COALESCE(SUM(cost_usd), 0) as total_cost,
|
|
750
|
+
COUNT(*) as requests
|
|
751
|
+
FROM usage GROUP BY provider, model ORDER BY total_cost DESC`
|
|
752
|
+
).all();
|
|
753
|
+
return Response.json({ breakdown: rows });
|
|
754
|
+
} catch {
|
|
755
|
+
return Response.json({ breakdown: [] });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// GET /api/dashboard/analytics/response-time-trend
|
|
760
|
+
if (path === "/analytics/response-time-trend" && req.method === "GET") {
|
|
761
|
+
try {
|
|
762
|
+
const db = getDb();
|
|
763
|
+
const rows = db.query(
|
|
764
|
+
`SELECT date(created_at) as day,
|
|
765
|
+
ROUND(AVG(response_time_ms)) as avg_ms,
|
|
766
|
+
MIN(response_time_ms) as min_ms,
|
|
767
|
+
MAX(response_time_ms) as max_ms,
|
|
768
|
+
COUNT(*) as requests
|
|
769
|
+
FROM usage WHERE response_time_ms IS NOT NULL
|
|
770
|
+
AND created_at >= datetime('now', '-7 days')
|
|
771
|
+
GROUP BY day ORDER BY day`
|
|
772
|
+
).all();
|
|
773
|
+
return Response.json({ trend: rows });
|
|
774
|
+
} catch {
|
|
775
|
+
return Response.json({ trend: [] });
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// GET /api/dashboard/analytics/top-models
|
|
780
|
+
if (path === "/analytics/top-models" && req.method === "GET") {
|
|
781
|
+
try {
|
|
782
|
+
const db = getDb();
|
|
783
|
+
const rows = db.query(
|
|
784
|
+
`SELECT provider, model,
|
|
785
|
+
COUNT(*) as requests,
|
|
786
|
+
SUM(input_tokens + output_tokens) as total_tokens,
|
|
787
|
+
COALESCE(SUM(cost_usd), 0) as total_cost,
|
|
788
|
+
ROUND(AVG(response_time_ms)) as avg_response_ms
|
|
789
|
+
FROM usage GROUP BY provider, model
|
|
790
|
+
ORDER BY total_tokens DESC LIMIT 10`
|
|
791
|
+
).all();
|
|
792
|
+
return Response.json({ models: rows });
|
|
793
|
+
} catch {
|
|
794
|
+
return Response.json({ models: [] });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// --- Budget controls ---
|
|
799
|
+
|
|
800
|
+
// GET /api/dashboard/budget
|
|
801
|
+
if (path === "/budget" && req.method === "GET") {
|
|
802
|
+
try {
|
|
803
|
+
const db = getDb();
|
|
804
|
+
// Ensure table exists (migration may not have run yet)
|
|
805
|
+
db.run(`CREATE TABLE IF NOT EXISTS budget_config (
|
|
806
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
807
|
+
daily_limit_usd REAL,
|
|
808
|
+
monthly_limit_usd REAL,
|
|
809
|
+
alert_threshold REAL DEFAULT 0.8,
|
|
810
|
+
paused INTEGER NOT NULL DEFAULT 0,
|
|
811
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
812
|
+
)`);
|
|
813
|
+
db.run("INSERT OR IGNORE INTO budget_config (id) VALUES (1)");
|
|
814
|
+
|
|
815
|
+
const config = db.query("SELECT * FROM budget_config WHERE id = 1").get() as any;
|
|
816
|
+
|
|
817
|
+
// Calculate current spend
|
|
818
|
+
const todaySpend = db.query(
|
|
819
|
+
"SELECT COALESCE(SUM(cost_usd), 0) as total FROM usage WHERE date(created_at) = date('now') AND cost_usd IS NOT NULL"
|
|
820
|
+
).get() as any;
|
|
821
|
+
|
|
822
|
+
const monthSpend = db.query(
|
|
823
|
+
"SELECT COALESCE(SUM(cost_usd), 0) as total FROM usage WHERE created_at >= datetime('now', 'start of month') AND cost_usd IS NOT NULL"
|
|
824
|
+
).get() as any;
|
|
825
|
+
|
|
826
|
+
// Last 7 days daily breakdown
|
|
827
|
+
const dailyBreakdown = db.query(
|
|
828
|
+
`SELECT date(created_at) as day, COALESCE(SUM(cost_usd), 0) as cost, SUM(input_tokens + output_tokens) as tokens
|
|
829
|
+
FROM usage WHERE created_at >= datetime('now', '-7 days') AND cost_usd IS NOT NULL
|
|
830
|
+
GROUP BY day ORDER BY day`
|
|
831
|
+
).all();
|
|
832
|
+
|
|
833
|
+
return Response.json({
|
|
834
|
+
daily_limit_usd: config?.daily_limit_usd ?? null,
|
|
835
|
+
monthly_limit_usd: config?.monthly_limit_usd ?? null,
|
|
836
|
+
alert_threshold: config?.alert_threshold ?? 0.8,
|
|
837
|
+
paused: config?.paused === 1,
|
|
838
|
+
today_spend_usd: Math.round((todaySpend?.total ?? 0) * 10000) / 10000,
|
|
839
|
+
month_spend_usd: Math.round((monthSpend?.total ?? 0) * 10000) / 10000,
|
|
840
|
+
daily_breakdown: dailyBreakdown,
|
|
841
|
+
});
|
|
842
|
+
} catch (err: any) {
|
|
843
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// PUT /api/dashboard/budget
|
|
848
|
+
if (path === "/budget" && req.method === "PUT") {
|
|
849
|
+
return (async () => {
|
|
850
|
+
try {
|
|
851
|
+
const body = (await req.json()) as {
|
|
852
|
+
daily_limit_usd?: number | null;
|
|
853
|
+
monthly_limit_usd?: number | null;
|
|
854
|
+
alert_threshold?: number;
|
|
855
|
+
};
|
|
856
|
+
const db = getDb();
|
|
857
|
+
db.run(`CREATE TABLE IF NOT EXISTS budget_config (
|
|
858
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
859
|
+
daily_limit_usd REAL,
|
|
860
|
+
monthly_limit_usd REAL,
|
|
861
|
+
alert_threshold REAL DEFAULT 0.8,
|
|
862
|
+
paused INTEGER NOT NULL DEFAULT 0,
|
|
863
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
864
|
+
)`);
|
|
865
|
+
db.run("INSERT OR IGNORE INTO budget_config (id) VALUES (1)");
|
|
866
|
+
|
|
867
|
+
db.prepare(
|
|
868
|
+
`UPDATE budget_config SET
|
|
869
|
+
daily_limit_usd = ?,
|
|
870
|
+
monthly_limit_usd = ?,
|
|
871
|
+
alert_threshold = ?,
|
|
872
|
+
paused = 0,
|
|
873
|
+
updated_at = datetime('now')
|
|
874
|
+
WHERE id = 1`
|
|
875
|
+
).run(
|
|
876
|
+
body.daily_limit_usd ?? null,
|
|
877
|
+
body.monthly_limit_usd ?? null,
|
|
878
|
+
body.alert_threshold ?? 0.8
|
|
879
|
+
);
|
|
880
|
+
return Response.json({ ok: true });
|
|
881
|
+
} catch (err: any) {
|
|
882
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
883
|
+
}
|
|
884
|
+
})() as any;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// --- Privacy dashboard ---
|
|
888
|
+
|
|
889
|
+
// GET /api/dashboard/privacy/summary
|
|
890
|
+
if (path === "/privacy/summary" && req.method === "GET") {
|
|
891
|
+
try {
|
|
892
|
+
const db = getDb();
|
|
893
|
+
|
|
894
|
+
const memoryCount = (db.query("SELECT COUNT(*) as c FROM memory_chunks").get() as any)?.c ?? 0;
|
|
895
|
+
const messageCount = (db.query("SELECT COUNT(*) as c FROM messages").get() as any)?.c ?? 0;
|
|
896
|
+
const sessionCount = (db.query("SELECT COUNT(DISTINCT session_id) as c FROM messages").get() as any)?.c ?? 0;
|
|
897
|
+
const secretCount = (db.query("SELECT COUNT(*) as c FROM secrets").get() as any)?.c ?? 0;
|
|
898
|
+
const cronCount = (db.query("SELECT COUNT(*) as c FROM cron_jobs").get() as any)?.c ?? 0;
|
|
899
|
+
const apiCallCount = (db.query("SELECT COUNT(*) as c FROM usage").get() as any)?.c ?? 0;
|
|
900
|
+
const toolCallCount = (db.query("SELECT COUNT(*) as c FROM tool_metrics").get() as any)?.c ?? 0;
|
|
901
|
+
const totalTokensSent = (db.query("SELECT COALESCE(SUM(input_tokens), 0) as t FROM usage").get() as any)?.t ?? 0;
|
|
902
|
+
const totalTokensReceived = (db.query("SELECT COALESCE(SUM(output_tokens), 0) as t FROM usage").get() as any)?.t ?? 0;
|
|
903
|
+
|
|
904
|
+
// Data by provider
|
|
905
|
+
const providerBreakdown = db.query(
|
|
906
|
+
"SELECT provider, COUNT(*) as calls, SUM(input_tokens) as tokens_sent FROM usage GROUP BY provider ORDER BY calls DESC"
|
|
907
|
+
).all();
|
|
908
|
+
|
|
909
|
+
return Response.json({
|
|
910
|
+
memoryCount,
|
|
911
|
+
messageCount,
|
|
912
|
+
sessionCount,
|
|
913
|
+
secretCount,
|
|
914
|
+
cronCount,
|
|
915
|
+
apiCallCount,
|
|
916
|
+
toolCallCount,
|
|
917
|
+
totalTokensSent,
|
|
918
|
+
totalTokensReceived,
|
|
919
|
+
providerBreakdown,
|
|
920
|
+
});
|
|
921
|
+
} catch (err: any) {
|
|
922
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// GET /api/dashboard/privacy/api-log
|
|
927
|
+
if (path === "/privacy/api-log" && req.method === "GET") {
|
|
928
|
+
try {
|
|
929
|
+
const db = getDb();
|
|
930
|
+
const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
|
|
931
|
+
const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
|
|
932
|
+
|
|
933
|
+
const rows = db.query(
|
|
934
|
+
`SELECT id, session_id, provider, model, input_tokens, output_tokens,
|
|
935
|
+
cost_usd, response_time_ms, created_at
|
|
936
|
+
FROM usage ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
|
937
|
+
).all(Math.min(limit, 100), offset);
|
|
938
|
+
|
|
939
|
+
const total = (db.query("SELECT COUNT(*) as c FROM usage").get() as any)?.c ?? 0;
|
|
940
|
+
|
|
941
|
+
return Response.json({ rows, total, limit, offset });
|
|
942
|
+
} catch (err: any) {
|
|
943
|
+
return Response.json({ rows: [], total: 0, error: err.message });
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// GET /api/dashboard/privacy/tool-log
|
|
948
|
+
if (path === "/privacy/tool-log" && req.method === "GET") {
|
|
949
|
+
try {
|
|
950
|
+
const db = getDb();
|
|
951
|
+
const limit = parseInt(url.searchParams.get("limit") ?? "50", 10);
|
|
952
|
+
const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
|
|
953
|
+
|
|
954
|
+
const rows = db.query(
|
|
955
|
+
`SELECT id, tool_name, session_id, duration_ms, success, created_at
|
|
956
|
+
FROM tool_metrics ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
|
957
|
+
).all(Math.min(limit, 100), offset);
|
|
958
|
+
|
|
959
|
+
const total = (db.query("SELECT COUNT(*) as c FROM tool_metrics").get() as any)?.c ?? 0;
|
|
960
|
+
|
|
961
|
+
return Response.json({ rows, total, limit, offset });
|
|
962
|
+
} catch (err: any) {
|
|
963
|
+
return Response.json({ rows: [], total: 0, error: err.message });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// POST /api/dashboard/privacy/wipe-memories
|
|
968
|
+
if (path === "/privacy/wipe-memories" && req.method === "POST") {
|
|
969
|
+
return (async () => {
|
|
970
|
+
try {
|
|
971
|
+
const body = (await req.json()) as { confirm?: string };
|
|
972
|
+
if (body.confirm !== "DELETE") return Response.json({ ok: false, error: "Confirmation required: send { confirm: \"DELETE\" }" }, { status: 400 });
|
|
973
|
+
const db = getDb();
|
|
974
|
+
db.run("DELETE FROM memory_chunks");
|
|
975
|
+
db.run("DELETE FROM memory_fts");
|
|
976
|
+
return Response.json({ ok: true, message: "All memories deleted" });
|
|
977
|
+
} catch (err: any) {
|
|
978
|
+
return Response.json({ ok: false, error: err.message }, { status: 500 });
|
|
979
|
+
}
|
|
980
|
+
})() as any;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// POST /api/dashboard/privacy/wipe-messages
|
|
984
|
+
if (path === "/privacy/wipe-messages" && req.method === "POST") {
|
|
985
|
+
return (async () => {
|
|
986
|
+
try {
|
|
987
|
+
const body = (await req.json()) as { confirm?: string };
|
|
988
|
+
if (body.confirm !== "DELETE") return Response.json({ ok: false, error: "Confirmation required: send { confirm: \"DELETE\" }" }, { status: 400 });
|
|
989
|
+
const db = getDb();
|
|
990
|
+
db.run("DELETE FROM messages");
|
|
991
|
+
return Response.json({ ok: true, message: "All messages deleted" });
|
|
992
|
+
} catch (err: any) {
|
|
993
|
+
return Response.json({ ok: false, error: err.message }, { status: 500 });
|
|
994
|
+
}
|
|
995
|
+
})() as any;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// POST /api/dashboard/privacy/wipe-usage
|
|
999
|
+
if (path === "/privacy/wipe-usage" && req.method === "POST") {
|
|
1000
|
+
return (async () => {
|
|
1001
|
+
try {
|
|
1002
|
+
const body = (await req.json()) as { confirm?: string };
|
|
1003
|
+
if (body.confirm !== "DELETE") return Response.json({ ok: false, error: "Confirmation required: send { confirm: \"DELETE\" }" }, { status: 400 });
|
|
1004
|
+
const db = getDb();
|
|
1005
|
+
db.run("DELETE FROM usage");
|
|
1006
|
+
db.run("DELETE FROM tool_metrics");
|
|
1007
|
+
return Response.json({ ok: true, message: "All usage data deleted" });
|
|
1008
|
+
} catch (err: any) {
|
|
1009
|
+
return Response.json({ ok: false, error: err.message }, { status: 500 });
|
|
1010
|
+
}
|
|
1011
|
+
})() as any;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// POST /api/dashboard/privacy/wipe-all
|
|
1015
|
+
if (path === "/privacy/wipe-all" && req.method === "POST") {
|
|
1016
|
+
return (async () => {
|
|
1017
|
+
try {
|
|
1018
|
+
const body = (await req.json()) as { confirm?: string };
|
|
1019
|
+
if (body.confirm !== "DELETE_ALL") return Response.json({ ok: false, error: "Confirmation required: send { confirm: \"DELETE_ALL\" }" }, { status: 400 });
|
|
1020
|
+
const db = getDb();
|
|
1021
|
+
db.run("DELETE FROM memory_chunks");
|
|
1022
|
+
db.run("DELETE FROM memory_fts");
|
|
1023
|
+
db.run("DELETE FROM messages");
|
|
1024
|
+
db.run("DELETE FROM usage");
|
|
1025
|
+
db.run("DELETE FROM tool_metrics");
|
|
1026
|
+
db.run("DELETE FROM secrets");
|
|
1027
|
+
db.run("DELETE FROM cron_logs");
|
|
1028
|
+
return Response.json({ ok: true, message: "All data wiped" });
|
|
1029
|
+
} catch (err: any) {
|
|
1030
|
+
return Response.json({ ok: false, error: err.message }, { status: 500 });
|
|
1031
|
+
}
|
|
1032
|
+
})() as any;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// --- Secrets management ---
|
|
1036
|
+
|
|
1037
|
+
// GET /api/dashboard/secrets — list all secrets (values masked) + config provider keys
|
|
1038
|
+
if (path === "/secrets" && req.method === "GET") {
|
|
1039
|
+
try {
|
|
1040
|
+
const db = getDb();
|
|
1041
|
+
const rows = db.query("SELECT name, service, updated_at FROM secrets ORDER BY name").all() as { name: string; service: string | null; updated_at: string }[];
|
|
1042
|
+
|
|
1043
|
+
// Also surface provider API keys from config (read-only)
|
|
1044
|
+
const configKeys: { name: string; service: string; updated_at: string; source: string }[] = [];
|
|
1045
|
+
try {
|
|
1046
|
+
const cfg = JSON.parse(readFileSync(paths.config, "utf-8"));
|
|
1047
|
+
if (cfg.providers) {
|
|
1048
|
+
for (const [provider, pCfg] of Object.entries(cfg.providers)) {
|
|
1049
|
+
const pc = pCfg as Record<string, unknown>;
|
|
1050
|
+
if (pc.apiKey && typeof pc.apiKey === "string") {
|
|
1051
|
+
configKeys.push({
|
|
1052
|
+
name: `${provider}_api_key`,
|
|
1053
|
+
service: provider,
|
|
1054
|
+
updated_at: "",
|
|
1055
|
+
source: "config",
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
} catch (err: any) {
|
|
1061
|
+
logger.warn("Failed to read provider API keys from config", { error: (err as Error).message });
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
const secrets = [
|
|
1065
|
+
...configKeys,
|
|
1066
|
+
...rows.map(r => ({ ...r, source: "secrets" })),
|
|
1067
|
+
];
|
|
1068
|
+
return Response.json({ secrets });
|
|
1069
|
+
} catch {
|
|
1070
|
+
return Response.json({ secrets: [] });
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// GET /api/dashboard/secrets/:name — reveal a single secret value
|
|
1075
|
+
if (path.startsWith("/secrets/") && req.method === "GET") {
|
|
1076
|
+
const secretName = decodeURIComponent(path.replace("/secrets/", ""));
|
|
1077
|
+
if (!secretName || !/^[a-z0-9_]+$/.test(secretName)) {
|
|
1078
|
+
return Response.json({ error: "Invalid secret name" }, { status: 400 });
|
|
1079
|
+
}
|
|
1080
|
+
try {
|
|
1081
|
+
// Check config provider keys first
|
|
1082
|
+
if (secretName.endsWith("_api_key")) {
|
|
1083
|
+
const provider = secretName.replace(/_api_key$/, "");
|
|
1084
|
+
try {
|
|
1085
|
+
const cfg = JSON.parse(readFileSync(paths.config, "utf-8"));
|
|
1086
|
+
if (cfg.providers?.[provider]?.apiKey) {
|
|
1087
|
+
return Response.json({ name: secretName, value: cfg.providers[provider].apiKey, source: "config" });
|
|
1088
|
+
}
|
|
1089
|
+
} catch (err: any) {
|
|
1090
|
+
logger.warn("Failed to read provider secret from config", { error: (err as Error).message });
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
const db = getDb();
|
|
1094
|
+
const row = db.query("SELECT value FROM secrets WHERE name = ?").get(secretName) as { value: string } | null;
|
|
1095
|
+
if (!row) return Response.json({ error: "Not found" }, { status: 404 });
|
|
1096
|
+
return Response.json({ name: secretName, value: row.value });
|
|
1097
|
+
} catch (err: any) {
|
|
1098
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// POST /api/dashboard/secrets — create or update a secret
|
|
1103
|
+
if (path === "/secrets" && req.method === "POST") {
|
|
1104
|
+
return (async () => {
|
|
1105
|
+
const body = (await req.json()) as { name?: string; value?: string; service?: string };
|
|
1106
|
+
if (!body.name || !/^[a-z0-9_]+$/.test(body.name)) {
|
|
1107
|
+
return Response.json({ error: "Name must match [a-z0-9_]+" }, { status: 400 });
|
|
1108
|
+
}
|
|
1109
|
+
if (!body.value) {
|
|
1110
|
+
return Response.json({ error: "Value is required" }, { status: 400 });
|
|
1111
|
+
}
|
|
1112
|
+
try {
|
|
1113
|
+
const db = getDb();
|
|
1114
|
+
db.prepare(
|
|
1115
|
+
`INSERT INTO secrets (name, value, service, updated_at)
|
|
1116
|
+
VALUES (?, ?, ?, datetime('now'))
|
|
1117
|
+
ON CONFLICT(name) DO UPDATE SET value = excluded.value, service = excluded.service, updated_at = datetime('now')`
|
|
1118
|
+
).run(body.name, body.value, body.service ?? null);
|
|
1119
|
+
return Response.json({ ok: true });
|
|
1120
|
+
} catch (err: any) {
|
|
1121
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
1122
|
+
}
|
|
1123
|
+
})() as any;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// DELETE /api/dashboard/secrets/:name — delete a secret
|
|
1127
|
+
if (path.startsWith("/secrets/") && req.method === "DELETE") {
|
|
1128
|
+
const secretName = decodeURIComponent(path.replace("/secrets/", ""));
|
|
1129
|
+
if (!secretName || !/^[a-z0-9_]+$/.test(secretName)) {
|
|
1130
|
+
return Response.json({ error: "Invalid secret name" }, { status: 400 });
|
|
1131
|
+
}
|
|
1132
|
+
try {
|
|
1133
|
+
const db = getDb();
|
|
1134
|
+
const result = db.prepare("DELETE FROM secrets WHERE name = ?").run(secretName);
|
|
1135
|
+
return Response.json({ deleted: result.changes > 0 });
|
|
1136
|
+
} catch (err: any) {
|
|
1137
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// --- Webhook ingress ---
|
|
1142
|
+
|
|
1143
|
+
// POST /api/dashboard/webhook/:name
|
|
1144
|
+
if (path.startsWith("/webhook/") && req.method === "POST") {
|
|
1145
|
+
const webhookName = path.slice("/webhook/".length);
|
|
1146
|
+
if (!webhookName || !/^[a-z0-9_-]+$/i.test(webhookName)) {
|
|
1147
|
+
return Response.json({ error: "Invalid webhook name" }, { status: 400 });
|
|
1148
|
+
}
|
|
1149
|
+
return (async () => {
|
|
1150
|
+
try {
|
|
1151
|
+
// Budget enforcement — block webhooks when budget exceeded
|
|
1152
|
+
const db = getDb();
|
|
1153
|
+
try {
|
|
1154
|
+
const budgetConfig = db.query("SELECT paused FROM budget_config WHERE id = 1").get() as { paused: number } | null;
|
|
1155
|
+
if (budgetConfig?.paused) {
|
|
1156
|
+
return Response.json({ error: "Budget exceeded — agent is paused" }, { status: 429 });
|
|
1157
|
+
}
|
|
1158
|
+
} catch { /* budget table may not exist */ }
|
|
1159
|
+
|
|
1160
|
+
const payload = await req.json();
|
|
1161
|
+
const summary = JSON.stringify(payload).slice(0, 500);
|
|
1162
|
+
const message = `[Webhook: ${webhookName}] ${summary}`;
|
|
1163
|
+
const { loadConfig } = await import("../config/loader");
|
|
1164
|
+
const { createProvider } = await import("../llm/factory");
|
|
1165
|
+
const config = await loadConfig();
|
|
1166
|
+
const webhookLlm = createProvider(config);
|
|
1167
|
+
const { agentLoop } = await import("../agent/loop");
|
|
1168
|
+
const sessionKey = `webhook:${webhookName}`;
|
|
1169
|
+
const result = await agentLoop(webhookLlm, sessionKey, message);
|
|
1170
|
+
return Response.json({ ok: true, reply: result.reply });
|
|
1171
|
+
} catch (err: any) {
|
|
1172
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
1173
|
+
}
|
|
1174
|
+
})() as any;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// --- Conversation threads CRUD ---
|
|
1178
|
+
|
|
1179
|
+
// GET /api/dashboard/threads — list all threads
|
|
1180
|
+
if (path === "/threads" && req.method === "GET") {
|
|
1181
|
+
try {
|
|
1182
|
+
const db = getDb();
|
|
1183
|
+
const threads = db.query(
|
|
1184
|
+
"SELECT id, title, created_at, updated_at FROM threads ORDER BY updated_at DESC"
|
|
1185
|
+
).all();
|
|
1186
|
+
return Response.json({ threads });
|
|
1187
|
+
} catch (err: any) {
|
|
1188
|
+
return Response.json({ threads: [], error: err.message });
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// POST /api/dashboard/threads — create new thread
|
|
1193
|
+
if (path === "/threads" && req.method === "POST") {
|
|
1194
|
+
return (async () => {
|
|
1195
|
+
const { title } = await req.json().catch(() => ({ title: undefined }));
|
|
1196
|
+
const id = crypto.randomUUID();
|
|
1197
|
+
const db = getDb();
|
|
1198
|
+
db.prepare(
|
|
1199
|
+
"INSERT INTO threads (id, title) VALUES (?, ?)"
|
|
1200
|
+
).run(id, title || "New conversation");
|
|
1201
|
+
return Response.json({ id, title: title || "New conversation" });
|
|
1202
|
+
})() as any;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// PUT /api/dashboard/threads/:id — rename thread
|
|
1206
|
+
if (path.match(/^\/threads\/[a-f0-9-]+$/) && req.method === "PUT") {
|
|
1207
|
+
return (async () => {
|
|
1208
|
+
const threadId = path.split("/").pop()!;
|
|
1209
|
+
const { title } = await req.json();
|
|
1210
|
+
const db = getDb();
|
|
1211
|
+
db.prepare(
|
|
1212
|
+
"UPDATE threads SET title = ?, updated_at = datetime('now') WHERE id = ?"
|
|
1213
|
+
).run(title, threadId);
|
|
1214
|
+
return Response.json({ ok: true });
|
|
1215
|
+
})() as any;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// DELETE /api/dashboard/threads/:id — delete thread and session file
|
|
1219
|
+
if (path.match(/^\/threads\/[a-f0-9-]+$/) && req.method === "DELETE") {
|
|
1220
|
+
const threadId = path.split("/").pop()!;
|
|
1221
|
+
try {
|
|
1222
|
+
const db = getDb();
|
|
1223
|
+
db.prepare("DELETE FROM threads WHERE id = ?").run(threadId);
|
|
1224
|
+
const sessionPath = join(paths.sessions, threadId + ".jsonl");
|
|
1225
|
+
if (existsSync(sessionPath)) unlinkSync(sessionPath);
|
|
1226
|
+
return Response.json({ ok: true });
|
|
1227
|
+
} catch (err: any) {
|
|
1228
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// GET /api/dashboard/threads/:id/messages — get thread messages
|
|
1233
|
+
if (path.match(/^\/threads\/[a-f0-9-]+\/messages$/) && req.method === "GET") {
|
|
1234
|
+
return (async () => {
|
|
1235
|
+
const threadId = path.split("/")[2];
|
|
1236
|
+
const { loadSession } = await import("../agent/session");
|
|
1237
|
+
const messages = loadSession(threadId, 100);
|
|
1238
|
+
return Response.json({ messages });
|
|
1239
|
+
})() as any;
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
// GET /api/dashboard/threads/:id/export — export thread as markdown
|
|
1243
|
+
if (path.match(/^\/threads\/[a-f0-9-]+\/export$/) && req.method === "GET") {
|
|
1244
|
+
return (async () => {
|
|
1245
|
+
const threadId = path.split("/")[2];
|
|
1246
|
+
const { loadSession } = await import("../agent/session");
|
|
1247
|
+
const messages = loadSession(threadId, 1000);
|
|
1248
|
+
const db = getDb();
|
|
1249
|
+
const thread = db.query(
|
|
1250
|
+
"SELECT title FROM threads WHERE id = ?"
|
|
1251
|
+
).get(threadId) as { title: string } | null;
|
|
1252
|
+
|
|
1253
|
+
let md = "# " + (thread?.title || "Conversation") + "\n\n";
|
|
1254
|
+
for (const m of messages) {
|
|
1255
|
+
const role = m.role === "user" ? "**You**" : "**Zubo**";
|
|
1256
|
+
const text = Array.isArray(m.content)
|
|
1257
|
+
? m.content
|
|
1258
|
+
.filter((b: any) => b.type === "text")
|
|
1259
|
+
.map((b: any) => b.text)
|
|
1260
|
+
.join("\n")
|
|
1261
|
+
: String(m.content);
|
|
1262
|
+
md += role + ": " + text + "\n\n";
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
return new Response(md, {
|
|
1266
|
+
headers: {
|
|
1267
|
+
"Content-Type": "text/markdown",
|
|
1268
|
+
"Content-Disposition": `attachment; filename="${threadId.slice(0, 8)}.md"`,
|
|
1269
|
+
},
|
|
1270
|
+
});
|
|
1271
|
+
})() as any;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
return null;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
export interface WebChatAdapter extends ChannelAdapter {
|
|
1278
|
+
getPort(): number;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// Cached config values — refreshed every 30s to avoid reading disk on every request
|
|
1282
|
+
let _cachedRateLimit: { chatPerMinute: number; uploadPerMinute: number } | null = null;
|
|
1283
|
+
let _cachedAuthEnabled: boolean | null = null;
|
|
1284
|
+
let _configCacheTime = 0;
|
|
1285
|
+
const CONFIG_CACHE_TTL = 30_000;
|
|
1286
|
+
|
|
1287
|
+
function refreshConfigCache(): void {
|
|
1288
|
+
const now = Date.now();
|
|
1289
|
+
if (_cachedRateLimit !== null && now - _configCacheTime < CONFIG_CACHE_TTL) return;
|
|
1290
|
+
_configCacheTime = now;
|
|
1291
|
+
try {
|
|
1292
|
+
const config = JSON.parse(readFileSync(paths.config, "utf-8"));
|
|
1293
|
+
_cachedRateLimit = {
|
|
1294
|
+
chatPerMinute: config.rateLimit?.chatPerMinute ?? 60,
|
|
1295
|
+
uploadPerMinute: config.rateLimit?.uploadPerMinute ?? 10,
|
|
1296
|
+
};
|
|
1297
|
+
_cachedAuthEnabled = config.auth?.enabled === true;
|
|
1298
|
+
} catch {
|
|
1299
|
+
_cachedRateLimit = { chatPerMinute: 60, uploadPerMinute: 10 };
|
|
1300
|
+
_cachedAuthEnabled = false;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function getRateLimitConfig(): { chatPerMinute: number; uploadPerMinute: number } {
|
|
1305
|
+
refreshConfigCache();
|
|
1306
|
+
return _cachedRateLimit!;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function isAuthEnabled(): boolean {
|
|
1310
|
+
refreshConfigCache();
|
|
1311
|
+
return _cachedAuthEnabled!;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
function getClientIp(req: Request, server: any): string {
|
|
1315
|
+
// Prefer actual connection IP from Bun server to prevent header spoofing
|
|
1316
|
+
try {
|
|
1317
|
+
const addr = server?.requestIP?.(req);
|
|
1318
|
+
if (addr?.address) return addr.address;
|
|
1319
|
+
} catch (err: any) {
|
|
1320
|
+
logger.warn("Failed to get client IP from server", { error: (err as Error).message });
|
|
1321
|
+
}
|
|
1322
|
+
// Fallback to x-forwarded-for only if behind a trusted proxy
|
|
1323
|
+
return req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "127.0.0.1";
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
export function createWebChatAdapter(
|
|
1327
|
+
port: number,
|
|
1328
|
+
router: MessageRouter
|
|
1329
|
+
): WebChatAdapter {
|
|
1330
|
+
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
1331
|
+
const sessionKey = "webchat:local";
|
|
1332
|
+
|
|
1333
|
+
const rlConfig = getRateLimitConfig();
|
|
1334
|
+
const chatLimiter = new RateLimiter(rlConfig.chatPerMinute, 60_000);
|
|
1335
|
+
const uploadLimiter = new RateLimiter(rlConfig.uploadPerMinute, 60_000);
|
|
1336
|
+
|
|
1337
|
+
return {
|
|
1338
|
+
channelName: "webchat",
|
|
1339
|
+
|
|
1340
|
+
getPort() {
|
|
1341
|
+
return server?.port ?? port;
|
|
1342
|
+
},
|
|
1343
|
+
|
|
1344
|
+
start() {
|
|
1345
|
+
// Ensure threads table exists
|
|
1346
|
+
try {
|
|
1347
|
+
const db = getDb();
|
|
1348
|
+
db.run(`CREATE TABLE IF NOT EXISTS threads (
|
|
1349
|
+
id TEXT PRIMARY KEY,
|
|
1350
|
+
title TEXT NOT NULL DEFAULT 'New conversation',
|
|
1351
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
1352
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
1353
|
+
)`);
|
|
1354
|
+
} catch (err: any) {
|
|
1355
|
+
logger.warn("Failed to create threads table", { error: (err as Error).message });
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
server = Bun.serve({
|
|
1359
|
+
port,
|
|
1360
|
+
async fetch(req) {
|
|
1361
|
+
const url = new URL(req.url);
|
|
1362
|
+
|
|
1363
|
+
// Health check
|
|
1364
|
+
if (url.pathname === "/health") {
|
|
1365
|
+
return Response.json({ status: "ok", uptime: Math.floor(process.uptime()) });
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// Unified UI (Agent chat + Dashboard)
|
|
1369
|
+
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
1370
|
+
return new Response(DASHBOARD_HTML, {
|
|
1371
|
+
headers: { "Content-Type": "text/html" },
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Legacy /dashboard → redirect to #status
|
|
1376
|
+
if (url.pathname === "/dashboard") {
|
|
1377
|
+
return new Response(null, {
|
|
1378
|
+
status: 302,
|
|
1379
|
+
headers: { Location: "/#status" },
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Health check (no auth required)
|
|
1384
|
+
if (url.pathname === "/api/health") {
|
|
1385
|
+
const uptime = process.uptime();
|
|
1386
|
+
let dbOk = false;
|
|
1387
|
+
try {
|
|
1388
|
+
getDb().query("SELECT 1").get();
|
|
1389
|
+
dbOk = true;
|
|
1390
|
+
} catch {}
|
|
1391
|
+
return Response.json({
|
|
1392
|
+
status: dbOk ? "healthy" : "degraded",
|
|
1393
|
+
uptime: Math.round(uptime),
|
|
1394
|
+
version: "0.1.0",
|
|
1395
|
+
db: dbOk ? "connected" : "error",
|
|
1396
|
+
timestamp: new Date().toISOString(),
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// Auth check for /api/* endpoints (if enabled) — runs BEFORE any API handler
|
|
1401
|
+
if (url.pathname.startsWith("/api/") && isAuthEnabled()) {
|
|
1402
|
+
const db = getDb();
|
|
1403
|
+
initAuth(db);
|
|
1404
|
+
if (!validateRequest(db, req)) {
|
|
1405
|
+
return Response.json(
|
|
1406
|
+
{ error: "Unauthorized. Provide a valid API key via Authorization: Bearer <key>" },
|
|
1407
|
+
{ status: 401, headers: { "WWW-Authenticate": "Bearer" } }
|
|
1408
|
+
);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Dashboard API
|
|
1413
|
+
if (url.pathname.startsWith("/api/dashboard")) {
|
|
1414
|
+
const result = handleDashboardApi(url, req);
|
|
1415
|
+
if (result) return result;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// API key management endpoints
|
|
1419
|
+
if (url.pathname === "/api/keys" && req.method === "POST") {
|
|
1420
|
+
const db = getDb();
|
|
1421
|
+
initAuth(db);
|
|
1422
|
+
const body = (await req.json()) as { label?: string };
|
|
1423
|
+
const result = createApiKey(db, body.label ?? "");
|
|
1424
|
+
return Response.json(result, { status: 201 });
|
|
1425
|
+
}
|
|
1426
|
+
if (url.pathname === "/api/keys" && req.method === "GET") {
|
|
1427
|
+
const db = getDb();
|
|
1428
|
+
initAuth(db);
|
|
1429
|
+
return Response.json({ keys: listApiKeys(db) });
|
|
1430
|
+
}
|
|
1431
|
+
if (url.pathname.startsWith("/api/keys/") && req.method === "DELETE") {
|
|
1432
|
+
const id = parseInt(url.pathname.split("/").pop()!, 10);
|
|
1433
|
+
if (isNaN(id)) return Response.json({ error: "Invalid key ID" }, { status: 400 });
|
|
1434
|
+
const db = getDb();
|
|
1435
|
+
initAuth(db);
|
|
1436
|
+
const deleted = deleteApiKey(db, id);
|
|
1437
|
+
return Response.json({ deleted });
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// Rate limiting for chat endpoints
|
|
1441
|
+
if (url.pathname.startsWith("/api/chat")) {
|
|
1442
|
+
const ip = getClientIp(req, server);
|
|
1443
|
+
const check = chatLimiter.check(ip);
|
|
1444
|
+
if (!check.allowed) {
|
|
1445
|
+
return Response.json(
|
|
1446
|
+
{ error: "Rate limit exceeded" },
|
|
1447
|
+
{ status: 429, headers: { "Retry-After": String(Math.ceil((check.retryAfterMs ?? 1000) / 1000)) } }
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
// Rate limiting for upload endpoint
|
|
1453
|
+
if (url.pathname === "/api/upload") {
|
|
1454
|
+
const ip = getClientIp(req, server);
|
|
1455
|
+
const check = uploadLimiter.check(ip);
|
|
1456
|
+
if (!check.allowed) {
|
|
1457
|
+
return Response.json(
|
|
1458
|
+
{ error: "Rate limit exceeded" },
|
|
1459
|
+
{ status: 429, headers: { "Retry-After": String(Math.ceil((check.retryAfterMs ?? 1000) / 1000)) } }
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Chat history — load last N messages for the web UI
|
|
1465
|
+
if (url.pathname === "/api/chat/history" && req.method === "GET") {
|
|
1466
|
+
try {
|
|
1467
|
+
const { loadSession } = await import("../agent/session");
|
|
1468
|
+
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50", 10) || 50, 200);
|
|
1469
|
+
const messages = loadSession("owner", limit);
|
|
1470
|
+
// Map to a simpler format for the UI
|
|
1471
|
+
const history = messages.map((m) => ({
|
|
1472
|
+
role: m.role,
|
|
1473
|
+
content: Array.isArray(m.content)
|
|
1474
|
+
? m.content
|
|
1475
|
+
.filter((b: any) => b.type === "text")
|
|
1476
|
+
.map((b: any) => b.text ?? "")
|
|
1477
|
+
.join("\n")
|
|
1478
|
+
: String(m.content),
|
|
1479
|
+
})).filter((m) => m.content.trim());
|
|
1480
|
+
return Response.json({ messages: history });
|
|
1481
|
+
} catch {
|
|
1482
|
+
return Response.json({ messages: [] });
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Chat API (non-streaming, backward compat)
|
|
1487
|
+
if (url.pathname === "/api/chat" && req.method === "POST") {
|
|
1488
|
+
try {
|
|
1489
|
+
const body = (await req.json()) as { message?: string };
|
|
1490
|
+
const text = body.message?.trim();
|
|
1491
|
+
if (!text) {
|
|
1492
|
+
return Response.json({ error: "No message" }, { status: 400 });
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const message: InboundMessage = {
|
|
1496
|
+
channel: "webchat",
|
|
1497
|
+
userId: "local",
|
|
1498
|
+
sessionKey,
|
|
1499
|
+
text,
|
|
1500
|
+
};
|
|
1501
|
+
|
|
1502
|
+
let reply = "";
|
|
1503
|
+
await router.handleMessage(message, async (r) => {
|
|
1504
|
+
reply = r;
|
|
1505
|
+
});
|
|
1506
|
+
|
|
1507
|
+
return Response.json({ reply });
|
|
1508
|
+
} catch (err: any) {
|
|
1509
|
+
return Response.json(
|
|
1510
|
+
{ error: err.message },
|
|
1511
|
+
{ status: 500 }
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Chat API (streaming via SSE)
|
|
1517
|
+
if (url.pathname === "/api/chat/stream" && req.method === "POST") {
|
|
1518
|
+
try {
|
|
1519
|
+
const body = (await req.json()) as { message?: string; threadId?: string };
|
|
1520
|
+
const text = body.message?.trim();
|
|
1521
|
+
if (!text) {
|
|
1522
|
+
return Response.json({ error: "No message" }, { status: 400 });
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Validate threadId format to prevent path traversal
|
|
1526
|
+
if (body.threadId && !/^[a-f0-9-]{36}$/.test(body.threadId)) {
|
|
1527
|
+
return Response.json({ error: "Invalid thread ID" }, { status: 400 });
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
// Use provided threadId as session, falling back to the shared session
|
|
1531
|
+
const effectiveSessionKey = body.threadId ?? sessionKey;
|
|
1532
|
+
|
|
1533
|
+
const message: InboundMessage = {
|
|
1534
|
+
channel: "webchat",
|
|
1535
|
+
userId: "local",
|
|
1536
|
+
sessionKey: effectiveSessionKey,
|
|
1537
|
+
text,
|
|
1538
|
+
};
|
|
1539
|
+
|
|
1540
|
+
const stream = new ReadableStream({
|
|
1541
|
+
start(controller) {
|
|
1542
|
+
const encoder = new TextEncoder();
|
|
1543
|
+
let closed = false;
|
|
1544
|
+
const send = (event: string, data: any) => {
|
|
1545
|
+
if (closed) return;
|
|
1546
|
+
try {
|
|
1547
|
+
controller.enqueue(
|
|
1548
|
+
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
|
|
1549
|
+
);
|
|
1550
|
+
} catch {
|
|
1551
|
+
closed = true;
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
const close = () => {
|
|
1555
|
+
if (closed) return;
|
|
1556
|
+
closed = true;
|
|
1557
|
+
try { controller.close(); } catch {}
|
|
1558
|
+
};
|
|
1559
|
+
|
|
1560
|
+
if (!router.handleMessageStream) {
|
|
1561
|
+
// Fallback to non-streaming
|
|
1562
|
+
router.handleMessage(message, async (reply) => {
|
|
1563
|
+
send("delta", { text: reply });
|
|
1564
|
+
send("done", { reply });
|
|
1565
|
+
close();
|
|
1566
|
+
}).catch((err) => {
|
|
1567
|
+
send("error", { error: err.message });
|
|
1568
|
+
close();
|
|
1569
|
+
});
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
router.handleMessageStream(
|
|
1574
|
+
message,
|
|
1575
|
+
(delta) => send("delta", { text: delta }),
|
|
1576
|
+
(name) => send("tool", { name, status: "start" }),
|
|
1577
|
+
(name) => send("tool", { name, status: "end" }),
|
|
1578
|
+
).then((reply) => {
|
|
1579
|
+
send("done", { reply });
|
|
1580
|
+
close();
|
|
1581
|
+
}).catch((err) => {
|
|
1582
|
+
send("error", { error: err.message });
|
|
1583
|
+
close();
|
|
1584
|
+
});
|
|
1585
|
+
},
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
return new Response(stream, {
|
|
1589
|
+
headers: {
|
|
1590
|
+
"Content-Type": "text/event-stream",
|
|
1591
|
+
"Cache-Control": "no-cache",
|
|
1592
|
+
"Connection": "keep-alive",
|
|
1593
|
+
},
|
|
1594
|
+
});
|
|
1595
|
+
} catch (err: any) {
|
|
1596
|
+
return Response.json(
|
|
1597
|
+
{ error: err.message },
|
|
1598
|
+
{ status: 500 }
|
|
1599
|
+
);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// Upload endpoint
|
|
1604
|
+
if (url.pathname === "/api/upload" && req.method === "POST") {
|
|
1605
|
+
try {
|
|
1606
|
+
const formData = await req.formData();
|
|
1607
|
+
const file = formData.get("file") as File | null;
|
|
1608
|
+
if (!file) {
|
|
1609
|
+
return Response.json({ error: "No file provided" }, { status: 400 });
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// Validate size (50MB max)
|
|
1613
|
+
const MAX_SIZE = 50 * 1024 * 1024;
|
|
1614
|
+
if (file.size > MAX_SIZE) {
|
|
1615
|
+
return Response.json({ error: "File too large (max 50MB)" }, { status: 400 });
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// Validate MIME type — only allow supported document types
|
|
1619
|
+
const ALLOWED_MIME_TYPES = new Set([
|
|
1620
|
+
"text/plain", "text/markdown", "text/csv", "application/pdf",
|
|
1621
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
1622
|
+
"application/json", "application/xml", "text/yaml",
|
|
1623
|
+
]);
|
|
1624
|
+
const ALLOWED_EXTENSIONS = new Set([
|
|
1625
|
+
".pdf", ".docx", ".txt", ".md", ".csv", ".json", ".html", ".xml", ".yaml", ".yml", ".ts", ".js", ".py", ".sh",
|
|
1626
|
+
]);
|
|
1627
|
+
const ext = file.name.includes(".") ? "." + file.name.split(".").pop()!.toLowerCase() : "";
|
|
1628
|
+
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
|
1629
|
+
return Response.json({ error: `Unsupported file type: ${ext}` }, { status: 400 });
|
|
1630
|
+
}
|
|
1631
|
+
if (!ALLOWED_MIME_TYPES.has(file.type) && file.type !== "application/octet-stream") {
|
|
1632
|
+
return Response.json({ error: "File type not allowed: " + file.type }, { status: 400 });
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
const { parseDocument, guessMimeType } = await import("../memory/document-parser");
|
|
1636
|
+
const { writeAndIndexMemory } = await import("../memory/engine");
|
|
1637
|
+
const { chunkText } = await import("../memory/chunker");
|
|
1638
|
+
const { mkdirSync, writeFileSync: fsWriteFileSync } = await import("fs");
|
|
1639
|
+
const pathMod = await import("path");
|
|
1640
|
+
|
|
1641
|
+
// Save file
|
|
1642
|
+
mkdirSync(paths.uploads, { recursive: true });
|
|
1643
|
+
const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^\.+/, "_");
|
|
1644
|
+
const filePath = pathMod.join(paths.uploads, `${Date.now()}_${safeName}`);
|
|
1645
|
+
// Verify resolved path stays within uploads directory
|
|
1646
|
+
if (!pathMod.resolve(filePath).startsWith(pathMod.resolve(paths.uploads))) {
|
|
1647
|
+
return Response.json({ error: "Invalid filename" }, { status: 400 });
|
|
1648
|
+
}
|
|
1649
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
1650
|
+
fsWriteFileSync(filePath, buffer);
|
|
1651
|
+
|
|
1652
|
+
// Parse document
|
|
1653
|
+
const mimeType = file.type || guessMimeType(file.name);
|
|
1654
|
+
const doc = await parseDocument(filePath, mimeType);
|
|
1655
|
+
|
|
1656
|
+
// Chunk and index into memory
|
|
1657
|
+
const chunks = chunkText(doc.text, filePath);
|
|
1658
|
+
const db = getDb();
|
|
1659
|
+
for (const chunk of chunks) {
|
|
1660
|
+
await writeAndIndexMemory(db, `[File: ${file.name}] ${chunk.content}`);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
// Track upload
|
|
1664
|
+
try {
|
|
1665
|
+
db.prepare(
|
|
1666
|
+
"INSERT INTO uploads (filename, original_name, mime_type, size_bytes, chunk_count) VALUES (?, ?, ?, ?, ?)"
|
|
1667
|
+
).run(filePath, file.name, mimeType, file.size, chunks.length);
|
|
1668
|
+
} catch (err: any) {
|
|
1669
|
+
logger.warn("Failed to record upload in database", { error: (err as Error).message });
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
return Response.json({
|
|
1673
|
+
uploaded: true,
|
|
1674
|
+
filename: file.name,
|
|
1675
|
+
size: file.size,
|
|
1676
|
+
chunks: chunks.length,
|
|
1677
|
+
wordCount: doc.metadata.wordCount,
|
|
1678
|
+
});
|
|
1679
|
+
} catch (err: any) {
|
|
1680
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
// Voice chat endpoint
|
|
1685
|
+
if (url.pathname === "/api/chat/voice" && req.method === "POST") {
|
|
1686
|
+
try {
|
|
1687
|
+
const formData = await req.formData();
|
|
1688
|
+
const audio = formData.get("audio") as File | null;
|
|
1689
|
+
if (!audio) {
|
|
1690
|
+
return Response.json({ error: "No audio provided" }, { status: 400 });
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
const { getSttProvider } = await import("../voice/stt");
|
|
1694
|
+
const { getTtsProvider } = await import("../voice/tts");
|
|
1695
|
+
|
|
1696
|
+
const stt = getSttProvider();
|
|
1697
|
+
if (!stt) {
|
|
1698
|
+
return Response.json({ error: "STT not configured" }, { status: 400 });
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Transcribe
|
|
1702
|
+
const audioBuffer = Buffer.from(await audio.arrayBuffer());
|
|
1703
|
+
const transcript = await stt.transcribe(audioBuffer);
|
|
1704
|
+
|
|
1705
|
+
if (!transcript.trim()) {
|
|
1706
|
+
return Response.json({ error: "Could not transcribe audio" }, { status: 400 });
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// Route through agent
|
|
1710
|
+
const message: InboundMessage = {
|
|
1711
|
+
channel: "webchat",
|
|
1712
|
+
userId: "local",
|
|
1713
|
+
sessionKey,
|
|
1714
|
+
text: transcript,
|
|
1715
|
+
};
|
|
1716
|
+
|
|
1717
|
+
let reply = "";
|
|
1718
|
+
await router.handleMessage(message, async (r) => {
|
|
1719
|
+
reply = r;
|
|
1720
|
+
});
|
|
1721
|
+
|
|
1722
|
+
// Optionally synthesize TTS response
|
|
1723
|
+
const tts = getTtsProvider();
|
|
1724
|
+
const wantTts = formData.get("tts") === "true";
|
|
1725
|
+
let audioResponse: string | null = null;
|
|
1726
|
+
|
|
1727
|
+
if (tts && wantTts && reply) {
|
|
1728
|
+
const ttsBuffer = await tts.synthesize(reply);
|
|
1729
|
+
audioResponse = ttsBuffer.toString("base64");
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
return Response.json({
|
|
1733
|
+
transcript,
|
|
1734
|
+
reply,
|
|
1735
|
+
audio: audioResponse,
|
|
1736
|
+
audioFormat: tts?.format ?? null,
|
|
1737
|
+
});
|
|
1738
|
+
} catch (err: any) {
|
|
1739
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// List uploads
|
|
1744
|
+
if (url.pathname === "/api/dashboard/uploads" && req.method === "GET") {
|
|
1745
|
+
try {
|
|
1746
|
+
const db = getDb();
|
|
1747
|
+
const uploads = db.query("SELECT * FROM uploads ORDER BY id DESC LIMIT 50").all();
|
|
1748
|
+
return Response.json({ uploads });
|
|
1749
|
+
} catch {
|
|
1750
|
+
return Response.json({ uploads: [] });
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// SSE events endpoint for desktop push notifications
|
|
1755
|
+
if (url.pathname === "/api/events" && req.method === "GET") {
|
|
1756
|
+
const stream = new ReadableStream({
|
|
1757
|
+
start(controller) {
|
|
1758
|
+
const encoder = new TextEncoder();
|
|
1759
|
+
const send = (event: string, data: any) => {
|
|
1760
|
+
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
|
1761
|
+
};
|
|
1762
|
+
// Send initial connection event
|
|
1763
|
+
send("connected", { timestamp: new Date().toISOString() });
|
|
1764
|
+
// Heartbeat every 30s
|
|
1765
|
+
const interval = setInterval(() => {
|
|
1766
|
+
try { send("ping", { timestamp: new Date().toISOString() }); } catch { clearInterval(interval); }
|
|
1767
|
+
}, 30000);
|
|
1768
|
+
// Clean up on abort
|
|
1769
|
+
req.signal.addEventListener("abort", () => clearInterval(interval));
|
|
1770
|
+
},
|
|
1771
|
+
});
|
|
1772
|
+
return new Response(stream, {
|
|
1773
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" },
|
|
1774
|
+
});
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
return new Response("Not Found", { status: 404 });
|
|
1778
|
+
},
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
logger.info(`WebChat + Dashboard at http://localhost:${server.port}`);
|
|
1782
|
+
},
|
|
1783
|
+
|
|
1784
|
+
stop() {
|
|
1785
|
+
if (server) {
|
|
1786
|
+
server.stop();
|
|
1787
|
+
server = null;
|
|
1788
|
+
}
|
|
1789
|
+
},
|
|
1790
|
+
|
|
1791
|
+
async sendMessage(_sessionKey: string, text: string) {
|
|
1792
|
+
logger.debug("WebChat proactive message (not delivered)", {
|
|
1793
|
+
text: text.slice(0, 100),
|
|
1794
|
+
});
|
|
1795
|
+
},
|
|
1796
|
+
};
|
|
1797
|
+
}
|