import { createServer } from "http"; import { readFileSync, existsSync, writeFileSync, statSync, mkdirSync, readdirSync, unlinkSync } 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 修改并持久化) 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 || 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"; 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(默认前 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 = ""; // 支持一次响应返回多个工具调用:按 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) { const id = tc.id || `call_anon_${anonIdx++}`; if (!toolCalls.has(id)) toolCalls.set(id, { id, type: "function", function: { name: "", arguments: "" } }); const acc = toolCalls.get(id); 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; // 系统提示:定义助手身份 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]; } // ---------- 工具参数容错解析与路径自动补全 ---------- // 解析模型返回的工具参数;非法 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: "" }; // 工具循环(最多 10 轮避免死循环) for (let round = 0; round < 10; 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", "对话结束", "达到最大工具调用轮数(10),强制结束"); 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/`); });