-
-
-
+
-
-
diff --git a/src/api.ts b/src/api.ts
new file mode 100644
index 0000000..fbf8c6d
--- /dev/null
+++ b/src/api.ts
@@ -0,0 +1,16 @@
+// API 地址:开发环境走 Vite 代理(/api → 后端,SSE 同样支持),生产用相对路径由后端托管静态资源
+export const API_BASE: string = (import.meta as any).env?.VITE_API_BASE || "";
+
+export async function api
(path: string, init?: RequestInit): Promise {
+ const r = await fetch(`${API_BASE}${path}`, init);
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.json();
+}
+
+export function apiJson(path: string, body: any): Promise {
+ return api(path, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+}
diff --git a/src/components/InputArea.vue b/src/components/InputArea.vue
index bbbd2a2..2670cf7 100644
--- a/src/components/InputArea.vue
+++ b/src/components/InputArea.vue
@@ -1,19 +1,22 @@
@@ -44,21 +47,30 @@ function submit() {
diff --git a/src/components/MessageBubble.vue b/src/components/MessageBubble.vue
index 83dd35f..4f96d41 100644
--- a/src/components/MessageBubble.vue
+++ b/src/components/MessageBubble.vue
@@ -1,41 +1,70 @@
-
-
-
⚠️ 需要审批的操作
-
{{ toolLabel }}
-
{{ msg.approval.command }}
-
-
-
-
+
+
+
+
+
+
-
+
diff --git a/src/components/MessageList.vue b/src/components/MessageList.vue
index c769a01..263a6af 100644
--- a/src/components/MessageList.vue
+++ b/src/components/MessageList.vue
@@ -7,7 +7,7 @@
{{ m.role === "user" ? "你" : "小鑫" }}
-
$emit('approve', m)" @reject="() => $emit('reject', m)" />
+ $emit('approve', p)" @reject="(p: any) => $emit('reject', p)" />
@@ -24,10 +24,11 @@ interface Msg {
content: string;
loading?: boolean;
approval?: any;
+ tools?: any[];
}
defineProps<{ messages: Msg[] }>();
-defineEmits<{ approve: [m: Msg]; reject: [m: Msg] }>();
+defineEmits<{ approve: [payload: any]; reject: [payload: any] }>();
diff --git a/src/components/SettingsModal.vue b/src/components/SettingsModal.vue
deleted file mode 100644
index 1250377..0000000
--- a/src/components/SettingsModal.vue
+++ /dev/null
@@ -1,233 +0,0 @@
-
-
-
-
-
⚙️ 设置
-
-
-
-
-
-
-
-
-
- 命令授权级别
-
- 控制执行 Shell 命令时是否需要你审批。
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/components/ToolCard.vue b/src/components/ToolCard.vue
new file mode 100644
index 0000000..c4ff459
--- /dev/null
+++ b/src/components/ToolCard.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
diff --git a/src/composables/useTheme.ts b/src/composables/useTheme.ts
new file mode 100644
index 0000000..0c1c8b5
--- /dev/null
+++ b/src/composables/useTheme.ts
@@ -0,0 +1,22 @@
+import { ref } from "vue";
+
+const theme = ref<"light" | "dark">("light");
+
+function applyTheme(t: "light" | "dark") {
+ document.documentElement.classList.toggle("dark", t === "dark");
+}
+
+export function initTheme() {
+ const saved = localStorage.getItem("theme");
+ if (saved === "dark" || saved === "light") theme.value = saved;
+ applyTheme(theme.value);
+}
+
+export function useTheme() {
+ function toggle() {
+ theme.value = theme.value === "dark" ? "light" : "dark";
+ localStorage.setItem("theme", theme.value);
+ applyTheme(theme.value);
+ }
+ return { theme, toggle };
+}
diff --git a/src/index.css b/src/index.css
index 9e964ce..83b5e38 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,4 +1,60 @@
-/* Mini AI Assistant - 全局基础样式 */
+/* MoneyPrinterClaw 风格全局主题
+ 设计令牌:浅色/暗色双主题,红色主色 + 低饱和灰阶,圆角卡片。
+ 参照 Streamlit 默认主题(MPC WebUI 所用):主色 #FF4B4B */
+:root {
+ --bg: #ffffff;
+ --surface: #ffffff;
+ --surface-2: #f0f2f6;
+ --surface-3: #fafbfc;
+ --text: #31333f;
+ --text-dim: #7c8194;
+ --border: #e6eaf1;
+ --primary: #ff4b4b;
+ --primary-hover: #e13b3b;
+ --primary-soft: #fff0f0;
+ --ok: #2ba04a;
+ --ok-soft: #e8f7ec;
+ --warn: #d97706;
+ --warn-soft: #fdf3e3;
+ --err: #d94848;
+ --err-soft: #fdeaea;
+ --code-bg: #1e2430;
+ --code-text: #e6edf3;
+ --user-bubble: #ff4b4b;
+ --user-text: #ffffff;
+ --assistant-bubble: #ffffff;
+ --shadow: 0 1px 2px rgba(20, 26, 40, 0.05), 0 6px 20px rgba(20, 26, 40, 0.05);
+ --shadow-lg: 0 4px 12px rgba(20, 26, 40, 0.08), 0 16px 40px rgba(20, 26, 40, 0.1);
+ --radius: 12px;
+ --radius-lg: 16px;
+}
+
+.dark {
+ --bg: #0e1117;
+ --surface: #161a22;
+ --surface-2: #1f242e;
+ --surface-3: #181d26;
+ --text: #f0f2f6;
+ --text-dim: #9aa2b3;
+ --border: #2a303c;
+ --primary: #ff5c5c;
+ --primary-hover: #ff7070;
+ --primary-soft: #3a2226;
+ --ok: #3fb964;
+ --ok-soft: #173023;
+ --warn: #e8a33d;
+ --warn-soft: #352a14;
+ --err: #ff6b6b;
+ --err-soft: #3a1f1f;
+ --code-bg: #0b0e14;
+ --code-text: #e6edf3;
+ --user-bubble: #ff4b4b;
+ --user-text: #ffffff;
+ --assistant-bubble: #1c212b;
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 6px 20px rgba(0, 0, 0, 0.35);
+ --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.5), 0 16px 40px rgba(0, 0, 0, 0.5);
+}
+
* { box-sizing: border-box; }
html, body, #app {
@@ -11,8 +67,15 @@ body {
"Microsoft YaHei", system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
+ background: var(--bg);
+ color: var(--text);
+ font-size: 14px;
+ line-height: 1.55;
}
-::-webkit-scrollbar { width: 8px; height: 8px; }
-::-webkit-scrollbar-thumb { background: var(--border, #ccc); border-radius: 4px; }
-::-webkit-scrollbar-thumb:hover { background: var(--text-dim, #999); }
+::-webkit-scrollbar { width: 10px; height: 10px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
+::-webkit-scrollbar-thumb:hover { background: var(--text-dim); background-clip: content-box; }
+
+button { font-family: inherit; }
diff --git a/src/lib/hljs.ts b/src/lib/hljs.ts
new file mode 100644
index 0000000..fa83fb1
--- /dev/null
+++ b/src/lib/hljs.ts
@@ -0,0 +1,33 @@
+// 按需加载 highlight.js(仅注册本助手常用语言,显著减小打包体积)
+import hljs from "highlight.js/lib/core";
+import javascript from "highlight.js/lib/languages/javascript";
+import typescript from "highlight.js/lib/languages/typescript";
+import json from "highlight.js/lib/languages/json";
+import sql from "highlight.js/lib/languages/sql";
+import bash from "highlight.js/lib/languages/bash";
+import powershell from "highlight.js/lib/languages/powershell";
+import python from "highlight.js/lib/languages/python";
+import xml from "highlight.js/lib/languages/xml";
+import css from "highlight.js/lib/languages/css";
+import markdown from "highlight.js/lib/languages/markdown";
+import yaml from "highlight.js/lib/languages/yaml";
+import java from "highlight.js/lib/languages/java";
+import csharp from "highlight.js/lib/languages/csharp";
+import cpp from "highlight.js/lib/languages/cpp";
+
+hljs.registerLanguage("javascript", javascript);
+hljs.registerLanguage("typescript", typescript);
+hljs.registerLanguage("json", json);
+hljs.registerLanguage("sql", sql);
+hljs.registerLanguage("bash", bash);
+hljs.registerLanguage("powershell", powershell);
+hljs.registerLanguage("python", python);
+hljs.registerLanguage("xml", xml);
+hljs.registerLanguage("css", css);
+hljs.registerLanguage("markdown", markdown);
+hljs.registerLanguage("yaml", yaml);
+hljs.registerLanguage("java", java);
+hljs.registerLanguage("csharp", csharp);
+hljs.registerLanguage("cpp", cpp);
+
+export default hljs;
\ No newline at end of file
diff --git a/src/main.ts b/src/main.ts
index bd7f832..5ca99ed 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,6 +1,7 @@
import { createApp } from "vue";
import App from "./App.vue";
+import router from "./router";
import "./index.css";
import "highlight.js/styles/atom-one-dark.css";
-createApp(App).mount("#app");
\ No newline at end of file
+createApp(App).use(router).mount("#app");
diff --git a/src/pages/ChatPage.vue b/src/pages/ChatPage.vue
new file mode 100644
index 0000000..843591e
--- /dev/null
+++ b/src/pages/ChatPage.vue
@@ -0,0 +1,392 @@
+
+
+
+
+
+
+ {{ currentTitle }}
+
+
+
+
+
+
💬
+
开始对话
+
小鑫可以读取文件、处理 Excel、执行命令(危险操作需审批)。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/SkillsPage.vue b/src/pages/SkillsPage.vue
new file mode 100644
index 0000000..468b93f
--- /dev/null
+++ b/src/pages/SkillsPage.vue
@@ -0,0 +1,185 @@
+
+
+
+
🛠️ 技能管理
+
小鑫可调用的工具清单。审批类操作在执行前会先征得你的同意。
+
+
+ {{ merged.length }} / {{ TOOLS.length }}
+
+
+
+
+ {{ cat }}
+
+
+
+
{{ t.icon }}
+
+
{{ t.label }} {{ t.name }}
+
{{ t.description }}
+
+
审批
+
+
+
+
+
+ {{ p.name }}
+ 必填
+ 可选
+ {{ p.desc }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/router.ts b/src/router.ts
new file mode 100644
index 0000000..efa1963
--- /dev/null
+++ b/src/router.ts
@@ -0,0 +1,12 @@
+import { createRouter, createWebHashHistory } from "vue-router";
+
+const ChatPage = () => import("./pages/ChatPage.vue");
+const SkillsPage = () => import("./pages/SkillsPage.vue");
+
+export default createRouter({
+ history: createWebHashHistory(),
+ routes: [
+ { path: "/", component: ChatPage },
+ { path: "/skills", component: SkillsPage },
+ ],
+});
\ No newline at end of file
diff --git a/src/server/app.ts b/src/server/app.ts
new file mode 100644
index 0000000..f5e684f
--- /dev/null
+++ b/src/server/app.ts
@@ -0,0 +1,259 @@
+import { createServer } from "http";
+import { existsSync, statSync, readFileSync } from "fs";
+import { readFile } from "fs/promises";
+import { resolve, extname, sep } from "path";
+import { config, saveEnv } from "./config";
+import { log, clip } from "./logger";
+import { listToolsForApi } from "./tools";
+import { runConversation, runApproved } from "./chat";
+import { conversationStore } from "./store";
+import type { ChatMsg } from "./types";
+
+// ---------- 静态文件服务(生产构建) ----------
+const DIST = resolve("dist");
+const MIME: Record
= {
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript",
+ ".css": "text/css",
+ ".json": "application/json",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".ico": "image/x-icon",
+ ".woff": "font/woff",
+ ".woff2": "font/woff2",
+ ".ttf": "font/ttf",
+ ".map": "application/json",
+};
+
+async function serveStatic(res: any, url: URL): Promise {
+ if (!existsSync(DIST) || !statSync(DIST).isDirectory()) return false;
+ let pathname = decodeURIComponent(url.pathname);
+ if (pathname === "/") pathname = "/index.html";
+ const safePath = resolve(DIST, "." + pathname);
+ if (safePath !== DIST && !safePath.startsWith(DIST + sep)) {
+ res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" });
+ res.end("Forbidden");
+ return true;
+ }
+ try {
+ if (existsSync(safePath) && statSync(safePath).isFile()) {
+ const ext = extname(safePath).toLowerCase();
+ res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
+ res.end(await readFile(safePath));
+ return true;
+ }
+ const idx = resolve(DIST, "index.html");
+ if (existsSync(idx)) {
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
+ res.end(await readFile(idx));
+ return true;
+ }
+ } catch {
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
+ res.end("Internal error");
+ return true;
+ }
+ return false;
+}
+
+async function readBody(req: any): Promise {
+ let body = "";
+ for await (const c of req) body += c;
+ return body;
+}
+
+export function startServer() {
+ const server = createServer(async (req, res) => {
+ const url = new URL(req.url || "", `http://localhost:${config.PORT}`);
+ res.setHeader("Access-Control-Allow-Origin", "*");
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
+
+ if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
+
+ if (req.method === "GET" && !url.pathname.startsWith("/api")) {
+ if (await serveStatic(res, url)) return;
+ }
+
+ if (req.method === "GET" && url.pathname === "/") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ status: "ok", model: config.MODEL || "default", workspace: config.WORKSPACE }));
+ return;
+ }
+
+ // 读取设置
+ if (req.method === "GET" && url.pathname === "/api/settings") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({
+ BASE_URL: config.BASE_URL,
+ API_KEY: config.API_KEY ? "******" + config.API_KEY.slice(-4) : "",
+ MODEL: config.MODEL,
+ CMD_APPROVE_LEVEL: config.CMD_APPROVE_LEVEL,
+ LOG_LEVEL: config.LOG_LEVEL,
+ TEMPERATURE: config.TEMPERATURE,
+ REMOTE_LOG_URL: config.REMOTE_LOG_URL,
+ }));
+ return;
+ }
+
+ // 保存设置(.env 按 key 增量更新)
+ if (req.method === "POST" && url.pathname === "/api/settings") {
+ let payload: any;
+ try { payload = JSON.parse(await readBody(req)); } catch { res.writeHead(400); res.end("bad json"); return; }
+ if (typeof payload.BASE_URL === "string" && payload.BASE_URL.trim()) config.BASE_URL = payload.BASE_URL.trim();
+ // token 为空时不覆盖已有配置(保留原 token)
+ if (typeof payload.API_KEY === "string" && payload.API_KEY.trim() && !payload.API_KEY.startsWith("******")) config.API_KEY = payload.API_KEY.trim();
+ if (typeof payload.MODEL === "string" && payload.MODEL.trim()) config.MODEL = payload.MODEL.trim();
+ if (payload.CMD_APPROVE_LEVEL === "high" || payload.CMD_APPROVE_LEVEL === "medium" || payload.CMD_APPROVE_LEVEL === "low") {
+ config.CMD_APPROVE_LEVEL = payload.CMD_APPROVE_LEVEL;
+ }
+ if (typeof payload.LOG_LEVEL === "string") config.LOG_LEVEL = payload.LOG_LEVEL.toLowerCase();
+ if (typeof payload.TEMPERATURE === "number") config.TEMPERATURE = payload.TEMPERATURE;
+ if (typeof payload.REMOTE_LOG_URL === "string") config.REMOTE_LOG_URL = payload.REMOTE_LOG_URL.trim();
+ saveEnv();
+ log("info", "settings_updated", { CMD_APPROVE_LEVEL: config.CMD_APPROVE_LEVEL, MODEL: config.MODEL, LOG_LEVEL: config.LOG_LEVEL, REMOTE_LOG_URL: config.REMOTE_LOG_URL });
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ ok: true }));
+ return;
+ }
+
+ // 模型可用性测试
+ if (req.method === "POST" && url.pathname === "/api/test_model") {
+ const urlLLM = config.BASE_URL.replace(/\/$/, "") + "/chat/completions";
+ const testBody: any = {
+ messages: [{ role: "user", content: "你好,请回复:测试通过" }],
+ stream: false,
+ max_tokens: 32,
+ temperature: config.TEMPERATURE,
+ };
+ if (config.MODEL) testBody.model = config.MODEL;
+ if (!config.API_KEY) {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ ok: false, error: "未配置 API_KEY" }));
+ return;
+ }
+ const t0 = Date.now();
+ try {
+ const r = await fetch(urlLLM, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.API_KEY}` },
+ body: JSON.stringify(testBody),
+ });
+ const ms = Date.now() - t0;
+ const txt = await r.text().catch(() => "");
+ let reply = "";
+ let errMsg = "";
+ try {
+ const j = JSON.parse(txt);
+ reply = j.choices?.[0]?.message?.content || "";
+ errMsg = j.error?.message || "";
+ } catch {}
+ if (r.ok) {
+ log("info", "model_test", { ok: true, model: config.MODEL, latencyMs: ms, reply: clip(reply, 100) });
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ ok: true, model: config.MODEL, latencyMs: ms, reply: reply.slice(0, 200) }));
+ } else {
+ log("warn", "model_test", { ok: false, status: r.status, error: errMsg || txt.slice(0, 200) });
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ ok: false, status: r.status, latencyMs: ms, error: errMsg || txt.slice(0, 200) }));
+ }
+ } catch (e: any) {
+ log("warn", "model_test", { ok: false, error: e.message });
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ ok: false, latencyMs: Date.now() - t0, error: "无法连接模型服务: " + e.message }));
+ }
+ return;
+ }
+
+ // 工具目录(A2:前端技能页数据源)
+ if (req.method === "GET" && url.pathname === "/api/tools") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(listToolsForApi()));
+ return;
+ }
+
+ // 会话列表
+ if (req.method === "GET" && url.pathname === "/api/conversations") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(conversationStore.listConvs()));
+ return;
+ }
+ // 新建会话
+ if (req.method === "POST" && url.pathname === "/api/conversations") {
+ const conv = { id: conversationStore.genConvId(), title: "新对话", messages: [], updatedAt: Date.now() };
+ conversationStore.writeConv(conv.id, conv);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(conv));
+ return;
+ }
+ // 单个会话:读取 / 保存 / 删除
+ const convMatch = url.pathname.match(/^\/api\/conversations\/([^/]+)$/);
+ if (convMatch) {
+ const cid = convMatch[1];
+ if (req.method === "GET") {
+ const conv = conversationStore.readConv(cid);
+ if (!conv) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); return; }
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(conv));
+ return;
+ }
+ if (req.method === "DELETE") {
+ conversationStore.deleteConv(cid);
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ ok: true }));
+ return;
+ }
+ if (req.method === "POST") {
+ let payload: any;
+ try { payload = JSON.parse(await readBody(req)); } catch { res.writeHead(400); res.end("bad json"); return; }
+ const old = conversationStore.readConv(cid) || { id: cid, messages: [] };
+ const msgs = Array.isArray(payload.messages) ? payload.messages : old.messages;
+ const first = msgs.find((x: any) => x.role === "user");
+ const title = typeof payload.title === "string" && payload.title
+ ? payload.title
+ : old.title && old.title !== "新对话"
+ ? old.title
+ : first ? String(first.content || "").slice(0, 20) : "新对话";
+ conversationStore.writeConv(cid, { id: cid, title, messages: msgs, updatedAt: Date.now() });
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ ok: true, title }));
+ return;
+ }
+ }
+
+ // 聊天:流式 SSE
+ if (req.method === "POST" && url.pathname === "/api/chat") {
+ let payload: any;
+ try { payload = JSON.parse(await readBody(req)); } catch { res.writeHead(400); res.end("bad json"); return; }
+ const history: ChatMsg[] = (payload.messages || []).map((m: any) => ({
+ role: m.role,
+ content: m.content,
+ }));
+ await runConversation(history, res, { conversationId: payload.conversationId });
+ return;
+ }
+
+ // 审批反馈:流式 SSE(继续对话)
+ if (req.method === "POST" && url.pathname === "/api/approve") {
+ let payload: any;
+ try { payload = JSON.parse(await readBody(req)); } catch { res.writeHead(400); res.end("bad json"); return; }
+ const approved = !!payload.approved;
+ await runApproved(payload.conversationId, payload.tool_call_id, approved, res);
+ return;
+ }
+
+ res.writeHead(404);
+ res.end("Not Found");
+ });
+
+ server.listen(config.PORT, "127.0.0.1", () => {
+ console.log(`Mini AI Assistant 后端已启动`);
+ console.log(` API: http://127.0.0.1:${config.PORT}`);
+ console.log(` 模型: ${config.BASE_URL}`);
+ console.log(` 工作区: ${config.WORKSPACE}`);
+ console.log(` 日志级别: ${config.LOG_LEVEL} → logs/`);
+ });
+}
diff --git a/src/server/chat.ts b/src/server/chat.ts
new file mode 100644
index 0000000..20c0611
--- /dev/null
+++ b/src/server/chat.ts
@@ -0,0 +1,287 @@
+import { readFileSync } from "fs";
+import { resolve, dirname } from "path";
+import { config } from "./config";
+import { log, clip, safeStringify, fmtMessages } from "./logger";
+import { streamLLM, MAX_TOOL_ROUNDS } from "./llm";
+import { execTool, type ToolContext } from "./tools";
+import { needsApproval, storeApproval, takeApproval } from "./security";
+import { DuckDBAdapter } from "./duckdb";
+import type { ChatMsg } from "./types";
+
+const duckdb = new DuckDBAdapter(config.DUCKDB_EXE, config.DUCKDB_FILE);
+
+// 系统提示:优先读取项目根目录 system.md(详细工具使用指南),缺失时用内置提示
+const BASE_SYSTEM_PROMPT = "你是小鑫,一个专为冯雅鑫定制的本地 AI 助手,可以读写文件、处理 Excel、用 DuckDB 分析数据、列出目录、执行命令(需审批)等。请用简体中文友好、简洁地回答。";
+let SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
+try {
+ const md = readFileSync(resolve(import.meta.dir ?? ".", "system.md"), "utf-8").trim();
+ if (md) SYSTEM_PROMPT = md;
+} catch {
+ /* 无 system.md 时使用内置提示 */
+}
+
+function withSystem(msgs: ChatMsg[]): ChatMsg[] {
+ if (msgs.length && msgs[0].role === "system") return msgs;
+ return [{ role: "system", content: SYSTEM_PROMPT }, ...msgs];
+}
+
+// ---------- 工具参数容错解析与路径自动补全 ----------
+// 解析模型返回的工具参数;非法 JSON 尝试修复,裸路径转为 {path}
+function parseToolArgs(raw: string | undefined): { args: any; note: string } {
+ if (!raw || !raw.trim()) return { args: {}, note: "空参数" };
+ try {
+ const a = JSON.parse(raw);
+ if (typeof a === "string") return { args: { path: a.trim() }, note: "裸路径转为 path" };
+ return { args: a && typeof a === "object" && !Array.isArray(a) ? a : {}, note: "" };
+ } catch {}
+ // 尝试转义单反斜杠(模型常把 Windows 路径写成 D:\xxx)
+ const fixed = raw.replace(/\\(?![\\"])/g, "\\\\");
+ if (fixed !== raw) {
+ try {
+ const a = JSON.parse(fixed);
+ return { args: a && typeof a === "object" && !Array.isArray(a) ? a : {}, note: "已修复非法反斜杠" };
+ } catch {}
+ }
+ // 裸字符串(去掉引号)→ 作为 path(若含 JSON 残留字符则视为解析失败)
+ const trimmed = raw.trim().replace(/^["']|["']$/g, "");
+ if (trimmed && !/[{}"]/.test(trimmed)) return { args: { path: trimmed }, note: "裸路径转为 path" };
+ return { args: {}, note: "参数无法解析" };
+}
+
+// 工具缺 path 时,用本对话已知的最近文件/目录补全(仅限非破坏性工具)
+function fillPathArgs(name: string, args: any, ctx: { lastDir: string; lastFile: string }): { args: any; note: string } {
+ if (args.path && String(args.path).trim()) return { args, note: "" };
+ if (["read_file", "read_excel", "load_excel"].includes(name)) {
+ if (ctx.lastFile) return { args: { ...args, path: ctx.lastFile }, note: `自动补全 path=${ctx.lastFile}` };
+ if (ctx.lastDir) return { args: { ...args, path: ctx.lastDir }, note: `自动补全 path=${ctx.lastDir}` };
+ return { args, note: "" };
+ }
+ if (name === "list_directory") {
+ if (ctx.lastDir) return { args: { ...args, path: ctx.lastDir }, note: `自动补全 path=${ctx.lastDir}` };
+ return { args: { ...args, path: "." }, note: "" };
+ }
+ return { args, note: "" };
+}
+
+// 从工具执行结果中学习已知文件/目录,供后续补全
+function absorbToolContext(name: string, args: any, exec: any, ctx: { lastDir: string; lastFile: string }) {
+ const p = args.path;
+ if (p && typeof p === "string") {
+ if (name === "list_directory") {
+ ctx.lastDir = p;
+ } else {
+ ctx.lastFile = p;
+ ctx.lastDir = dirname(p);
+ }
+ }
+ if (name === "list_directory" && Array.isArray(exec.result)) {
+ for (const it of exec.result) {
+ if (it?.type === "file") ctx.lastFile = resolve(ctx.lastDir || ".", it.name);
+ }
+ }
+ if (name === "execute_command" && typeof exec.result === "string") {
+ const m = exec.result.split(/\r?\n/).find((l) => /\.(xlsx?|xls|csv|txt|json)$/i.test(l.trim()));
+ if (m) ctx.lastFile = m.trim();
+ }
+}
+
+// B5:工具结果在进入模型上下文前做压缩,避免多轮后 token 爆炸
+const TOOL_CONTENT_LIMIT = 6000;
+function summarizeToolContent(exec: any): string {
+ let s = safeStringify(exec);
+ if (s.length > TOOL_CONTENT_LIMIT) {
+ s = s.slice(0, TOOL_CONTENT_LIMIT) + `\n…[工具结果过长(共 ${s.length} 字符),已截断]`;
+ }
+ return s;
+}
+
+// ---------- SSE 辅助 ----------
+function sseInit(res: any) {
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+ "Access-Control-Allow-Origin": "*",
+ });
+}
+function sseSend(res: any, obj: any) {
+ if (res.writableEnded) return;
+ res.write(`data: ${JSON.stringify(obj)}\n\n`);
+}
+
+export interface ConversationOptions {
+ conversationId?: string;
+ fromApproval?: boolean;
+}
+
+// ---------- 对话处理(带工具循环) ----------
+export async function runConversation(initialMessages: ChatMsg[], res: any, opts: ConversationOptions = {}) {
+ sseInit(res);
+ const ac = new AbortController();
+ let ended = false;
+ // B4:客户端断连时取消上游(LLM / 命令 / duckdb)
+ res.on("close", () => {
+ if (!ended) ac.abort();
+ });
+ const signal = ac.signal;
+
+ let messages = withSystem(initialMessages);
+
+ const userInput = initialMessages
+ .filter((m) => m.role === "user")
+ .map((m) => clip(m.content ?? "", 2000))
+ .join(" | ");
+ log("info", "对话开始", opts.fromApproval ? "审批后继续" : "新对话");
+ if (userInput) log("info", "用户输入", userInput);
+
+ const toolCtx: ToolContext = { workspace: config.WORKSPACE, duckdb, signal };
+ // 本对话已知的最近文件/目录(供工具缺 path 时自动补全)
+ const pathCtx = { lastDir: "", lastFile: "" };
+
+ const finish = (extraToken?: string) => {
+ if (ended || res.writableEnded) return;
+ ended = true;
+ if (extraToken) sseSend(res, { type: "token", content: extraToken });
+ sseSend(res, { type: "done" });
+ res.end();
+ };
+
+ try {
+ // 工具循环(最多 MAX_TOOL_ROUNDS 轮避免死循环)
+ for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
+ if (signal.aborted) break;
+
+ log("info", "发送给AI", `第${round + 1}轮 model=${config.MODEL} temperature=${config.TEMPERATURE}\n${fmtMessages(messages)}`);
+ const gen = streamLLM(messages, { signal });
+ let content = "";
+ let errored = false;
+ let llmErr = "";
+ const toolCalls: any[] = [];
+
+ for await (const ev of gen) {
+ if (signal.aborted) break;
+ if (ev.type === "token") {
+ content += ev.content;
+ sseSend(res, { type: "token", content: ev.content });
+ } else if (ev.type === "toolcall") {
+ toolCalls.push(ev.toolcall);
+ } else if (ev.type === "error") {
+ errored = true;
+ llmErr = ev.content;
+ log("error", "AI错误", ev.content);
+ sseSend(res, { type: "token", content: "\n\n⚠️ " + ev.content });
+ }
+ }
+ if (signal.aborted) break;
+
+ if (errored) {
+ log("info", "返回界面", "⚠️ " + clip(llmErr, 1000));
+ finish();
+ return;
+ }
+
+ // 需要工具调用:本轮把模型返回的多个工具调用全部执行,统一把结果返回给模型
+ const calls = toolCalls.filter((tc) => tc?.function?.name);
+ if (calls.length) {
+ const assistantMsg = { role: "assistant", content: content || null, tool_calls: calls.map((tc) => ({ id: tc.id, type: tc.type, function: tc.function })) };
+ const toolResults: ChatMsg[] = [];
+
+ for (const tc of calls) {
+ if (signal.aborted) break;
+ const name = tc.function.name;
+ const parsed = parseToolArgs(tc.function.arguments);
+ let args = parsed.args;
+ // AI 返回:决定调用工具
+ log("info", "AI返回", `调用工具 ${name},参数 ${safeStringify(args)}${parsed.note ? `(${parsed.note})` : ""}`);
+
+ // 路径自动补全:工具缺 path 时用本对话已知的最近文件/目录
+ const filled = fillPathArgs(name, args, pathCtx);
+ if (filled.note) log("info", "路径补全", filled.note);
+ args = filled.args;
+
+ if (needsApproval(name, args)) {
+ // 请求审批,保存上下文等待 /api/approve(之前已执行的结果一并保留)
+ log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(等待用户审批,其余 ${calls.length - 1} 个调用一并暂停)`);
+ storeApproval(opts.conversationId, tc.id, {
+ messages: [...messages, assistantMsg, ...toolResults],
+ toolCall: tc,
+ args,
+ });
+ sseSend(res, {
+ type: "approval_required",
+ tool_call_id: tc.id,
+ tool: name,
+ args,
+ command: name === "execute_command" ? args.command : `${name}(${JSON.stringify(args)})`,
+ });
+ finish();
+ return;
+ }
+
+ // 执行命令/工具
+ log("info", "执行命令", `${name} 参数 ${safeStringify(args)}`);
+ const exec = await execTool(name, args, toolCtx);
+ // 执行结果
+ log("info", "执行结果", `ok=${exec.ok} ${clip(exec.result, 1000)}`);
+ // 从结果中学习已知路径(供后续工具缺 path 时补全)
+ absorbToolContext(name, args, exec, pathCtx);
+ toolResults.push({ role: "tool", name, content: summarizeToolContent(exec), tool_call_id: tc.id });
+ }
+
+ if (signal.aborted) break;
+ if (content) log("info", "返回界面", clip(content, 2000));
+ if (calls.length > 1) log("info", "工具调用", `本轮共执行 ${calls.length} 个工具调用`);
+ messages = [...messages, assistantMsg, ...toolResults];
+ // 继续循环,让模型基于全部工具结果生成最终回复
+ continue;
+ }
+
+ // 无工具调用,正常结束
+ log("info", "AI返回", clip(content, 2000));
+ log("info", "返回界面", clip(content, 2000));
+ finish();
+ return;
+ }
+
+ if (signal.aborted) {
+ log("info", "对话结束", "客户端已断开,取消上游请求");
+ } else {
+ log("warn", "对话结束", `达到最大工具调用轮数(${MAX_TOOL_ROUNDS}),强制结束`);
+ finish("\n[达到最大工具调用轮数]");
+ }
+ } catch (e: any) {
+ log("error", "对话异常", e.message);
+ if (!ended && !signal.aborted) finish("\n⚠️ 处理出错:" + e.message);
+ } finally {
+ // 兜底:任何路径都确保响应结束
+ if (!ended) finish();
+ }
+}
+
+// 供 /api/approve 使用:取出待审批上下文并继续对话
+export async function runApproved(conversationId: string | undefined, toolCallId: string, approved: boolean, res: any) {
+ const pending = takeApproval(conversationId, toolCallId);
+ if (!pending) {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "无待审批操作或已过期" }));
+ return;
+ }
+ const { messages, toolCall, args } = pending;
+ const name = toolCall.function.name;
+ log("info", "审批执行", `${name} 参数 ${safeStringify(args)}`);
+
+ let exec;
+ if (approved) {
+ log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(已批准)`);
+ exec = await execTool(name, args, { workspace: config.WORKSPACE, duckdb });
+ log("info", "执行结果", `ok=${exec.ok} ${clip(exec.result, 1000)}`);
+ } else {
+ log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(已拒绝)`);
+ exec = { ok: false, result: "用户已拒绝该操作" };
+ }
+ const nextMessages: ChatMsg[] = [
+ ...messages,
+ { role: "tool", name, content: safeStringify(exec), tool_call_id: toolCall.id },
+ ];
+ await runConversation(nextMessages, res, { conversationId, fromApproval: true });
+}
diff --git a/src/server/config.ts b/src/server/config.ts
new file mode 100644
index 0000000..5a7dee9
--- /dev/null
+++ b/src/server/config.ts
@@ -0,0 +1,70 @@
+import { readFileSync, writeFileSync, existsSync } from "fs";
+import { resolve } from "path";
+
+// ---------- 配置加载 ----------
+function loadEnv(): Record {
+ const env: Record = {};
+ try {
+ const raw = readFileSync(".env", "utf-8");
+ for (const line of raw.split("\n")) {
+ const t = line.trim();
+ if (!t || t.startsWith("#")) continue;
+ const idx = t.indexOf("=");
+ if (idx === -1) continue;
+ env[t.slice(0, idx).trim()] = t.slice(idx + 1).trim();
+ }
+ } catch {
+ /* ignore */
+ }
+ return env;
+}
+
+const ENV = loadEnv();
+
+// 运行时可变配置(可通过 /api/settings 修改并持久化)
+export const config = {
+ BASE_URL: process.env.BASE_URL || ENV.BASE_URL || "https://ark.cn-beijing.volces.com/api/coding/v3",
+ API_KEY: process.env.API_KEY || ENV.API_KEY || "",
+ MODEL: process.env.MODEL || ENV.MODEL || "",
+ WORKSPACE: process.env.WORKSPACE || ENV.WORKSPACE || process.cwd(),
+ CMD_APPROVE_LEVEL: (process.env.CMD_APPROVE_LEVEL || ENV.CMD_APPROVE_LEVEL || "medium").toLowerCase(),
+ PORT: parseInt(process.env.PORT || ENV.PORT || "3000", 10),
+ LOG_LEVEL: (process.env.LOG_LEVEL || ENV.LOG_LEVEL || "debug").toLowerCase(),
+ TEMPERATURE: parseFloat(process.env.TEMPERATURE || ENV.TEMPERATURE || "0.3"),
+ DUCKDB_EXE: process.env.DUCKDB_EXE || ENV.DUCKDB_EXE || resolve("bin", "duckdb.exe"),
+ DUCKDB_FILE: process.env.DUCKDB_FILE || ENV.DUCKDB_FILE || resolve("data", "duck.db"),
+ REMOTE_LOG_URL: process.env.REMOTE_LOG_URL || ENV.REMOTE_LOG_URL || "",
+};
+
+// 持久化配置到 .env(WORKSPACE 为内部默认值,不在设置页暴露)
+const ENV_KEYS = ["BASE_URL", "API_KEY", "MODEL", "WORKSPACE", "CMD_APPROVE_LEVEL", "PORT", "LOG_LEVEL", "TEMPERATURE", "DUCKDB_EXE", "DUCKDB_FILE", "REMOTE_LOG_URL"] as const;
+
+// 按 key 增量更新 .env:保留注释与未知键,只覆盖 ENV_KEYS 中已有的行
+export function saveEnv() {
+ const header = "# Mini AI Assistant 配置(可由设置页面修改)\n";
+ const existing = existsSync(".env") ? readFileSync(".env", "utf-8").split(/\r?\n/) : [];
+ const out: string[] = [];
+ const written = new Set();
+ for (const line of existing) {
+ const idx = line.indexOf("=");
+ if (idx > 0) {
+ const k = line.slice(0, idx).trim();
+ if ((ENV_KEYS as readonly string[]).includes(k)) {
+ out.push(`${k}=${String((config as any)[k])}`);
+ written.add(k);
+ } else {
+ out.push(line);
+ }
+ } else {
+ out.push(line);
+ }
+ }
+ for (const k of ENV_KEYS) {
+ if (!written.has(k)) out.push(`${k}=${String((config as any)[k])}`);
+ }
+ try {
+ writeFileSync(".env", header + out.join("\n") + "\n", "utf-8");
+ } catch (e) {
+ console.error("保存配置失败:", (e as any).message);
+ }
+}
diff --git a/src/server/duckdb.ts b/src/server/duckdb.ts
new file mode 100644
index 0000000..eaeda85
--- /dev/null
+++ b/src/server/duckdb.ts
@@ -0,0 +1,156 @@
+import { spawn } from "child_process";
+import { mkdirSync } from "fs";
+import { dirname } from "path";
+
+// ---------- DuckDB(长驻 duckdb.exe 子进程,避免每次查询新建进程) ----------
+// 采用「SQL + 哨兵查询」的方式在 stdout 上做结果分帧:
+// 发送: \nSELECT '__mpc_sentinel__' AS s;\n
+// duckdb -json 模式逐条打印 SELECT 结果,最后一个就是哨兵行 [{"s":"__mpc_sentinel__"}],
+// 取其之前的内容即为本次查询结果;DDL 语句无输出则只有哨兵行。
+// duckdb 遇错会停止执行后续语句并写入 stderr → 感知到 stderr 即失败,随后重建进程。
+
+const SENTINEL = '[{"s":"__mpc_sentinel__"}]';
+const DEFAULT_TIMEOUT_MS = 60000;
+
+export interface DuckRunOptions {
+ timeout?: number;
+ signal?: AbortSignal;
+}
+
+export interface DuckRunResult {
+ ok: boolean;
+ output: string;
+ stderr: string;
+}
+
+// CSV 单元格转义(供导入表时写 CSV 使用)
+export function csvCell(v: any): string {
+ if (v === null || v === undefined) return "";
+ if (typeof v === "number") return Number.isFinite(v) ? String(v) : "";
+ if (v instanceof Date) return '"' + v.toISOString().replace(/"/g, '""') + '"';
+ const s = String(v);
+ return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
+}
+
+export class DuckDBAdapter {
+ private proc: any = null;
+ // 串行队列:DuckDB 为单引擎,并发请求按到达顺序排队执行
+ private chain: Promise = Promise.resolve();
+
+ constructor(private exe: string, private dbFile: string) {}
+
+ get dbFilePath(): string {
+ return this.dbFile;
+ }
+
+ private ensureProc() {
+ if (this.proc && this.proc.exitCode === null) return;
+ mkdirSync(dirname(this.dbFile), { recursive: true });
+ const p = spawn(this.exe, [this.dbFile, "-json"], { stdio: ["pipe", "pipe", "pipe"] });
+ this.proc = p;
+ p.on("error", () => { if (this.proc === p) this.proc = null; });
+ p.on("close", () => { if (this.proc === p) this.proc = null; });
+ try { p.stdin.on("error", () => {}); } catch {}
+ }
+
+ private killProc() {
+ try { this.proc?.kill(); } catch {}
+ this.proc = null;
+ }
+
+ // 排队执行(并发安全)
+ run(sql: string, opts: DuckRunOptions = {}): Promise {
+ const task = () => this._run(sql, opts);
+ const result = this.chain.then(task, task);
+ this.chain = result.then(
+ () => {},
+ () => {}
+ );
+ return result;
+ }
+
+ private _run(sql: string, opts: DuckRunOptions): Promise {
+ return new Promise((resolve) => {
+ this.ensureProc();
+ const proc = this.proc;
+ if (!proc) {
+ resolve({ ok: false, output: "", stderr: "无法启动 duckdb 进程" });
+ return;
+ }
+ let stdoutBuf = "";
+ let stderrBuf = "";
+ let done = false;
+ const timeout = opts.timeout ?? DEFAULT_TIMEOUT_MS;
+ let timer: any = null;
+ let onData: (d: Buffer) => void;
+ let onErr: (d: Buffer) => void;
+ let onAbort: () => void;
+
+ const cleanup = () => {
+ clearTimeout(timer);
+ opts.signal?.removeEventListener("abort", onAbort);
+ proc.stdout.off("data", onData);
+ proc.stderr.off("data", onErr);
+ };
+ const finish = (ok: boolean, output: string, stderr: string) => {
+ if (done) return;
+ done = true;
+ cleanup();
+ if (!ok) this.killProc();
+ resolve({ ok, output, stderr });
+ };
+
+ onData = (d: Buffer) => {
+ if (done) return;
+ stdoutBuf += d.toString("utf8");
+ const idx = stdoutBuf.indexOf(SENTINEL);
+ if (idx >= 0) {
+ const output = stdoutBuf.slice(0, idx).trim();
+ const err = stderrBuf.trim();
+ if (err) finish(false, "", err);
+ else finish(true, output, "");
+ }
+ };
+ onErr = (d: Buffer) => {
+ if (done) return;
+ stderrBuf += d.toString("utf8");
+ if (stderrBuf.trim()) finish(false, "", stderrBuf.trim());
+ };
+ onAbort = () => finish(false, "", "已取消");
+
+ proc.stdout.on("data", onData);
+ proc.stderr.on("data", onErr);
+ if (opts.signal) {
+ if (opts.signal.aborted) { finish(false, "", "已取消"); return; }
+ opts.signal.addEventListener("abort", onAbort);
+ }
+ timer = setTimeout(() => finish(false, "", `duckdb 执行超时(${timeout}ms)`), timeout);
+ try {
+ let s = sql.trim();
+ if (!/;\s*$/.test(s)) s += ";";
+ proc.stdin.write(s + "\nSELECT '__mpc_sentinel__' AS s;\n");
+ } catch (e: any) {
+ finish(false, "", "写入 duckdb 失败: " + e.message);
+ }
+ });
+ }
+
+ // 执行 SELECT,返回行数组(空结果返回 [])
+ async query(sql: string, opts?: DuckRunOptions): Promise {
+ const r = await this.run(sql, opts);
+ if (!r.ok) throw new Error(r.stderr);
+ const t = r.output.trim();
+ if (!t) return [];
+ try {
+ return JSON.parse(t);
+ } catch {
+ return [];
+ }
+ }
+
+ // 执行 DDL / COPY 等无返回语句
+ async exec(sql: string, opts?: DuckRunOptions): Promise {
+ const r = await this.run(sql, opts);
+ if (!r.ok) throw new Error(r.stderr);
+ }
+}
diff --git a/src/server/excel.ts b/src/server/excel.ts
new file mode 100644
index 0000000..4460d90
--- /dev/null
+++ b/src/server/excel.ts
@@ -0,0 +1,40 @@
+import { readFile, mkdir } from "fs/promises";
+import { writeFileSync } from "fs";
+import { resolve, normalize } from "path";
+import { read as xlsxRead, write as xlsxWrite, utils } from "xlsx";
+import { config } from "./config";
+
+// ---------- Excel 读写(纯 JS,无原生依赖) ----------
+
+// 读取 Excel,默认仅前 5 行,用于理解数据结构
+export async function readExcel(
+ path: string,
+ sheet?: string,
+ limit = 5
+): Promise<{ totalRows: number; columns: any[]; data: any[][] }> {
+ const buf = await readFile(resolve(config.WORKSPACE, normalize(path)));
+ const wb = xlsxRead(buf, { type: "buffer" });
+ const sheetName = sheet || wb.SheetNames[0];
+ const ws = wb.Sheets[sheetName];
+ const rows = utils.sheet_to_json(ws, { header: 1 });
+ return {
+ totalRows: rows.length,
+ columns: rows[0] || [],
+ data: rows.slice(0, Math.min(limit || 5, 200)),
+ };
+}
+
+// 将 JSON 数组写入 Excel
+export async function writeExcel(path: string, data: any[], sheet?: string): Promise {
+ const rows = (data || []).map((o: any) => Object.values(o));
+ const headers = data?.length ? Object.keys(data[0]) : [];
+ const ws = utils.aoa_to_sheet([headers, ...rows]);
+ const wb = { Sheets: {} as Record, SheetNames: [] as string[] };
+ wb.SheetNames.push(sheet || "Sheet1");
+ wb.Sheets[wb.SheetNames[0]] = ws;
+ const abs = resolve(config.WORKSPACE, normalize(path));
+ await mkdir(resolve(abs, ".."), { recursive: true }).catch(() => {});
+ const out = xlsxWrite(wb, { type: "buffer", bookType: "xlsx" });
+ writeFileSync(abs, out);
+ return `已写入 Excel: ${path} (${rows.length} 行)`;
+}
diff --git a/src/server/llm.ts b/src/server/llm.ts
new file mode 100644
index 0000000..71f93ab
--- /dev/null
+++ b/src/server/llm.ts
@@ -0,0 +1,132 @@
+import { config } from "./config";
+import { getToolDefinitions } from "./tools";
+import type { ChatMsg } from "./types";
+
+// ---------- LLM 调用(流式,带重试与断连取消) ----------
+export interface StreamLLMOptions {
+ signal?: AbortSignal;
+ maxRetries?: number;
+}
+
+const TOOL_CALL_LIMIT = 20;
+
+async function sleep(ms: number, signal?: AbortSignal): Promise {
+ return new Promise((resolve) => {
+ if (signal?.aborted) return resolve();
+ const onAbort = () => { clearTimeout(t); resolve(); };
+ const t = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms);
+ signal?.addEventListener("abort", onAbort);
+ });
+}
+
+export async function* streamLLM(
+ messages: ChatMsg[],
+ opts: StreamLLMOptions = {}
+): AsyncGenerator<{ type: "token" | "toolcall" | "error"; content?: string; toolcall?: any }> {
+ const url = config.BASE_URL.replace(/\/$/, "") + "/chat/completions";
+ const body: any = {
+ messages: messages.map((m) => ({
+ role: m.role,
+ content: m.content,
+ ...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
+ ...(m.name ? { name: m.name } : {}),
+ ...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
+ })),
+ tools: getToolDefinitions(),
+ tool_choice: "auto",
+ stream: true,
+ temperature: config.TEMPERATURE,
+ };
+ if (config.MODEL) body.model = config.MODEL;
+
+ // 重试瞬时错误(网络失败 / 5xx / 429),4xx 不重试
+ const maxRetries = opts.maxRetries ?? 2;
+ let resp: any = null;
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ try {
+ resp = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.API_KEY}` },
+ body: JSON.stringify(body),
+ signal: opts.signal,
+ });
+ } catch (e: any) {
+ if (opts.signal?.aborted) {
+ yield { type: "error", content: "请求已取消" };
+ return;
+ }
+ if (attempt >= maxRetries) {
+ yield { type: "error", content: "无法连接模型服务: " + e.message };
+ return;
+ }
+ await sleep(Math.min(500 * 2 ** attempt, 5000), opts.signal);
+ continue;
+ }
+ if (resp.ok || (resp.status < 500 && resp.status !== 429)) break;
+ if (attempt >= maxRetries) break;
+ await sleep(Math.min(500 * 2 ** attempt, 5000), opts.signal);
+ }
+
+ if (!resp || !resp.ok) {
+ const txt = await resp?.text().catch(() => "") || "";
+ yield { type: "error", content: `模型返回错误 (${resp?.status ?? "?"}): ${txt.slice(0, 300)}` };
+ return;
+ }
+
+ const reader = resp.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+ // 支持一次响应返回多个工具调用:按 index 聚合,各自独立
+ const toolCalls = new Map();
+ let anonIdx = 0;
+
+ while (true) {
+ let chunk: Uint8Array | null = null;
+ try {
+ const r = await reader.read();
+ if (r.done) break;
+ chunk = r.value;
+ } catch (e: any) {
+ if (opts.signal?.aborted) {
+ yield { type: "error", content: "请求已取消" };
+ return;
+ }
+ yield { type: "error", content: "读取模型响应中断: " + e.message };
+ return;
+ }
+ buffer += decoder.decode(chunk, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+ for (const line of lines) {
+ const t = line.trim();
+ if (!t.startsWith("data:")) continue;
+ const data = t.slice(5).trim();
+ if (data === "[DONE]") continue;
+ let json: any;
+ try { json = JSON.parse(data); } catch { continue; }
+ const delta = json.choices?.[0]?.delta || {};
+ if (delta.content) yield { type: "token", content: delta.content };
+ if (delta.tool_calls) {
+ for (const tc of delta.tool_calls) {
+ // OpenAI 兼容流:首个分片带 id+name,延续分片 id 为 null 且只带 arguments,
+ // 必须按 index 聚合,否则每个分片被拆成独立调用导致参数残缺/为空
+ const key = (tc.index !== undefined ? String(tc.index) : tc.id) || `call_anon_${anonIdx++}`;
+ if (!toolCalls.has(key)) toolCalls.set(key, { id: tc.id || undefined, type: "function", function: { name: "", arguments: "" } });
+ const acc = toolCalls.get(key);
+ if (tc.function?.name) acc.function.name = tc.function.name;
+ if (tc.function?.arguments) acc.function.arguments += tc.function.arguments;
+ }
+ }
+ }
+ if (opts.signal?.aborted) {
+ yield { type: "error", content: "请求已取消" };
+ return;
+ }
+ }
+ for (const tc of toolCalls.values()) {
+ if (!tc.id || tc.id.startsWith("call_anon_")) tc.id = "call_" + Date.now() + "_" + Math.random().toString(36).slice(2, 7);
+ yield { type: "toolcall", toolcall: tc };
+ }
+}
+
+export const MAX_TOOL_ROUNDS = TOOL_CALL_LIMIT;
diff --git a/src/server/logger.ts b/src/server/logger.ts
new file mode 100644
index 0000000..734cc61
--- /dev/null
+++ b/src/server/logger.ts
@@ -0,0 +1,103 @@
+import { existsSync, appendFileSync, mkdirSync } from "fs";
+import { mkdir, appendFile } from "fs/promises";
+import { resolve } from "path";
+import { config } from "./config";
+import type { ChatMsg } from "./types";
+
+// ---------- 日志系统 ----------
+// 本地写入采用「内存队列 + 周期批量落盘」,避免 debug 级别高频小写造成 I/O 压力;
+// 日志文件按天轮转,避免单个 global.log 无限增长。
+const LOG_DIR = "logs";
+const LEVELS: Record = { debug: 0, info: 1, warn: 2, error: 3 };
+const FLUSH_INTERVAL_MS = 800;
+
+function shouldLog(level: string): boolean {
+ const lv = LEVELS[level] ?? 1;
+ return lv >= (LEVELS[config.LOG_LEVEL] ?? 0);
+}
+
+function logFileName(): string {
+ const d = new Date().toISOString().slice(0, 10);
+ return resolve(LOG_DIR, `global-${d}.log`);
+}
+
+const queue: string[] = [];
+let timer: any = null;
+
+function enqueue(line: string) {
+ queue.push(line);
+ if (!timer) timer = setTimeout(() => { timer = null; void flush(); }, FLUSH_INTERVAL_MS);
+}
+
+async function flush() {
+ if (!queue.length) return;
+ const lines = queue.splice(0, queue.length).join("");
+ try {
+ if (!existsSync(LOG_DIR)) await mkdir(LOG_DIR, { recursive: true });
+ await appendFile(logFileName(), lines, "utf-8");
+ } catch {
+ /* 忽略日志写入失败 */
+ }
+}
+
+// 进程退出时兜底落盘(同步写)
+process.on("exit", () => {
+ try {
+ if (queue.length) {
+ if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
+ appendFileSync(logFileName(), queue.join(""));
+ }
+ } catch {
+ /* ignore */
+ }
+});
+
+export function log(level: string, category: string, data: any) {
+ if (!shouldLog(level)) return;
+ const ts = new Date().toISOString();
+ const text = typeof data === "string" ? data : safeStringify(data);
+ const line = `[${ts}] [${level.toUpperCase().padEnd(5)}] [${category}] ${text}\n`;
+ enqueue(line);
+ pushRemote(level, category, text);
+}
+
+// 推送到远程日志接收器(仅配置了地址时启用;失败静默忽略)
+function pushRemote(level: string, category: string, message: string) {
+ const url = config.REMOTE_LOG_URL;
+ if (!url) return;
+ try {
+ fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ level, category, message, ts: new Date().toISOString() }),
+ }).catch(() => {});
+ } catch {
+ /* 忽略推送失败 */
+ }
+}
+
+// 对话消息列表的可读格式化(供“发送给AI”日志使用)
+export function fmtMessages(msgs: ChatMsg[]): string {
+ return msgs
+ .map((m) => {
+ if (m.role === "tool") return ` [tool ${m.name}] ${clip(m.content ?? "", 600)}`;
+ if (m.tool_calls?.length) return ` [assistant→tool] ${m.tool_calls.map((tc: any) => tc.function?.name).join(", ")}`;
+ return ` [${m.role}] ${clip(m.content ?? "", 1000)}`;
+ })
+ .join("\n");
+}
+
+// 可安全序列化 BigInt 等类型
+export function safeStringify(s: any): string {
+ try {
+ return JSON.stringify(s, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 0);
+ } catch {
+ try { return String(s); } catch { return "[unserializable]"; }
+ }
+}
+
+// 安全截断
+export function clip(s: any, n = 600): string {
+ const str = typeof s === "string" ? s : safeStringify(s);
+ return str.length > n ? str.slice(0, n) + "…" : str;
+}
diff --git a/src/server/security.ts b/src/server/security.ts
new file mode 100644
index 0000000..577480c
--- /dev/null
+++ b/src/server/security.ts
@@ -0,0 +1,77 @@
+import { config } from "./config";
+import type { ChatMsg } from "./types";
+
+// ---------- 危险命令检测 ----------
+const DANGEROUS_PATTERNS = [
+ "rm -rf /", "rd /s", "format", "mkfs", "shutdown", "reboot", "poweroff",
+ "dd if=", "fdisk", ":(){:|:&};:", "chmod -r", "del /s", "attrib",
+];
+
+export function isDangerousCommand(cmd: string): boolean {
+ const lower = cmd.toLowerCase();
+ if (DANGEROUS_PATTERNS.some((p) => lower.includes(p))) return true;
+ // 递归删除(开关顺序不定):del/rmdir 带 /s,rm 带 -rf/-fr 等组合
+ if (/\bdel\b[^\r\n]*?\/s\b/.test(lower)) return true;
+ if (/\brmdir\b[^\r\n]*?\/s\b/.test(lower)) return true;
+ if (/\brm\b[^\r\n]*?-(?:rf|fr|r\s+f|f\s+r)\b/.test(lower)) return true;
+ return false;
+}
+
+// 需要审批的工具:delete_file 始终需要;execute_command 依据授权级别
+const NEEDS_APPROVAL = new Set(["delete_file"]);
+
+// 命令授权级别:high=每个命令都审核, medium=仅危险命令审核, low=全部不审核(默认 medium)
+export function needsApproval(name: string, args: any): boolean {
+ if (NEEDS_APPROVAL.has(name)) return true;
+ if (name === "execute_command") {
+ const lv = config.CMD_APPROVE_LEVEL;
+ if (lv === "high") return true;
+ if (lv === "low") return false;
+ return isDangerousCommand(String(args?.command || ""));
+ }
+ return false;
+}
+
+// ---------- 审批上下文(按会话 + tool_call_id 绑定,带过期时间) ----------
+// 修复原先全局单槽的问题:多会话 / 多标签页的审批互不干扰,且过期自动清理。
+const TTL_MS = 10 * 60 * 1000;
+
+export interface PendingApproval {
+ messages: ChatMsg[];
+ toolCall: any;
+ args: any;
+ expiresAt: number;
+}
+
+const pendingApprovals = new Map();
+
+function approvalKey(conversationId: string | undefined, toolCallId: string): string {
+ return `${conversationId || "default"}::${toolCallId}`;
+}
+
+function cleanupExpired() {
+ const now = Date.now();
+ for (const [k, v] of pendingApprovals) {
+ if (v.expiresAt < now) pendingApprovals.delete(k);
+ }
+}
+
+export function storeApproval(conversationId: string | undefined, toolCallId: string, data: Omit) {
+ cleanupExpired();
+ pendingApprovals.set(approvalKey(conversationId, toolCallId), {
+ ...data,
+ expiresAt: Date.now() + TTL_MS,
+ });
+}
+
+export function takeApproval(conversationId: string | undefined, toolCallId: string): PendingApproval | null {
+ const k = approvalKey(conversationId, toolCallId);
+ const v = pendingApprovals.get(k);
+ if (!v) return null;
+ if (v.expiresAt < Date.now()) {
+ pendingApprovals.delete(k);
+ return null;
+ }
+ pendingApprovals.delete(k);
+ return v;
+}
diff --git a/src/server/store.ts b/src/server/store.ts
new file mode 100644
index 0000000..1b0e63e
--- /dev/null
+++ b/src/server/store.ts
@@ -0,0 +1,45 @@
+import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, readdirSync } from "fs";
+import { resolve } from "path";
+
+// ---------- 会话记忆(本地 JSON 文件,按会话 id 持久化) ----------
+const CONVERSATIONS_DIR = resolve("conversations");
+
+function convFile(id: string): string {
+ const safe = String(id).replace(/[^a-zA-Z0-9_-]/g, "");
+ return resolve(CONVERSATIONS_DIR, safe + ".json");
+}
+
+function genConvId(): string {
+ return "c_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
+}
+
+function readConv(id: string): any {
+ try { return JSON.parse(readFileSync(convFile(id), "utf-8")); } catch { return null; }
+}
+
+function writeConv(id: string, data: any) {
+ mkdirSync(CONVERSATIONS_DIR, { recursive: true });
+ writeFileSync(convFile(id), JSON.stringify(data, null, 2), "utf-8");
+}
+
+function listConvs(): { id: string; title: string; updatedAt: number; count: number }[] {
+ try {
+ if (!existsSync(CONVERSATIONS_DIR)) return [];
+ return readdirSync(CONVERSATIONS_DIR)
+ .filter((f) => f.endsWith(".json"))
+ .map((f) => {
+ try {
+ const d = JSON.parse(readFileSync(resolve(CONVERSATIONS_DIR, f), "utf-8"));
+ return { id: d.id || f.replace(".json", ""), title: d.title || "对话", updatedAt: d.updatedAt || 0, count: (d.messages || []).length };
+ } catch { return null; }
+ })
+ .filter(Boolean)
+ .sort((a: any, b: any) => b.updatedAt - a.updatedAt);
+ } catch { return []; }
+}
+
+function deleteConv(id: string) {
+ try { unlinkSync(convFile(id)); } catch {}
+}
+
+export const conversationStore = { convFile, genConvId, readConv, writeConv, listConvs, deleteConv };
diff --git a/src/server/tools.ts b/src/server/tools.ts
new file mode 100644
index 0000000..aef1cb2
--- /dev/null
+++ b/src/server/tools.ts
@@ -0,0 +1,366 @@
+import { readFile, writeFile, readdir, rename, unlink, mkdir } from "fs/promises";
+import { writeFileSync, unlinkSync, mkdirSync } from "fs";
+import { resolve, normalize, dirname } from "path";
+import { spawn } from "child_process";
+import { read as xlsxRead, utils } from "xlsx";
+import { config } from "./config";
+import { DuckDBAdapter, csvCell } from "./duckdb";
+import { readExcel, writeExcel } from "./excel";
+
+// ---------- 工具注册表(A2:定义 + 执行 + 元数据合并为一处注册) ----------
+export interface ToolContext {
+ workspace: string;
+ duckdb: DuckDBAdapter;
+ signal?: AbortSignal;
+}
+
+export interface ToolExecutor {
+ name: string;
+ description: string;
+ parameters: any;
+ approvalNote?: string;
+ meta?: { label: string; icon: string; category: string };
+ execute: (args: any, ctx: ToolContext, opts?: ToolExecOpts) => Promise<{ ok: boolean; result: any }>;
+}
+
+export interface ToolExecOpts {
+ timeout?: number;
+}
+
+const registry = new Map();
+
+export function registerTool(t: ToolExecutor) {
+ registry.set(t.name, t);
+}
+
+export function getTool(name: string): ToolExecutor | undefined {
+ return registry.get(name);
+}
+
+// 供 LLM function calling 使用的 OpenAI 工具定义
+export function getToolDefinitions() {
+ return [...registry.values()].map((t) => ({
+ type: "function",
+ function: { name: t.name, description: t.description, parameters: t.parameters },
+ }));
+}
+
+// 供前端技能页 /api/tools 使用的元数据
+export function listToolsForApi() {
+ return [...registry.values()].map((t) => ({
+ name: t.name,
+ label: t.meta?.label || t.name,
+ icon: t.meta?.icon || "🛠️",
+ category: t.meta?.category || "其他",
+ description: t.description,
+ parameters: t.parameters,
+ approvalNote: t.approvalNote,
+ }));
+}
+
+export async function execTool(name: string, args: any, ctx: ToolContext, opts?: ToolExecOpts): Promise<{ ok: boolean; result: any }> {
+ const t = registry.get(name);
+ if (!t) return { ok: false, result: "未知工具: " + name };
+ try {
+ return await t.execute(args, ctx, opts);
+ } catch (e: any) {
+ return { ok: false, result: "执行失败: " + e.message };
+ }
+}
+
+// ---------- 命令执行:超时 + 输出上限 + 终止进程树(B1) ----------
+const COMMAND_TIMEOUT_MS = 120000;
+const COMMAND_MAX_OUTPUT = 512 * 1024;
+
+function killTree(proc: any) {
+ if (process.platform === "win32") {
+ try { spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]); } catch {}
+ } else {
+ try { proc.kill("SIGKILL"); } catch {}
+ }
+}
+
+function executeCommand(command: string, ctx: ToolContext, timeoutMs: number = COMMAND_TIMEOUT_MS): Promise<{ ok: boolean; result: string }> {
+ return new Promise((resolve) => {
+ const proc = spawn(String(command || ""), { cwd: ctx.workspace, shell: true, env: { ...process.env } });
+ let stdout = "", stderr = "", truncated = false;
+ let settled = false;
+
+ const push = (d: Buffer, target: "out" | "err") => {
+ let s = d.toString();
+ const cur = target === "out" ? stdout : stderr;
+ if (s.length + cur.length > COMMAND_MAX_OUTPUT) {
+ s = s.slice(0, Math.max(0, COMMAND_MAX_OUTPUT - cur.length));
+ truncated = true;
+ }
+ if (target === "out") stdout += s;
+ else stderr += s;
+ };
+
+ proc.stdout?.on("data", (d: Buffer) => push(d, "out"));
+ proc.stderr?.on("data", (d: Buffer) => push(d, "err"));
+
+ const cleanup = () => {
+ clearTimeout(timer);
+ ctx.signal?.removeEventListener("abort", onAbort);
+ };
+ const settle = (ok: boolean, result: string) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ resolve({ ok, result });
+ };
+ const onAbort = () => {
+ killTree(proc);
+ settle(false, "命令执行已取消");
+ };
+
+ const timer = setTimeout(() => {
+ killTree(proc);
+ settle(false, `命令执行超时(${Math.round(timeoutMs / 1000)}s),已终止${truncated ? "(输出已截断)" : ""}`);
+ }, timeoutMs);
+
+ if (ctx.signal) {
+ if (ctx.signal.aborted) { onAbort(); return; }
+ ctx.signal.addEventListener("abort", onAbort);
+ }
+
+ proc.on("close", (code: number) => {
+ const out = stdout || stderr;
+ settle(code === 0, out + (truncated ? "\n[输出已截断]" : ""));
+ });
+ proc.on("error", (e: Error) => settle(false, e.message));
+ });
+}
+
+// ---------- 工具定义与执行 ----------
+
+function param(name: string, desc: string, required = false) {
+ const p: any = { type: "string", description: desc };
+ return { name, desc, required, p };
+}
+
+function objParams(props: any[], required: string[] = []) {
+ const o: any = { type: "object", properties: {}, required: required.length ? required : undefined };
+ for (const p of props) o.properties[p.name] = p.p;
+ return o;
+}
+
+registerTool({
+ name: "read_file",
+ description: "读取文本文件内容(txt、js、md、log 等,上限 5 万字符)",
+ parameters: objParams([param("path", "文件路径")], ["path"]),
+ meta: { label: "读取文件", icon: "📄", category: "文件操作" },
+ execute: async (args, ctx) => {
+ const content = await readFile(resolve(ctx.workspace, normalize(args.path)), "utf-8");
+ return { ok: true, result: content.slice(0, 50000) };
+ },
+});
+
+registerTool({
+ name: "write_file",
+ description: "写入文本内容到文件(覆盖)",
+ parameters: objParams([param("path", "文件路径"), param("content", "写入内容,覆盖原文件")], ["path", "content"]),
+ meta: { label: "写入文件", icon: "✍️", category: "文件操作" },
+ execute: async (args, ctx) => {
+ const abs = resolve(ctx.workspace, normalize(args.path));
+ await mkdir(resolve(abs, ".."), { recursive: true }).catch(() => {});
+ await writeFile(abs, args.content ?? "", "utf-8");
+ return { ok: true, result: `已写入 ${args.path} (${((args.content ?? "").length)} 字符)` };
+ },
+});
+
+registerTool({
+ name: "list_directory",
+ description: "列出目录内容,返回 [{name, type}]",
+ parameters: objParams([param("path", "目录路径,默认当前工作区")]),
+ meta: { label: "列出目录", icon: "📂", category: "文件操作" },
+ execute: async (args, ctx) => {
+ const target = args.path ? normalize(args.path) : ".";
+ const entries = await readdir(resolve(ctx.workspace, target), { withFileTypes: true });
+ return {
+ ok: true,
+ result: entries.map((e) => ({ name: e.name, type: e.isDirectory() ? "dir" : "file" })),
+ };
+ },
+});
+
+registerTool({
+ name: "copy_file",
+ description: "复制文件到目标位置",
+ parameters: objParams([param("source", "源路径"), param("destination", "目标路径")], ["source", "destination"]),
+ meta: { label: "复制", icon: "📋", category: "文件操作" },
+ execute: async (args, ctx) => {
+ const src = resolve(ctx.workspace, normalize(args.source));
+ const dst = resolve(ctx.workspace, normalize(args.destination));
+ const content = await readFile(src);
+ await mkdir(resolve(dst, ".."), { recursive: true }).catch(() => {});
+ await writeFile(dst, content);
+ return { ok: true, result: `已复制 ${args.source} -> ${args.destination}` };
+ },
+});
+
+registerTool({
+ name: "move_file",
+ description: "移动/重命名文件或目录",
+ parameters: objParams([param("source", "源路径"), param("destination", "目标路径")], ["source", "destination"]),
+ meta: { label: "移动 / 重命名", icon: "📦", category: "文件操作" },
+ execute: async (args, ctx) => {
+ const src = resolve(ctx.workspace, normalize(args.source));
+ const dst = resolve(ctx.workspace, normalize(args.destination));
+ await mkdir(resolve(dst, ".."), { recursive: true }).catch(() => {});
+ await rename(src, dst);
+ return { ok: true, result: `已移动 ${args.source} -> ${args.destination}` };
+ },
+});
+
+registerTool({
+ name: "delete_file",
+ description: "删除文件或空目录(需审批)",
+ parameters: objParams([param("path", "文件路径")], ["path"]),
+ approvalNote: "需要审批",
+ meta: { label: "删除", icon: "🗑️", category: "文件操作" },
+ execute: async (args, ctx) => {
+ await unlink(resolve(ctx.workspace, normalize(args.path)));
+ return { ok: true, result: `已删除 ${args.path}` };
+ },
+});
+
+registerTool({
+ name: "execute_command",
+ description: "执行 Shell 命令(需审批,120 秒超时)",
+ parameters: objParams([param("command", "要执行的命令")], ["command"]),
+ approvalNote: "按授权级别",
+ meta: { label: "执行命令", icon: "⌨️", category: "命令执行" },
+ execute: async (args, ctx, opts) => executeCommand(args.command, ctx, opts?.timeout),
+});
+
+registerTool({
+ name: "read_excel",
+ description: "读取 Excel 返回 JSON(默认仅前 5 行,用于理解数据结构;数据分析请用 load_excel + query_duckdb)",
+ parameters: objParams([
+ param("path", "文件路径"),
+ param("sheet", "工作表名,默认第一个"),
+ { name: "limit", desc: "返回行数,默认 5", required: false, p: { type: "number", description: "返回行数,默认 5" } },
+ ], ["path"]),
+ meta: { label: "预览 Excel", icon: "🔎", category: "Excel / 数据" },
+ execute: async (args) => {
+ const r = await readExcel(args.path, args.sheet, args.limit);
+ return {
+ ok: true,
+ result: {
+ ...r,
+ note: "read_excel 仅用于理解数据结构;真实数据分析请用 load_excel + query_duckdb",
+ },
+ };
+ },
+});
+
+registerTool({
+ name: "write_excel",
+ description: "将 JSON 数组写入 Excel(每个对象的键为列名)",
+ parameters: objParams([
+ param("path", "输出文件路径"),
+ { name: "data", desc: "JSON 对象数组,每项一行", required: true, p: { type: "array", description: "JSON 对象数组" } },
+ param("sheet", "工作表名,默认 Sheet1"),
+ ], ["path", "data"]),
+ meta: { label: "写出 Excel", icon: "📈", category: "Excel / 数据" },
+ execute: async (args) => {
+ const r = await writeExcel(args.path, args.data, args.sheet);
+ return { ok: true, result: r };
+ },
+});
+
+registerTool({
+ name: "load_excel",
+ description: "将 Excel 加载进 DuckDB 表以便用 SQL 分析数据。返回表名、列名与类型、前 5 行预览(仅用于理解格式);加载完成后用 query_duckdb 对该表执行任意 SQL(筛选/聚合/排序/连接等)。",
+ parameters: objParams([
+ param("path", "Excel 文件路径"),
+ param("sheet", "工作表名,默认第一个"),
+ param("table", "目标表名,默认 excel_data"),
+ ], ["path"]),
+ meta: { label: "载入 DuckDB", icon: "🗃️", category: "Excel / 数据" },
+ execute: async (args, ctx) => {
+ const buf = await readFile(resolve(ctx.workspace, normalize(args.path)));
+ const wb = xlsxRead(buf, { type: "buffer" });
+ const sheetName = args.sheet || wb.SheetNames[0];
+ const ws = wb.Sheets[sheetName];
+ if (!ws) throw new Error("工作表不存在: " + sheetName);
+ const rows: any[] = utils.sheet_to_json(ws, { defval: null });
+ const table = args.table || "excel_data";
+ if (!rows.length) return { ok: true, result: `工作表 "${sheetName}" 无数据行` };
+
+ const rawCols = Object.keys(rows[0]);
+ const seen = new Set();
+ const cols = rawCols.map((c, i) => {
+ let name = String(c).trim() === "" ? `col_${i}` : c;
+ let n = name, k = 1;
+ while (seen.has(n)) n = `${name}_${k++}`;
+ seen.add(n);
+ return n;
+ });
+ // 类型推断:整列均为数字 → DOUBLE,否则 VARCHAR
+ const types: Record = {};
+ for (const c of cols) {
+ let allNum = true, hasVal = false;
+ for (const r of rows) {
+ const v = r[c];
+ if (v === null || v === undefined || v === "") continue;
+ hasVal = true;
+ if (typeof v !== "number") { allNum = false; break; }
+ }
+ types[c] = hasVal && allNum ? "DOUBLE" : "VARCHAR";
+ }
+ const colDefs = cols.map((c) => `"${c}" ${types[c]}`).join(", ");
+
+ // 超过上限时截断加载,并明确警告,避免 AI 基于不完整数据下结论
+ const MAX = 50000;
+ const slice = rows.slice(0, MAX);
+ const truncated = rows.length > MAX;
+
+ const csvPath = resolve(ctx.duckdb.dbFilePath, "..", `load_${Date.now()}.csv`);
+ mkdirSync(dirname(csvPath), { recursive: true });
+ const csvLines = [cols.join(",")];
+ for (const r of slice) csvLines.push(cols.map((c) => csvCell(r[c])).join(","));
+ writeFileSync(csvPath, csvLines.join("\n"), "utf-8");
+ const csvSql = csvPath.replace(/\\/g, "/");
+ try {
+ await ctx.duckdb.exec(
+ `CREATE OR REPLACE TABLE "${table}" (${colDefs}); COPY "${table}" FROM '${csvSql}' (FORMAT csv, HEADER true, DELIMITER ',');`
+ );
+ } finally {
+ try { unlinkSync(csvPath); } catch {}
+ }
+
+ return {
+ ok: true,
+ result: {
+ table,
+ sheet: sheetName,
+ rowCount: rows.length,
+ truncated,
+ columns: cols.map((c) => ({ name: c, type: types[c] })),
+ preview: slice.slice(0, 5),
+ note: `已加载为 DuckDB 表 "${table}"。以上仅展示前 5 行用于理解数据结构(列名/类型),真实数据分析请用 query_duckdb 对该表执行 SQL(列名用双引号引用,例如 SELECT * FROM "${table}" LIMIT 10)` +
+ (truncated ? `;注意:行数 ${rows.length} 超过 ${MAX},已截断加载,分析结论可能不完整` : ""),
+ },
+ };
+ },
+});
+
+registerTool({
+ name: "query_duckdb",
+ description: "在已加载的 DuckDB 表上执行 SQL 查询(SELECT 等)。列名用双引号引用,例如 SELECT \"姓名\", SUM(\"金额\") FROM excel_data GROUP BY \"姓名\"。",
+ parameters: objParams([param("sql", "要执行的 SQL 语句")], ["sql"]),
+ meta: { label: "SQL 查询", icon: "🧮", category: "Excel / 数据" },
+ execute: async (args, ctx) => {
+ const sql = String(args.sql || "").trim();
+ if (!sql) throw new Error("缺少 SQL 语句");
+ const rows = await ctx.duckdb.query(sql, { signal: ctx.signal });
+ const cols = rows.length ? Object.keys(rows[0]) : [];
+ return {
+ ok: true,
+ result: { rowCount: rows.length, columns: cols, data: rows.slice(0, 100) },
+ };
+ },
+});
diff --git a/src/server/types.ts b/src/server/types.ts
new file mode 100644
index 0000000..ec60221
--- /dev/null
+++ b/src/server/types.ts
@@ -0,0 +1,7 @@
+export interface ChatMsg {
+ role: string;
+ content: any;
+ tool_calls?: any[];
+ name?: string;
+ tool_call_id?: string;
+}
diff --git a/system.md b/system.md
index 1b976d5..3b458a6 100644
--- a/system.md
+++ b/system.md
@@ -58,8 +58,8 @@
### 10. load_excel — 把 Excel 加载进 DuckDB(数据分析第一步)
- 参数:`path`(必填,Excel 文件路径)、`sheet`(可选,工作表名,默认第一个)、`table`(可选,目标表名,默认 `excel_data`)
-- 用途:把 Excel 导入 DuckDB 内存表,返回表名、列名与类型、前几行预览。
-- 之后即可用 `query_duckdb` 对这张表执行任意 SQL。
+- 用途:把 Excel 导入 DuckDB 表,返回表名、列名与类型、**前 5 行预览**。预览仅供理解数据结构(列名/类型/样例),**真实数据分析一律用 `query_duckdb` 对该表执行 SQL**。
+- 注意:行数超过 5 万时会截断加载并在结果中明确警告,此时分析结论可能不完整。
- 示例:`{"path": "D:/workbench/上海付迅...对账单.xlsx", "table": "bill"}`
### 11. query_duckdb — 在 DuckDB 上执行 SQL(数据分析核心)
diff --git a/vite.config.ts b/vite.config.ts
index 6e808c9..9fc30b6 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,20 +1,25 @@
-import { defineConfig } from "vite";
+import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
-export default defineConfig({
- plugins: [vue()],
- server: {
- host: true,
- port: 5173,
- proxy: {
- "/api": {
- target: "http://127.0.0.1:3000",
- changeOrigin: true,
+// 代理目标端口与后端一致(读取 .env 的 PORT,默认 3000)
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), "");
+ const port = parseInt(env.PORT || "3000", 10);
+ return {
+ plugins: [vue()],
+ server: {
+ host: true,
+ port: 5173,
+ proxy: {
+ "/api": {
+ target: `http://127.0.0.1:${port}`,
+ changeOrigin: true,
+ },
},
},
- },
- build: {
- outDir: "dist",
- base: "./",
- },
+ build: {
+ outDir: "dist",
+ base: "./",
+ },
+ };
});
\ No newline at end of file