694 lines
30 KiB
TypeScript
694 lines
30 KiB
TypeScript
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),
|
||
};
|
||
|
||
// 持久化配置到 .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<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));
|
||
}
|
||
|
||
// ---------- DuckDB(Excel 数据处理) ----------
|
||
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/`);
|
||
});
|