miniai/server.ts

694 lines
30 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createServer } from "http";
import { readFileSync, existsSync, writeFileSync, statSync } from "fs";
import { appendFile, mkdir, readFile } from "fs/promises";
// ---------- 配置加载 ----------
function loadEnv() {
const env: Record<string, string> = {};
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),
};
// 持久化配置到 .envWORKSPACE 为内部默认值,不在设置页暴露)
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<string, number> = { 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));
}
// ---------- DuckDBExcel 数据处理) ----------
let duckDb: any = null;
function getDuck(): Promise<any> {
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<void> {
return new Promise((resolve, reject) => db.exec(sql, (e: any) => (e ? reject(e) : resolve())));
}
function duckAll(db: any, sql: string): Promise<any[]> {
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<string>();
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<string, string> = {};
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<string, string> = {
".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<boolean> {
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/`);
});