import { createServer } from "http"; import { readFileSync, existsSync, writeFileSync, statSync } from "fs"; import { appendFile, mkdir, readFile } from "fs/promises"; // ---------- 配置加载 ---------- 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 修改并持久化) function parseList(v: string | undefined): string[] { return (v || "").split(",").map((s) => s.trim()).filter(Boolean); } const DEFAULT_WHITELIST = ["d:\\"]; 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"), WHITELIST: parseList(process.env.WHITELIST || ENV.WHITELIST).length ? parseList(process.env.WHITELIST || ENV.WHITELIST) : DEFAULT_WHITELIST, BLACKLIST: parseList(process.env.BLACKLIST || ENV.BLACKLIST), }; // 持久化配置到 .env(WORKSPACE 为内部默认值,不在设置页暴露) const ENV_KEYS = ["BASE_URL", "API_KEY", "MODEL", "WORKSPACE", "CMD_APPROVE_LEVEL", "PORT", "LOG_LEVEL", "TEMPERATURE", "WHITELIST", "BLACKLIST"] 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 s === "string" ? s : 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}/chat-${ts.slice(0, 10)}.log`, line, "utf-8"); } catch { /* 忽略日志写入失败 */ } } // 可安全序列化 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 } from "path"; import { read as xlsxRead, write as xlsxWrite, utils } from "xlsx"; import duckdb from "duckdb"; // ---------- 路径安全(白名单 + 黑名单,黑名单优先) ---------- // 判断 target 是否等于 base 或位于 base 的子目录下(跨平台) function isWithin(base: string, target: string): boolean { const b = normalize(base).replace(/\\/g, "/").replace(/\/+$/, ""); const t = normalize(target).replace(/\\/g, "/").replace(/\/+$/, ""); return t === b || t.startsWith(b + "/"); } // 白名单:允许访问的根目录(默认包含 D 盘根目录);工作区始终隐式允许 // 黑名单:禁止访问(优先于白名单);命中黑名单或其子目录则拒绝 function isAllowedPath(p: string): boolean { const abs = resolve(config.WORKSPACE, normalize(p)); for (const b of config.BLACKLIST) { if (isWithin(b, abs)) return false; } const allowed = [...config.WHITELIST, config.WORKSPACE]; for (const w of allowed) { if (isWithin(w, abs)) return true; } return false; } // ---------- 危险命令检测 ---------- 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(Excel 数据处理) ---------- let duckDb: any = null; function getDuck(): Promise { return new Promise((resolve, reject) => { if (duckDb) return resolve(duckDb); duckDb = new duckdb.Database(":memory:", (err: any) => { if (err) return reject(err); resolve(duckDb); }); }); } function duckExec(db: any, sql: string): Promise { return new Promise((resolve, reject) => db.exec(sql, (e: any) => (e ? reject(e) : resolve()))); } function duckAll(db: any, sql: string): Promise { return new Promise((resolve, reject) => db.all(sql, (e: any, rows: any) => (e ? reject(e) : resolve(rows || [])))); } function sqlVal(v: any): string { if (v === null || v === undefined || v === "") return "NULL"; if (typeof v === "number") return Number.isFinite(v) ? String(v) : "NULL"; if (typeof v === "boolean") return v ? "TRUE" : "FALSE"; if (v instanceof Date) return `'${v.toISOString().replace(/'/g, "''")}'`; return `'${String(v).replace(/'/g, "''")}'`; } function safeCol(c: string): string { const s = String(c).trim() || "col"; return s.replace(/[^\w一-龥]/g, "_"); } // ---------- 工具执行 ---------- 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 || 200; 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"; const db = await getDuck(); 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(", "); await duckExec(db, `CREATE OR REPLACE TABLE "${table}" (${colDefs})`); const MAX = 50000; const slice = rows.slice(0, MAX); const values = slice.map((r) => `(${cols.map((c) => sqlVal(r[c])).join(", ")})`).join(", "); if (values) await duckExec(db, `INSERT INTO "${table}" VALUES ${values}`); 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 语句"); const db = await getDuck(); try { const rows = await duckAll(db, 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(默认前 200 行)", 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 = ""; let toolAcc: any = null; 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) { if (!toolAcc) toolAcc = { id: "", type: "function", function: { name: "", arguments: "" } }; if (tc.id) toolAcc.id = tc.id; if (tc.function?.name) toolAcc.function.name = tc.function.name; if (tc.function?.arguments) toolAcc.function.arguments += tc.function.arguments; } } } } if (toolAcc) { if (!toolAcc.id) toolAcc.id = "call_" + Date.now() + "_" + Math.random().toString(36).slice(2, 7); yield { type: "toolcall", toolcall: toolAcc }; } } // ---------- 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; // 系统提示:定义助手身份 const SYSTEM_PROMPT = "你是小鑫,一个专为冯雅鑫定制的本地 AI 助手,可以帮助处理文件读写、Excel、列出目录、执行命令(需审批)等任务。分析 Excel 数据时,优先用 load_excel 把文件加载进 DuckDB,再用 query_duckdb 执行 SQL 完成筛选、聚合、统计、排序等;处理大量数据时不要逐行读取。请用简体中文友好、简洁地回答。"; function withSystem(msgs: ChatMsg[]): ChatMsg[] { if (msgs.length && msgs[0].role === "system") return msgs; return [{ role: "system", content: SYSTEM_PROMPT }, ...msgs]; } async function runConversation(initialMessages: ChatMsg[], res: any, fromApproval = false) { sseInit(res); let messages = withSystem(initialMessages); // 日志:记录用户/工具输入 log("debug", "conversation_start", { fromApproval, messages: messages.map((m) => ({ role: m.role, content: clip(m.content, 800) })), }); // 工具循环(最多 5 轮避免死循环) for (let round = 0; round < 5; round++) { const gen = streamLLM(messages); let content = ""; let toolCall: any = null; let errored = false; 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") { toolCall = ev.toolcall; } else if (ev.type === "error") { errored = true; log("error", "llm_error", ev.content); sseSend(res, { type: "token", content: "\n\n⚠️ " + ev.content }); } } if (errored) { sseSend(res, { type: "done" }); res.end(); return; } // 需要工具调用 if (toolCall && toolCall.function?.name) { const name = toolCall.function.name; let args = {}; try { args = toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {}; } catch { args = {}; } if (needsApproval(name, args)) { // 请求审批,保存上下文等待 /api/approve log("info", "approval_required", { tool: name, args }); pendingApproval = { messages: [ ...messages, { role: "assistant", content: content || null, tool_calls: [toolCall] }, ], toolCall, }; sseSend(res, { type: "approval_required", tool_call_id: toolCall.id, tool: name, args, command: name === "execute_command" ? args.command : `${name}(${JSON.stringify(args)})`, }); sseSend(res, { type: "done" }); res.end(); return; } // 直接执行 log("debug", "tool_call", { name, args }); const exec = await execTool(name, args); log("debug", "tool_result", { name, ok: exec.ok, result: clip(exec.result, 800) }); messages = [ ...messages, { role: "assistant", content: content || null, tool_calls: [toolCall] }, { role: "tool", name, content: safeStringify(exec), tool_call_id: toolCall.id }, ]; // 继续循环,让模型基于工具结果生成最终回复 continue; } // 无工具调用,正常结束 log("debug", "assistant", clip(content, 2000)); sseSend(res, { type: "done" }); res.end(); return; } 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; } // ---------- 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, WHITELIST: config.WHITELIST, BLACKLIST: config.BLACKLIST, })); 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") config.BASE_URL = payload.BASE_URL; if (typeof payload.API_KEY === "string" && !payload.API_KEY.startsWith("******")) config.API_KEY = payload.API_KEY; if (typeof payload.MODEL === "string") config.MODEL = payload.MODEL; 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 (payload.WHITELIST !== undefined) { const wl = Array.isArray(payload.WHITELIST) ? payload.WHITELIST : String(payload.WHITELIST).split(","); config.WHITELIST = wl.map((s: string) => String(s).trim()).filter(Boolean); } if (payload.BLACKLIST !== undefined) { const bl = Array.isArray(payload.BLACKLIST) ? payload.BLACKLIST : String(payload.BLACKLIST).split(","); config.BLACKLIST = bl.map((s: string) => String(s).trim()).filter(Boolean); } saveEnv(); log("info", "settings_updated", { CMD_APPROVE_LEVEL: config.CMD_APPROVE_LEVEL, MODEL: config.MODEL, LOG_LEVEL: config.LOG_LEVEL, WHITELIST: config.WHITELIST, BLACKLIST: config.BLACKLIST }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); 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 } = pendingApproval; pendingApproval = null; const name = toolCall.function.name; let args = {}; try { args = toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {}; } catch {} let exec; if (approved) { log("info", "approval_granted", { tool: name, args }); exec = await execTool(name, args); } else { log("info", "approval_denied", { tool: name, 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/`); });