diff --git a/.env b/.env new file mode 100644 index 0000000..6268745 --- /dev/null +++ b/.env @@ -0,0 +1,11 @@ +BASE_URL=https://ark.cn-beijing.volces.com/api/coding/v3 +API_KEY=ark-ef9b970f-5301-4691-a95e-3f01597ca3f2-b430e +MODEL=deepseek-v4-flash +WORKSPACE=D:\workbench\miniai +CMD_APPROVE_LEVEL=medium +PORT=3100 +LOG_LEVEL=debug +TEMPERATURE=0.3 +DUCKDB_EXE=D:\workbench\miniai\bin\duckdb.exe +DUCKDB_FILE=D:\workbench\miniai\data\duck.db +REMOTE_LOG_URL=http://127.0.0.1:3200/log \ No newline at end of file diff --git a/index.html b/index.html index 50fd7e8..fd2d96c 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Mini AI Assistant + 小鑫 AI 助手
diff --git a/server.ts b/server.ts index 0faf8f1..4aec0fc 100644 --- a/server.ts +++ b/server.ts @@ -1,940 +1,3 @@ -import { createServer } from "http"; -import { readFileSync, existsSync, writeFileSync, statSync, mkdirSync, readdirSync, unlinkSync } from "fs"; -import { appendFile, mkdir, readFile } from "fs/promises"; +import { startServer } from "./src/server/app"; -// ---------- 配置加载 ---------- -function loadEnv() { - 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 修改并持久化) -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; -function saveEnv() { - const lines = ENV_KEYS.map((k) => `${k}=${String((config as any)[k])}`); - const header = "# Mini AI Assistant 配置(可由设置页面修改)\n"; - try { - writeFileSync(".env", header + lines.join("\n") + "\n", "utf-8"); - } catch (e) { - console.error("保存配置失败:", (e as any).message); - } -} - -// ---------- 日志系统 ---------- -const LOG_DIR = "logs"; -const LEVELS: Record = { debug: 0, info: 1, warn: 2, error: 3 }; -function shouldLog(level: string): boolean { - const lv = LEVELS[level] ?? 1; - return lv >= (LEVELS[config.LOG_LEVEL] ?? 0); -} -async 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`; - try { - if (!existsSync(LOG_DIR)) await mkdir(LOG_DIR, { recursive: true }); - await appendFile(`${LOG_DIR}/global.log`, line, "utf-8"); - } catch { - /* 忽略日志写入失败 */ - } - 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”日志使用) -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 等类型 -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]"; } - } -} -// 安全截断 -function clip(s: any, n = 600): string { - const str = typeof s === "string" ? s : safeStringify(s); - return str.length > n ? str.slice(0, n) + "…" : str; -} - -// ---------- 依赖 ---------- -import { readFile, writeFile, readdir, rename, unlink, stat, mkdir } from "fs/promises"; -import { resolve, normalize, extname, sep, dirname } from "path"; -import { read as xlsxRead, write as xlsxWrite, utils } from "xlsx"; -import { spawn } from "child_process"; - -// ---------- 路径访问(已移除目录权限限制,全部放行) ---------- -function isAllowedPath(_p: string): boolean { - return true; -} - -// ---------- 危险命令检测 ---------- -const DANGEROUS_PATTERNS = [ - "rm -rf /", "rd /s", "format", "mkfs", "shutdown", "reboot", "poweroff", - "dd if=", "fdisk", ":(){:|:&};:", "chmod -r", "del /s", "attrib", -]; - -function isDangerousCommand(cmd: string): boolean { - const lower = cmd.toLowerCase(); - return DANGEROUS_PATTERNS.some((p) => lower.includes(p)); -} - -// ---------- DuckDB(内置 duckdb.exe 子进程 + 本地数据库文件,无原生 .node 依赖) ---------- -function duckRun(sql: string): Promise { - return new Promise((resolve, reject) => { - mkdirSync(dirname(config.DUCKDB_FILE), { recursive: true }); - const child = spawn(config.DUCKDB_EXE, [config.DUCKDB_FILE, "-json"], { stdio: ["pipe", "pipe", "pipe"] }); - let out = "", err = ""; - child.stdout.on("data", (d) => (out += d.toString("utf8"))); - child.stderr.on("data", (d) => (err += d.toString("utf8"))); - child.on("error", (e) => reject(new Error("无法启动 duckdb: " + e.message))); - child.on("close", (code) => { - if (code === 0) resolve(out); - else reject(new Error(err.trim() || `duckdb 退出码 ${code}`)); - }); - child.stdin.write(sql, "utf8"); - child.stdin.end(); - }); -} -async function duckExec(sql: string): Promise { - await duckRun(sql); -} -async function duckAll(sql: string): Promise { - const out = await duckRun(sql); - const t = out.trim(); - if (!t) return []; - try { return JSON.parse(t); } catch { return []; } -} -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; -} - -// ---------- 工具执行 ---------- -async function execTool(name: string, args: any): Promise<{ ok: boolean; result: any }> { - try { - switch (name) { - case "read_file": { - if (!isAllowedPath(args.path)) throw new Error("路径越权"); - const content = await readFile(resolve(config.WORKSPACE, normalize(args.path)), "utf-8"); - return { ok: true, result: content.slice(0, 50000) }; - } - case "write_file": { - if (!isAllowedPath(args.path)) throw new Error("路径越权"); - const abs = resolve(config.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)} 字符)` }; - } - case "list_directory": { - const target = args.path ? normalize(args.path) : "."; - if (!isAllowedPath(target)) throw new Error("路径越权"); - const entries = await readdir(resolve(config.WORKSPACE, target), { withFileTypes: true }); - return { - ok: true, - result: entries.map((e) => ({ name: e.name, type: e.isDirectory() ? "dir" : "file" })), - }; - } - case "copy_file": { - if (!isAllowedPath(args.source) || !isAllowedPath(args.destination)) throw new Error("路径越权"); - const src = resolve(config.WORKSPACE, normalize(args.source)); - const dst = resolve(config.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}` }; - } - case "move_file": { - if (!isAllowedPath(args.source) || !isAllowedPath(args.destination)) throw new Error("路径越权"); - const src = resolve(config.WORKSPACE, normalize(args.source)); - const dst = resolve(config.WORKSPACE, normalize(args.destination)); - await mkdir(resolve(dst, ".."), { recursive: true }).catch(() => {}); - await rename(src, dst); - return { ok: true, result: `已移动 ${args.source} -> ${args.destination}` }; - } - case "delete_file": { - if (!isAllowedPath(args.path)) throw new Error("路径越权"); - await unlink(resolve(config.WORKSPACE, normalize(args.path))); - return { ok: true, result: `已删除 ${args.path}` }; - } - case "execute_command": { - const { spawn } = await import("child_process"); - const out = await new Promise<{ success: boolean; output: string; error: string }>((resolve) => { - const proc = spawn(args.command, { cwd: config.WORKSPACE, shell: true, env: { ...process.env } }); - let stdout = "", stderr = ""; - proc.stdout?.on("data", (d) => (stdout += d.toString())); - proc.stderr?.on("data", (d) => (stderr += d.toString())); - proc.on("close", (code) => resolve({ success: code === 0, output: stdout, error: stderr })); - proc.on("error", (e) => resolve({ success: false, output: "", error: e.message })); - }); - return { ok: out.success, result: out.output || out.error }; - } - case "read_excel": { - if (!isAllowedPath(args.path)) throw new Error("路径越权"); - const buf = await readFile(resolve(config.WORKSPACE, normalize(args.path))); - const wb = xlsxRead(buf, { type: "buffer" }); - const sheetName = args.sheet || wb.SheetNames[0]; - const ws = wb.Sheets[sheetName]; - const rows = utils.sheet_to_json(ws, { header: 1 }); - const limit = args.limit || 5; - return { - ok: true, - result: { totalRows: rows.length, columns: rows[0] || [], data: rows.slice(0, limit) }, - }; - } - case "write_excel": { - if (!isAllowedPath(args.path)) throw new Error("路径越权"); - const rows = (args.data || []).map((o: any) => Object.values(o)); - const headers = args.data?.length ? Object.keys(args.data[0]) : []; - const ws = utils.aoa_to_sheet([headers, ...rows]); - const wb = { Sheets: {}, SheetNames: [] } as any; - wb.SheetNames.push(args.sheet || "Sheet1"); - wb.Sheets[wb.SheetNames[0]] = ws; - await mkdir(resolve(config.WORKSPACE, normalize(args.path), ".."), { recursive: true }).catch(() => {}); - const out = xlsxWrite(wb, { type: "buffer", bookType: "xlsx" }); - writeFileSync(resolve(config.WORKSPACE, normalize(args.path)), out); - return { ok: true, result: `已写入 Excel: ${args.path} (${rows.length} 行)` }; - } - case "load_excel": { - if (!isAllowedPath(args.path)) throw new Error("路径越权"); - const buf = await readFile(resolve(config.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(", "); - const MAX = 50000; - const slice = rows.slice(0, MAX); - // 写 UTF-8 CSV → duckdb.exe COPY 导入 - const csvPath = resolve(config.DUCKDB_FILE, "..", `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 duckExec(`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: rows.length > MAX, - columns: cols.map((c) => ({ name: c, type: types[c] })), - preview: slice.slice(0, 3), - note: `已加载为 DuckDB 表 "${table}",可用 query_duckdb 执行 SQL;列名用双引号引用,例如 SELECT * FROM "${table}" LIMIT 10`, - }, - }; - } - case "query_duckdb": { - const sql = String(args.sql || "").trim(); - if (!sql) throw new Error("缺少 SQL 语句"); - try { - const rows = await duckAll(sql); - const cols = rows.length ? Object.keys(rows[0]) : []; - return { ok: true, result: { rowCount: rows.length, columns: cols, data: rows.slice(0, 200) } }; - } catch (e: any) { - return { ok: false, result: "SQL 执行失败: " + e.message }; - } - } - default: - return { ok: false, result: "未知工具: " + name }; - } - } catch (e: any) { - return { ok: false, result: "执行失败: " + e.message }; - } -} - -// 需要审批的工具:delete_file 始终需要;execute_command 依据授权级别 -const NEEDS_APPROVAL = new Set(["delete_file"]); - -// 命令授权级别:high=每个命令都审核, medium=仅危险命令审核, low=全部不审核(默认 medium) -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; -} - -// ---------- 工具定义 (OpenAI function calling) ---------- -const TOOL_DEFINITIONS = [ - { type: "function", function: { name: "read_file", description: "读取文本文件内容", parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } } }, - { type: "function", function: { name: "write_file", description: "写入文本内容到文件(覆盖)", parameters: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } } }, - { type: "function", function: { name: "list_directory", description: "列出目录内容", parameters: { type: "object", properties: { path: { type: "string" } } } } }, - { type: "function", function: { name: "copy_file", description: "复制文件或目录", parameters: { type: "object", properties: { source: { type: "string" }, destination: { type: "string" } }, required: ["source", "destination"] } } }, - { type: "function", function: { name: "move_file", description: "移动/重命名文件或目录", parameters: { type: "object", properties: { source: { type: "string" }, destination: { type: "string" } }, required: ["source", "destination"] } } }, - { type: "function", function: { name: "delete_file", description: "删除文件或空目录(需审批)", parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } } }, - { type: "function", function: { name: "execute_command", description: "执行 Shell 命令(需审批)", parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } } }, - { type: "function", function: { name: "read_excel", description: "读取 Excel 返回 JSON(默认前 5 行,仅用于理解数据结构;数据分析请用 load_excel + query_duckdb)", parameters: { type: "object", properties: { path: { type: "string" }, sheet: { type: "string" }, limit: { type: "number" } }, required: ["path"] } } }, - { type: "function", function: { name: "write_excel", description: "将 JSON 数组写入 Excel", parameters: { type: "object", properties: { path: { type: "string" }, data: { type: "array" }, sheet: { type: "string" } }, required: ["path", "data"] } } }, - { type: "function", function: { name: "load_excel", description: "将 Excel 文件加载进 DuckDB 内存表以便用 SQL 分析数据处理。返回表名、列名与类型、前几行预览。加载后即可用 query_duckdb 执行 SQL(筛选/聚合/排序/连接等)。", parameters: { type: "object", properties: { path: { type: "string", description: "Excel 文件路径" }, sheet: { type: "string", description: "工作表名,默认第一个" }, table: { type: "string", description: "目标表名,默认 excel_data" } }, required: ["path"] } } }, - { type: "function", function: { name: "query_duckdb", description: "在已加载的 DuckDB 表上执行 SQL 查询(SELECT 等)。列名用双引号引用,例如 SELECT \"姓名\", SUM(\"金额\") FROM excel_data GROUP BY \"姓名\"。", parameters: { type: "object", properties: { sql: { type: "string", description: "要执行的 SQL 语句" } }, required: ["sql"] } } }, -]; - -// ---------- LLM 调用 (流式) ---------- -interface ChatMsg { role: string; content: any; tool_calls?: any[]; name?: string; } - -async function* streamLLM(messages: ChatMsg[]): 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: TOOL_DEFINITIONS, - tool_choice: "auto", - stream: true, - temperature: config.TEMPERATURE, - }; - if (config.MODEL) body.model = config.MODEL; - - let resp: any; - try { - resp = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.API_KEY}` }, - body: JSON.stringify(body), - }); - } catch (e: any) { - yield { type: "error", content: "无法连接模型服务: " + e.message }; - return; - } - if (!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 = ""; - // 支持一次响应返回多个工具调用:按 id 聚合,各自独立 - const toolCalls = new Map(); - let anonIdx = 0; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { 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; - } - } - } - } - 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 }; - } -} - -// ---------- 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) { - res.write(`data: ${JSON.stringify(obj)}\n\n`); -} - -// ---------- 对话处理(带工具循环) ---------- -let pendingApproval: { messages: ChatMsg[]; toolCall: any } | null = null; - -// 系统提示:优先读取项目根目录 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(); - } -} - -async function runConversation(initialMessages: ChatMsg[], res: any, fromApproval = false) { - sseInit(res); - let messages = withSystem(initialMessages); - - // 用户输入 - const userInput = initialMessages - .filter((m) => m.role === "user") - .map((m) => clip(m.content ?? "", 2000)) - .join(" | "); - log("info", "对话开始", fromApproval ? "审批后继续" : "新对话"); - if (userInput) log("info", "用户输入", userInput); - - // 本对话已知的最近文件/目录(供工具缺 path 时自动补全) - const ctx = { lastDir: "", lastFile: "" }; - - // 工具循环(最多 20 轮避免死循环) - for (let round = 0; round < 20; round++) { - // 发送给 AI 的请求 - log("info", "发送给AI", `第${round + 1}轮 model=${config.MODEL} temperature=${config.TEMPERATURE}\n${fmtMessages(messages)}`); - const gen = streamLLM(messages); - let content = ""; - let errored = false; - let llmErr = ""; - const toolCalls: any[] = []; - for await (const ev of gen) { - 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 (errored) { - log("info", "返回界面", "⚠️ " + clip(llmErr, 1000)); - sseSend(res, { type: "done" }); - res.end(); - 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) { - 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, ctx); - if (filled.note) { - args = filled.args; - log("info", "路径补全", filled.note); - } else { - args = filled.args; - } - - if (needsApproval(name, args)) { - // 请求审批,保存上下文等待 /api/approve(之前已执行的结果一并保留) - log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(等待用户审批,其余 ${calls.length - 1} 个调用一并暂停)`); - pendingApproval = { - 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)})`, - }); - sseSend(res, { type: "done" }); - res.end(); - return; - } - - // 执行命令/工具 - log("info", "执行命令", `${name} 参数 ${safeStringify(args)}`); - const exec = await execTool(name, args); - // 执行结果 - log("info", "执行结果", `ok=${exec.ok} ${clip(exec.result, 1000)}`); - // 从结果中学习已知路径(供后续工具缺 path 时补全) - absorbToolContext(name, args, exec, ctx); - toolResults.push({ role: "tool", name, content: safeStringify(exec), tool_call_id: tc.id }); - } - - 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)); - sseSend(res, { type: "done" }); - res.end(); - return; - } - - log("warn", "对话结束", "达到最大工具调用轮数(20),强制结束"); - sseSend(res, { type: "token", content: "\n[达到最大工具调用轮数]" }); - sseSend(res, { type: "done" }); - res.end(); -} - -// ---------- 静态文件服务(生产构建) ---------- -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; -} - -// ---------- 会话记忆(本地 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 []; } -} - -// ---------- HTTP 服务 ---------- -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; - } - - // 保存设置 - if (req.method === "POST" && url.pathname === "/api/settings") { - let body = ""; - for await (const c of req) body += c; - let payload: any; - try { payload = JSON.parse(body); } 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; - } - - // 会话列表 - if (req.method === "GET" && url.pathname === "/api/conversations") { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify(listConvs())); - return; - } - // 新建会话 - if (req.method === "POST" && url.pathname === "/api/conversations") { - const conv = { id: genConvId(), title: "新对话", messages: [], updatedAt: Date.now() }; - 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 = 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") { - try { unlinkSync(convFile(cid)); } catch {} - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ ok: true })); - return; - } - if (req.method === "POST") { - let body = ""; - for await (const c of req) body += c; - let payload: any; - try { payload = JSON.parse(body); } catch { res.writeHead(400); res.end("bad json"); return; } - const old = 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) : "新对话"; - 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 body = ""; - for await (const c of req) body += c; - let payload: any; - try { payload = JSON.parse(body); } 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); - return; - } - - // 审批反馈:流式 SSE(继续对话) - if (req.method === "POST" && url.pathname === "/api/approve") { - let body = ""; - for await (const c of req) body += c; - let payload: any; - try { payload = JSON.parse(body); } catch { res.writeHead(400); res.end("bad json"); return; } - const approved = !!payload.approved; - - if (!pendingApproval) { - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: "无待审批操作" })); - return; - } - const { messages, toolCall, args: pendingArgs } = pendingApproval; - pendingApproval = null; - const name = toolCall.function.name; - // 优先使用审批时的已补全参数;否则重新容错解析 - const parsed = parseToolArgs(toolCall.function.arguments); - const args = pendingArgs && typeof pendingArgs === "object" && !Array.isArray(pendingArgs) ? pendingArgs : parsed.args; - log("info", "审批执行", `${name} 参数 ${safeStringify(args)}${parsed.note ? `(${parsed.note})` : ""}`); - - let exec; - if (approved) { - log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(已批准)`); - exec = await execTool(name, args); - 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, true); - return; - } - - res.writeHead(404); - res.end("Not Found"); -}); - -server.listen(config.PORT, () => { - 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/`); -}); +startServer(); diff --git a/src/App.vue b/src/App.vue index 69404e7..f1cdebd 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,408 +1,98 @@ - - 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 @@ 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" ? "你" : "小鑫" }}
- +
@@ -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 @@ - - - - - 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 @@ + + + + + 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 @@ + + + + + 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