refactor: 后端拆分为 src/server 模块,前端迁移至路由分页(Chat/Skills);设置改为抽屉、新增工具卡片组件;提交测试用 .env
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
BASE_URL=https://ark.cn-beijing.volces.com/api/coding/v3
|
||||
API_KEY=ark-ef9b970f-5301-4691-a95e-3f01597ca3f2-b430e
|
||||
MODEL=deepseek-v4-flash
|
||||
WORKSPACE=D:\workbench\miniai
|
||||
CMD_APPROVE_LEVEL=medium
|
||||
PORT=3100
|
||||
LOG_LEVEL=debug
|
||||
TEMPERATURE=0.3
|
||||
DUCKDB_EXE=D:\workbench\miniai\bin\duckdb.exe
|
||||
DUCKDB_FILE=D:\workbench\miniai\data\duck.db
|
||||
REMOTE_LOG_URL=http://127.0.0.1:3200/log
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mini AI Assistant</title>
|
||||
<title>小鑫 AI 助手</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,940 +1,3 @@
|
||||
import { createServer } from "http";
|
||||
import { readFileSync, existsSync, writeFileSync, statSync, mkdirSync, readdirSync, unlinkSync } from "fs";
|
||||
import { appendFile, mkdir, readFile } from "fs/promises";
|
||||
import { startServer } from "./src/server/app";
|
||||
|
||||
// ---------- 配置加载 ----------
|
||||
function loadEnv() {
|
||||
const env: Record<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 修改并持久化)
|
||||
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<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 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<string> {
|
||||
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<void> {
|
||||
await duckRun(sql);
|
||||
}
|
||||
async function duckAll(sql: string): Promise<any[]> {
|
||||
const out = await duckRun(sql);
|
||||
const t = out.trim();
|
||||
if (!t) return [];
|
||||
try { return JSON.parse(t); } catch { return []; }
|
||||
}
|
||||
function csvCell(v: any): string {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (typeof v === "number") return Number.isFinite(v) ? String(v) : "";
|
||||
if (v instanceof Date) return '"' + v.toISOString().replace(/"/g, '""') + '"';
|
||||
const s = String(v);
|
||||
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||||
}
|
||||
|
||||
// ---------- 工具执行 ----------
|
||||
async function execTool(name: string, args: any): Promise<{ ok: boolean; result: any }> {
|
||||
try {
|
||||
switch (name) {
|
||||
case "read_file": {
|
||||
if (!isAllowedPath(args.path)) throw new Error("路径越权");
|
||||
const content = await readFile(resolve(config.WORKSPACE, normalize(args.path)), "utf-8");
|
||||
return { ok: true, result: content.slice(0, 50000) };
|
||||
}
|
||||
case "write_file": {
|
||||
if (!isAllowedPath(args.path)) throw new Error("路径越权");
|
||||
const abs = resolve(config.WORKSPACE, normalize(args.path));
|
||||
await mkdir(resolve(abs, ".."), { recursive: true }).catch(() => {});
|
||||
await writeFile(abs, args.content ?? "", "utf-8");
|
||||
return { ok: true, result: `已写入 ${args.path} (${((args.content ?? "").length)} 字符)` };
|
||||
}
|
||||
case "list_directory": {
|
||||
const target = args.path ? normalize(args.path) : ".";
|
||||
if (!isAllowedPath(target)) throw new Error("路径越权");
|
||||
const entries = await readdir(resolve(config.WORKSPACE, target), { withFileTypes: true });
|
||||
return {
|
||||
ok: true,
|
||||
result: entries.map((e) => ({ name: e.name, type: e.isDirectory() ? "dir" : "file" })),
|
||||
};
|
||||
}
|
||||
case "copy_file": {
|
||||
if (!isAllowedPath(args.source) || !isAllowedPath(args.destination)) throw new Error("路径越权");
|
||||
const src = resolve(config.WORKSPACE, normalize(args.source));
|
||||
const dst = resolve(config.WORKSPACE, normalize(args.destination));
|
||||
const content = await readFile(src);
|
||||
await mkdir(resolve(dst, ".."), { recursive: true }).catch(() => {});
|
||||
await writeFile(dst, content);
|
||||
return { ok: true, result: `已复制 ${args.source} -> ${args.destination}` };
|
||||
}
|
||||
case "move_file": {
|
||||
if (!isAllowedPath(args.source) || !isAllowedPath(args.destination)) throw new Error("路径越权");
|
||||
const src = resolve(config.WORKSPACE, normalize(args.source));
|
||||
const dst = resolve(config.WORKSPACE, normalize(args.destination));
|
||||
await mkdir(resolve(dst, ".."), { recursive: true }).catch(() => {});
|
||||
await rename(src, dst);
|
||||
return { ok: true, result: `已移动 ${args.source} -> ${args.destination}` };
|
||||
}
|
||||
case "delete_file": {
|
||||
if (!isAllowedPath(args.path)) throw new Error("路径越权");
|
||||
await unlink(resolve(config.WORKSPACE, normalize(args.path)));
|
||||
return { ok: true, result: `已删除 ${args.path}` };
|
||||
}
|
||||
case "execute_command": {
|
||||
const { spawn } = await import("child_process");
|
||||
const out = await new Promise<{ success: boolean; output: string; error: string }>((resolve) => {
|
||||
const proc = spawn(args.command, { cwd: config.WORKSPACE, shell: true, env: { ...process.env } });
|
||||
let stdout = "", stderr = "";
|
||||
proc.stdout?.on("data", (d) => (stdout += d.toString()));
|
||||
proc.stderr?.on("data", (d) => (stderr += d.toString()));
|
||||
proc.on("close", (code) => resolve({ success: code === 0, output: stdout, error: stderr }));
|
||||
proc.on("error", (e) => resolve({ success: false, output: "", error: e.message }));
|
||||
});
|
||||
return { ok: out.success, result: out.output || out.error };
|
||||
}
|
||||
case "read_excel": {
|
||||
if (!isAllowedPath(args.path)) throw new Error("路径越权");
|
||||
const buf = await readFile(resolve(config.WORKSPACE, normalize(args.path)));
|
||||
const wb = xlsxRead(buf, { type: "buffer" });
|
||||
const sheetName = args.sheet || wb.SheetNames[0];
|
||||
const ws = wb.Sheets[sheetName];
|
||||
const rows = utils.sheet_to_json(ws, { header: 1 });
|
||||
const limit = args.limit || 5;
|
||||
return {
|
||||
ok: true,
|
||||
result: { totalRows: rows.length, columns: rows[0] || [], data: rows.slice(0, limit) },
|
||||
};
|
||||
}
|
||||
case "write_excel": {
|
||||
if (!isAllowedPath(args.path)) throw new Error("路径越权");
|
||||
const rows = (args.data || []).map((o: any) => Object.values(o));
|
||||
const headers = args.data?.length ? Object.keys(args.data[0]) : [];
|
||||
const ws = utils.aoa_to_sheet([headers, ...rows]);
|
||||
const wb = { Sheets: {}, SheetNames: [] } as any;
|
||||
wb.SheetNames.push(args.sheet || "Sheet1");
|
||||
wb.Sheets[wb.SheetNames[0]] = ws;
|
||||
await mkdir(resolve(config.WORKSPACE, normalize(args.path), ".."), { recursive: true }).catch(() => {});
|
||||
const out = xlsxWrite(wb, { type: "buffer", bookType: "xlsx" });
|
||||
writeFileSync(resolve(config.WORKSPACE, normalize(args.path)), out);
|
||||
return { ok: true, result: `已写入 Excel: ${args.path} (${rows.length} 行)` };
|
||||
}
|
||||
case "load_excel": {
|
||||
if (!isAllowedPath(args.path)) throw new Error("路径越权");
|
||||
const buf = await readFile(resolve(config.WORKSPACE, normalize(args.path)));
|
||||
const wb = xlsxRead(buf, { type: "buffer" });
|
||||
const sheetName = args.sheet || wb.SheetNames[0];
|
||||
const ws = wb.Sheets[sheetName];
|
||||
if (!ws) throw new Error("工作表不存在: " + sheetName);
|
||||
const rows: any[] = utils.sheet_to_json(ws, { defval: null });
|
||||
const table = args.table || "excel_data";
|
||||
if (!rows.length) return { ok: true, result: `工作表 "${sheetName}" 无数据行` };
|
||||
const rawCols = Object.keys(rows[0]);
|
||||
const seen = new Set<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(", ");
|
||||
const MAX = 50000;
|
||||
const slice = rows.slice(0, MAX);
|
||||
// 写 UTF-8 CSV → duckdb.exe COPY 导入
|
||||
const csvPath = resolve(config.DUCKDB_FILE, "..", `load_${Date.now()}.csv`);
|
||||
mkdirSync(dirname(csvPath), { recursive: true });
|
||||
const csvLines = [cols.join(",")];
|
||||
for (const r of slice) csvLines.push(cols.map((c) => csvCell(r[c])).join(","));
|
||||
writeFileSync(csvPath, csvLines.join("\n"), "utf-8");
|
||||
const csvSql = csvPath.replace(/\\/g, "/");
|
||||
try {
|
||||
await duckExec(`CREATE OR REPLACE TABLE "${table}" (${colDefs}); COPY "${table}" FROM '${csvSql}' (FORMAT csv, HEADER true, DELIMITER ',');`);
|
||||
} finally {
|
||||
try { unlinkSync(csvPath); } catch {}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
table, sheet: sheetName, rowCount: rows.length, truncated: rows.length > MAX,
|
||||
columns: cols.map((c) => ({ name: c, type: types[c] })),
|
||||
preview: slice.slice(0, 3),
|
||||
note: `已加载为 DuckDB 表 "${table}",可用 query_duckdb 执行 SQL;列名用双引号引用,例如 SELECT * FROM "${table}" LIMIT 10`,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "query_duckdb": {
|
||||
const sql = String(args.sql || "").trim();
|
||||
if (!sql) throw new Error("缺少 SQL 语句");
|
||||
try {
|
||||
const rows = await duckAll(sql);
|
||||
const cols = rows.length ? Object.keys(rows[0]) : [];
|
||||
return { ok: true, result: { rowCount: rows.length, columns: cols, data: rows.slice(0, 200) } };
|
||||
} catch (e: any) {
|
||||
return { ok: false, result: "SQL 执行失败: " + e.message };
|
||||
}
|
||||
}
|
||||
default:
|
||||
return { ok: false, result: "未知工具: " + name };
|
||||
}
|
||||
} catch (e: any) {
|
||||
return { ok: false, result: "执行失败: " + e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// 需要审批的工具:delete_file 始终需要;execute_command 依据授权级别
|
||||
const NEEDS_APPROVAL = new Set(["delete_file"]);
|
||||
|
||||
// 命令授权级别:high=每个命令都审核, medium=仅危险命令审核, low=全部不审核(默认 medium)
|
||||
function needsApproval(name: string, args: any): boolean {
|
||||
if (NEEDS_APPROVAL.has(name)) return true;
|
||||
if (name === "execute_command") {
|
||||
const lv = config.CMD_APPROVE_LEVEL;
|
||||
if (lv === "high") return true;
|
||||
if (lv === "low") return false;
|
||||
return isDangerousCommand(String(args?.command || ""));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- 工具定义 (OpenAI function calling) ----------
|
||||
const TOOL_DEFINITIONS = [
|
||||
{ type: "function", function: { name: "read_file", description: "读取文本文件内容", parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } } },
|
||||
{ type: "function", function: { name: "write_file", description: "写入文本内容到文件(覆盖)", parameters: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } } },
|
||||
{ type: "function", function: { name: "list_directory", description: "列出目录内容", parameters: { type: "object", properties: { path: { type: "string" } } } } },
|
||||
{ type: "function", function: { name: "copy_file", description: "复制文件或目录", parameters: { type: "object", properties: { source: { type: "string" }, destination: { type: "string" } }, required: ["source", "destination"] } } },
|
||||
{ type: "function", function: { name: "move_file", description: "移动/重命名文件或目录", parameters: { type: "object", properties: { source: { type: "string" }, destination: { type: "string" } }, required: ["source", "destination"] } } },
|
||||
{ type: "function", function: { name: "delete_file", description: "删除文件或空目录(需审批)", parameters: { type: "object", properties: { path: { type: "string" } }, required: ["path"] } } },
|
||||
{ type: "function", function: { name: "execute_command", description: "执行 Shell 命令(需审批)", parameters: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } } },
|
||||
{ type: "function", function: { name: "read_excel", description: "读取 Excel 返回 JSON(默认前 5 行,仅用于理解数据结构;数据分析请用 load_excel + query_duckdb)", parameters: { type: "object", properties: { path: { type: "string" }, sheet: { type: "string" }, limit: { type: "number" } }, required: ["path"] } } },
|
||||
{ type: "function", function: { name: "write_excel", description: "将 JSON 数组写入 Excel", parameters: { type: "object", properties: { path: { type: "string" }, data: { type: "array" }, sheet: { type: "string" } }, required: ["path", "data"] } } },
|
||||
{ type: "function", function: { name: "load_excel", description: "将 Excel 文件加载进 DuckDB 内存表以便用 SQL 分析数据处理。返回表名、列名与类型、前几行预览。加载后即可用 query_duckdb 执行 SQL(筛选/聚合/排序/连接等)。", parameters: { type: "object", properties: { path: { type: "string", description: "Excel 文件路径" }, sheet: { type: "string", description: "工作表名,默认第一个" }, table: { type: "string", description: "目标表名,默认 excel_data" } }, required: ["path"] } } },
|
||||
{ type: "function", function: { name: "query_duckdb", description: "在已加载的 DuckDB 表上执行 SQL 查询(SELECT 等)。列名用双引号引用,例如 SELECT \"姓名\", SUM(\"金额\") FROM excel_data GROUP BY \"姓名\"。", parameters: { type: "object", properties: { sql: { type: "string", description: "要执行的 SQL 语句" } }, required: ["sql"] } } },
|
||||
];
|
||||
|
||||
// ---------- LLM 调用 (流式) ----------
|
||||
interface ChatMsg { role: string; content: any; tool_calls?: any[]; name?: string; }
|
||||
|
||||
async function* streamLLM(messages: ChatMsg[]): AsyncGenerator<{ type: "token" | "toolcall" | "error"; content?: string; toolcall?: any }> {
|
||||
const url = config.BASE_URL.replace(/\/$/, "") + "/chat/completions";
|
||||
const body: any = {
|
||||
messages: messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
||||
...(m.name ? { name: m.name } : {}),
|
||||
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
||||
})),
|
||||
tools: TOOL_DEFINITIONS,
|
||||
tool_choice: "auto",
|
||||
stream: true,
|
||||
temperature: config.TEMPERATURE,
|
||||
};
|
||||
if (config.MODEL) body.model = config.MODEL;
|
||||
|
||||
let resp: any;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.API_KEY}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (e: any) {
|
||||
yield { type: "error", content: "无法连接模型服务: " + e.message };
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text().catch(() => "");
|
||||
yield { type: "error", content: `模型返回错误 (${resp.status}): ${txt.slice(0, 300)}` };
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
// 支持一次响应返回多个工具调用:按 id 聚合,各自独立
|
||||
const toolCalls = new Map<string, any>();
|
||||
let anonIdx = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith("data:")) continue;
|
||||
const data = t.slice(5).trim();
|
||||
if (data === "[DONE]") continue;
|
||||
let json: any;
|
||||
try { json = JSON.parse(data); } catch { continue; }
|
||||
const delta = json.choices?.[0]?.delta || {};
|
||||
if (delta.content) yield { type: "token", content: delta.content };
|
||||
if (delta.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
// OpenAI 兼容流:首个分片带 id+name,延续分片 id 为 null 且只带 arguments,
|
||||
// 必须按 index 聚合,否则每个分片被拆成独立调用导致参数残缺/为空
|
||||
const key = (tc.index !== undefined ? String(tc.index) : tc.id) || `call_anon_${anonIdx++}`;
|
||||
if (!toolCalls.has(key)) toolCalls.set(key, { id: tc.id || undefined, type: "function", function: { name: "", arguments: "" } });
|
||||
const acc = toolCalls.get(key);
|
||||
if (tc.function?.name) acc.function.name = tc.function.name;
|
||||
if (tc.function?.arguments) acc.function.arguments += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const tc of toolCalls.values()) {
|
||||
if (!tc.id || tc.id.startsWith("call_anon_")) tc.id = "call_" + Date.now() + "_" + Math.random().toString(36).slice(2, 7);
|
||||
yield { type: "toolcall", toolcall: tc };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- SSE 辅助 ----------
|
||||
function sseInit(res: any) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
}
|
||||
function sseSend(res: any, obj: any) {
|
||||
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
||||
}
|
||||
|
||||
// ---------- 对话处理(带工具循环) ----------
|
||||
let pendingApproval: { messages: ChatMsg[]; toolCall: any } | null = null;
|
||||
|
||||
// 系统提示:优先读取项目根目录 system.md(详细工具使用指南),缺失时用内置提示
|
||||
const BASE_SYSTEM_PROMPT = "你是小鑫,一个专为冯雅鑫定制的本地 AI 助手,可以读写文件、处理 Excel、用 DuckDB 分析数据、列出目录、执行命令(需审批)等。请用简体中文友好、简洁地回答。";
|
||||
let SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
|
||||
try {
|
||||
const md = readFileSync(resolve(import.meta.dir ?? ".", "system.md"), "utf-8").trim();
|
||||
if (md) SYSTEM_PROMPT = md;
|
||||
} catch {
|
||||
/* 无 system.md 时使用内置提示 */
|
||||
}
|
||||
function withSystem(msgs: ChatMsg[]): ChatMsg[] {
|
||||
if (msgs.length && msgs[0].role === "system") return msgs;
|
||||
return [{ role: "system", content: SYSTEM_PROMPT }, ...msgs];
|
||||
}
|
||||
|
||||
// ---------- 工具参数容错解析与路径自动补全 ----------
|
||||
// 解析模型返回的工具参数;非法 JSON 尝试修复,裸路径转为 {path}
|
||||
function parseToolArgs(raw: string | undefined): { args: any; note: string } {
|
||||
if (!raw || !raw.trim()) return { args: {}, note: "空参数" };
|
||||
try {
|
||||
const a = JSON.parse(raw);
|
||||
if (typeof a === "string") return { args: { path: a.trim() }, note: "裸路径转为 path" };
|
||||
return { args: a && typeof a === "object" && !Array.isArray(a) ? a : {}, note: "" };
|
||||
} catch {}
|
||||
// 尝试转义单反斜杠(模型常把 Windows 路径写成 D:\xxx)
|
||||
const fixed = raw.replace(/\\(?![\\"])/g, "\\\\");
|
||||
if (fixed !== raw) {
|
||||
try {
|
||||
const a = JSON.parse(fixed);
|
||||
return { args: a && typeof a === "object" && !Array.isArray(a) ? a : {}, note: "已修复非法反斜杠" };
|
||||
} catch {}
|
||||
}
|
||||
// 裸字符串(去掉引号)→ 作为 path(若含 JSON 残留字符则视为解析失败)
|
||||
const trimmed = raw.trim().replace(/^["']|["']$/g, "");
|
||||
if (trimmed && !/[{}"]/.test(trimmed)) return { args: { path: trimmed }, note: "裸路径转为 path" };
|
||||
return { args: {}, note: "参数无法解析" };
|
||||
}
|
||||
// 工具缺 path 时,用本对话已知的最近文件/目录补全(仅限非破坏性工具)
|
||||
function fillPathArgs(name: string, args: any, ctx: { lastDir: string; lastFile: string }): { args: any; note: string } {
|
||||
if (args.path && String(args.path).trim()) return { args, note: "" };
|
||||
if (["read_file", "read_excel", "load_excel"].includes(name)) {
|
||||
if (ctx.lastFile) return { args: { ...args, path: ctx.lastFile }, note: `自动补全 path=${ctx.lastFile}` };
|
||||
if (ctx.lastDir) return { args: { ...args, path: ctx.lastDir }, note: `自动补全 path=${ctx.lastDir}` };
|
||||
return { args, note: "" };
|
||||
}
|
||||
if (name === "list_directory") {
|
||||
if (ctx.lastDir) return { args: { ...args, path: ctx.lastDir }, note: `自动补全 path=${ctx.lastDir}` };
|
||||
return { args: { ...args, path: "." }, note: "" };
|
||||
}
|
||||
return { args, note: "" };
|
||||
}
|
||||
// 从工具执行结果中学习已知文件/目录,供后续补全
|
||||
function absorbToolContext(name: string, args: any, exec: any, ctx: { lastDir: string; lastFile: string }) {
|
||||
const p = args.path;
|
||||
if (p && typeof p === "string") {
|
||||
if (name === "list_directory") {
|
||||
ctx.lastDir = p;
|
||||
} else {
|
||||
ctx.lastFile = p;
|
||||
ctx.lastDir = dirname(p);
|
||||
}
|
||||
}
|
||||
if (name === "list_directory" && Array.isArray(exec.result)) {
|
||||
for (const it of exec.result) {
|
||||
if (it?.type === "file") ctx.lastFile = resolve(ctx.lastDir || ".", it.name);
|
||||
}
|
||||
}
|
||||
if (name === "execute_command" && typeof exec.result === "string") {
|
||||
const m = exec.result.split(/\r?\n/).find((l) => /\.(xlsx?|xls|csv|txt|json)$/i.test(l.trim()));
|
||||
if (m) ctx.lastFile = m.trim();
|
||||
}
|
||||
}
|
||||
|
||||
async function runConversation(initialMessages: ChatMsg[], res: any, fromApproval = false) {
|
||||
sseInit(res);
|
||||
let messages = withSystem(initialMessages);
|
||||
|
||||
// 用户输入
|
||||
const userInput = initialMessages
|
||||
.filter((m) => m.role === "user")
|
||||
.map((m) => clip(m.content ?? "", 2000))
|
||||
.join(" | ");
|
||||
log("info", "对话开始", fromApproval ? "审批后继续" : "新对话");
|
||||
if (userInput) log("info", "用户输入", userInput);
|
||||
|
||||
// 本对话已知的最近文件/目录(供工具缺 path 时自动补全)
|
||||
const ctx = { lastDir: "", lastFile: "" };
|
||||
|
||||
// 工具循环(最多 20 轮避免死循环)
|
||||
for (let round = 0; round < 20; round++) {
|
||||
// 发送给 AI 的请求
|
||||
log("info", "发送给AI", `第${round + 1}轮 model=${config.MODEL} temperature=${config.TEMPERATURE}\n${fmtMessages(messages)}`);
|
||||
const gen = streamLLM(messages);
|
||||
let content = "";
|
||||
let errored = false;
|
||||
let llmErr = "";
|
||||
const toolCalls: any[] = [];
|
||||
for await (const ev of gen) {
|
||||
if (ev.type === "token") {
|
||||
content += ev.content;
|
||||
sseSend(res, { type: "token", content: ev.content });
|
||||
} else if (ev.type === "toolcall") {
|
||||
toolCalls.push(ev.toolcall);
|
||||
} else if (ev.type === "error") {
|
||||
errored = true;
|
||||
llmErr = ev.content;
|
||||
log("error", "AI错误", ev.content);
|
||||
sseSend(res, { type: "token", content: "\n\n⚠️ " + ev.content });
|
||||
}
|
||||
}
|
||||
|
||||
if (errored) {
|
||||
log("info", "返回界面", "⚠️ " + clip(llmErr, 1000));
|
||||
sseSend(res, { type: "done" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// 需要工具调用:本轮把模型返回的多个工具调用全部执行,统一把结果返回给模型
|
||||
const calls = toolCalls.filter((tc) => tc?.function?.name);
|
||||
if (calls.length) {
|
||||
const assistantMsg = { role: "assistant", content: content || null, tool_calls: calls.map((tc) => ({ id: tc.id, type: tc.type, function: tc.function })) };
|
||||
const toolResults: ChatMsg[] = [];
|
||||
|
||||
for (const tc of calls) {
|
||||
const name = tc.function.name;
|
||||
const parsed = parseToolArgs(tc.function.arguments);
|
||||
let args = parsed.args;
|
||||
// AI 返回:决定调用工具
|
||||
log("info", "AI返回", `调用工具 ${name},参数 ${safeStringify(args)}${parsed.note ? `(${parsed.note})` : ""}`);
|
||||
|
||||
// 路径自动补全:工具缺 path 时用本对话已知的最近文件/目录
|
||||
const filled = fillPathArgs(name, args, ctx);
|
||||
if (filled.note) {
|
||||
args = filled.args;
|
||||
log("info", "路径补全", filled.note);
|
||||
} else {
|
||||
args = filled.args;
|
||||
}
|
||||
|
||||
if (needsApproval(name, args)) {
|
||||
// 请求审批,保存上下文等待 /api/approve(之前已执行的结果一并保留)
|
||||
log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(等待用户审批,其余 ${calls.length - 1} 个调用一并暂停)`);
|
||||
pendingApproval = {
|
||||
messages: [...messages, assistantMsg, ...toolResults],
|
||||
toolCall: tc,
|
||||
args,
|
||||
};
|
||||
sseSend(res, {
|
||||
type: "approval_required",
|
||||
tool_call_id: tc.id,
|
||||
tool: name,
|
||||
args,
|
||||
command: name === "execute_command" ? args.command : `${name}(${JSON.stringify(args)})`,
|
||||
});
|
||||
sseSend(res, { type: "done" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行命令/工具
|
||||
log("info", "执行命令", `${name} 参数 ${safeStringify(args)}`);
|
||||
const exec = await execTool(name, args);
|
||||
// 执行结果
|
||||
log("info", "执行结果", `ok=${exec.ok} ${clip(exec.result, 1000)}`);
|
||||
// 从结果中学习已知路径(供后续工具缺 path 时补全)
|
||||
absorbToolContext(name, args, exec, ctx);
|
||||
toolResults.push({ role: "tool", name, content: safeStringify(exec), tool_call_id: tc.id });
|
||||
}
|
||||
|
||||
if (content) log("info", "返回界面", clip(content, 2000));
|
||||
if (calls.length > 1) log("info", "工具调用", `本轮共执行 ${calls.length} 个工具调用`);
|
||||
messages = [...messages, assistantMsg, ...toolResults];
|
||||
// 继续循环,让模型基于全部工具结果生成最终回复
|
||||
continue;
|
||||
}
|
||||
|
||||
// 无工具调用,正常结束
|
||||
log("info", "AI返回", clip(content, 2000));
|
||||
log("info", "返回界面", clip(content, 2000));
|
||||
sseSend(res, { type: "done" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
log("warn", "对话结束", "达到最大工具调用轮数(20),强制结束");
|
||||
sseSend(res, { type: "token", content: "\n[达到最大工具调用轮数]" });
|
||||
sseSend(res, { type: "done" });
|
||||
res.end();
|
||||
}
|
||||
|
||||
// ---------- 静态文件服务(生产构建) ----------
|
||||
const DIST = resolve("dist");
|
||||
const MIME: Record<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;
|
||||
}
|
||||
|
||||
// ---------- 会话记忆(本地 JSON 文件,按会话 id 持久化) ----------
|
||||
const CONVERSATIONS_DIR = resolve("conversations");
|
||||
function convFile(id: string): string {
|
||||
const safe = String(id).replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
return resolve(CONVERSATIONS_DIR, safe + ".json");
|
||||
}
|
||||
function genConvId(): string {
|
||||
return "c_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
function readConv(id: string): any {
|
||||
try { return JSON.parse(readFileSync(convFile(id), "utf-8")); } catch { return null; }
|
||||
}
|
||||
function writeConv(id: string, data: any) {
|
||||
mkdirSync(CONVERSATIONS_DIR, { recursive: true });
|
||||
writeFileSync(convFile(id), JSON.stringify(data, null, 2), "utf-8");
|
||||
}
|
||||
function listConvs(): { id: string; title: string; updatedAt: number; count: number }[] {
|
||||
try {
|
||||
if (!existsSync(CONVERSATIONS_DIR)) return [];
|
||||
return readdirSync(CONVERSATIONS_DIR)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => {
|
||||
try {
|
||||
const d = JSON.parse(readFileSync(resolve(CONVERSATIONS_DIR, f), "utf-8"));
|
||||
return { id: d.id || f.replace(".json", ""), title: d.title || "对话", updatedAt: d.updatedAt || 0, count: (d.messages || []).length };
|
||||
} catch { return null; }
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a: any, b: any) => b.updatedAt - a.updatedAt);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// ---------- HTTP 服务 ----------
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url || "", `http://localhost:${config.PORT}`);
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
|
||||
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
||||
|
||||
if (req.method === "GET" && !url.pathname.startsWith("/api")) {
|
||||
if (await serveStatic(res, url)) return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok", model: config.MODEL || "default", workspace: config.WORKSPACE }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取设置
|
||||
if (req.method === "GET" && url.pathname === "/api/settings") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
BASE_URL: config.BASE_URL,
|
||||
API_KEY: config.API_KEY ? "******" + config.API_KEY.slice(-4) : "",
|
||||
MODEL: config.MODEL,
|
||||
CMD_APPROVE_LEVEL: config.CMD_APPROVE_LEVEL,
|
||||
LOG_LEVEL: config.LOG_LEVEL,
|
||||
TEMPERATURE: config.TEMPERATURE,
|
||||
REMOTE_LOG_URL: config.REMOTE_LOG_URL,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
if (req.method === "POST" && url.pathname === "/api/settings") {
|
||||
let body = "";
|
||||
for await (const c of req) body += c;
|
||||
let payload: any;
|
||||
try { payload = JSON.parse(body); } catch { res.writeHead(400); res.end("bad json"); return; }
|
||||
if (typeof payload.BASE_URL === "string" && payload.BASE_URL.trim()) config.BASE_URL = payload.BASE_URL.trim();
|
||||
// token 为空时不覆盖已有配置(保留原 token)
|
||||
if (typeof payload.API_KEY === "string" && payload.API_KEY.trim() && !payload.API_KEY.startsWith("******")) config.API_KEY = payload.API_KEY.trim();
|
||||
if (typeof payload.MODEL === "string" && payload.MODEL.trim()) config.MODEL = payload.MODEL.trim();
|
||||
if (payload.CMD_APPROVE_LEVEL === "high" || payload.CMD_APPROVE_LEVEL === "medium" || payload.CMD_APPROVE_LEVEL === "low") {
|
||||
config.CMD_APPROVE_LEVEL = payload.CMD_APPROVE_LEVEL;
|
||||
}
|
||||
if (typeof payload.LOG_LEVEL === "string") config.LOG_LEVEL = payload.LOG_LEVEL.toLowerCase();
|
||||
if (typeof payload.TEMPERATURE === "number") config.TEMPERATURE = payload.TEMPERATURE;
|
||||
if (typeof payload.REMOTE_LOG_URL === "string") config.REMOTE_LOG_URL = payload.REMOTE_LOG_URL.trim();
|
||||
saveEnv();
|
||||
log("info", "settings_updated", { CMD_APPROVE_LEVEL: config.CMD_APPROVE_LEVEL, MODEL: config.MODEL, LOG_LEVEL: config.LOG_LEVEL, REMOTE_LOG_URL: config.REMOTE_LOG_URL });
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 模型可用性测试
|
||||
if (req.method === "POST" && url.pathname === "/api/test_model") {
|
||||
const urlLLM = config.BASE_URL.replace(/\/$/, "") + "/chat/completions";
|
||||
const testBody: any = {
|
||||
messages: [{ role: "user", content: "你好,请回复:测试通过" }],
|
||||
stream: false,
|
||||
max_tokens: 32,
|
||||
temperature: config.TEMPERATURE,
|
||||
};
|
||||
if (config.MODEL) testBody.model = config.MODEL;
|
||||
if (!config.API_KEY) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: "未配置 API_KEY" }));
|
||||
return;
|
||||
}
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const r = await fetch(urlLLM, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.API_KEY}` },
|
||||
body: JSON.stringify(testBody),
|
||||
});
|
||||
const ms = Date.now() - t0;
|
||||
const txt = await r.text().catch(() => "");
|
||||
let reply = "";
|
||||
let errMsg = "";
|
||||
try {
|
||||
const j = JSON.parse(txt);
|
||||
reply = j.choices?.[0]?.message?.content || "";
|
||||
errMsg = j.error?.message || "";
|
||||
} catch {}
|
||||
if (r.ok) {
|
||||
log("info", "model_test", { ok: true, model: config.MODEL, latencyMs: ms, reply: clip(reply, 100) });
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, model: config.MODEL, latencyMs: ms, reply: reply.slice(0, 200) }));
|
||||
} else {
|
||||
log("warn", "model_test", { ok: false, status: r.status, error: errMsg || txt.slice(0, 200) });
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, status: r.status, latencyMs: ms, error: errMsg || txt.slice(0, 200) }));
|
||||
}
|
||||
} catch (e: any) {
|
||||
log("warn", "model_test", { ok: false, error: e.message });
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, latencyMs: Date.now() - t0, error: "无法连接模型服务: " + e.message }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 会话列表
|
||||
if (req.method === "GET" && url.pathname === "/api/conversations") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(listConvs()));
|
||||
return;
|
||||
}
|
||||
// 新建会话
|
||||
if (req.method === "POST" && url.pathname === "/api/conversations") {
|
||||
const conv = { id: genConvId(), title: "新对话", messages: [], updatedAt: Date.now() };
|
||||
writeConv(conv.id, conv);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(conv));
|
||||
return;
|
||||
}
|
||||
// 单个会话:读取 / 保存 / 删除
|
||||
const convMatch = url.pathname.match(/^\/api\/conversations\/([^/]+)$/);
|
||||
if (convMatch) {
|
||||
const cid = convMatch[1];
|
||||
if (req.method === "GET") {
|
||||
const conv = readConv(cid);
|
||||
if (!conv) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); return; }
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(conv));
|
||||
return;
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
try { unlinkSync(convFile(cid)); } catch {}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
let body = "";
|
||||
for await (const c of req) body += c;
|
||||
let payload: any;
|
||||
try { payload = JSON.parse(body); } catch { res.writeHead(400); res.end("bad json"); return; }
|
||||
const old = readConv(cid) || { id: cid, messages: [] };
|
||||
const msgs = Array.isArray(payload.messages) ? payload.messages : old.messages;
|
||||
const first = msgs.find((x: any) => x.role === "user");
|
||||
const title = typeof payload.title === "string" && payload.title
|
||||
? payload.title
|
||||
: old.title && old.title !== "新对话"
|
||||
? old.title
|
||||
: first ? String(first.content || "").slice(0, 20) : "新对话";
|
||||
writeConv(cid, { id: cid, title, messages: msgs, updatedAt: Date.now() });
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, title }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天:流式 SSE
|
||||
if (req.method === "POST" && url.pathname === "/api/chat") {
|
||||
let body = "";
|
||||
for await (const c of req) body += c;
|
||||
let payload: any;
|
||||
try { payload = JSON.parse(body); } catch { res.writeHead(400); res.end("bad json"); return; }
|
||||
const history: ChatMsg[] = (payload.messages || []).map((m: any) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
await runConversation(history, res);
|
||||
return;
|
||||
}
|
||||
|
||||
// 审批反馈:流式 SSE(继续对话)
|
||||
if (req.method === "POST" && url.pathname === "/api/approve") {
|
||||
let body = "";
|
||||
for await (const c of req) body += c;
|
||||
let payload: any;
|
||||
try { payload = JSON.parse(body); } catch { res.writeHead(400); res.end("bad json"); return; }
|
||||
const approved = !!payload.approved;
|
||||
|
||||
if (!pendingApproval) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "无待审批操作" }));
|
||||
return;
|
||||
}
|
||||
const { messages, toolCall, args: pendingArgs } = pendingApproval;
|
||||
pendingApproval = null;
|
||||
const name = toolCall.function.name;
|
||||
// 优先使用审批时的已补全参数;否则重新容错解析
|
||||
const parsed = parseToolArgs(toolCall.function.arguments);
|
||||
const args = pendingArgs && typeof pendingArgs === "object" && !Array.isArray(pendingArgs) ? pendingArgs : parsed.args;
|
||||
log("info", "审批执行", `${name} 参数 ${safeStringify(args)}${parsed.note ? `(${parsed.note})` : ""}`);
|
||||
|
||||
let exec;
|
||||
if (approved) {
|
||||
log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(已批准)`);
|
||||
exec = await execTool(name, args);
|
||||
log("info", "执行结果", `ok=${exec.ok} ${clip(exec.result, 1000)}`);
|
||||
} else {
|
||||
log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(已拒绝)`);
|
||||
exec = { ok: false, result: "用户已拒绝该操作" };
|
||||
}
|
||||
const nextMessages: ChatMsg[] = [
|
||||
...messages,
|
||||
{ role: "tool", name, content: safeStringify(exec), tool_call_id: toolCall.id },
|
||||
];
|
||||
await runConversation(nextMessages, res, true);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not Found");
|
||||
});
|
||||
|
||||
server.listen(config.PORT, () => {
|
||||
console.log(`Mini AI Assistant 后端已启动`);
|
||||
console.log(` API: http://127.0.0.1:${config.PORT}`);
|
||||
console.log(` 模型: ${config.BASE_URL}`);
|
||||
console.log(` 工作区: ${config.WORKSPACE}`);
|
||||
console.log(` 日志级别: ${config.LOG_LEVEL} → logs/`);
|
||||
});
|
||||
startServer();
|
||||
|
||||
+53
-378
@@ -1,408 +1,98 @@
|
||||
<template>
|
||||
<div class="app" :class="theme">
|
||||
<aside class="sidebar">
|
||||
<button class="new-chat" @click="newConversation">+ 新建对话</button>
|
||||
<div class="conv-list">
|
||||
<div
|
||||
v-for="c in conversations"
|
||||
:key="c.id"
|
||||
class="conv-item"
|
||||
:class="{ active: c.id === conversationId }"
|
||||
@click="selectConversation(c.id)"
|
||||
>
|
||||
<span class="conv-title">{{ c.title }}</span>
|
||||
<button class="del" title="删除对话" @click.stop="deleteConversation(c.id)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="main-col">
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="logo"><AssistantAvatar /></div>
|
||||
<div>
|
||||
<div class="brand-text">
|
||||
<div class="brand-title">小鑫 AI 助手</div>
|
||||
<div class="brand-sub">专为冯雅鑫定制</div>
|
||||
<div class="brand-sub">
|
||||
专为冯雅鑫定制
|
||||
<span class="version" @click="gotoHome">v1.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<router-link to="/" class="nav-item" :class="{ active: route.path === '/' }">对话</router-link>
|
||||
<router-link to="/skills" class="nav-item" :class="{ active: route.path === '/skills' }">技能</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="actions">
|
||||
<button class="icon-btn" title="设置" @click="showSettings = true">⚙️</button>
|
||||
<button class="icon-btn" title="清空对话" @click="clearChat">🗑️</button>
|
||||
<button class="icon-btn" title="设置" @click="settingsOpen = true">⚙️</button>
|
||||
<button class="icon-btn" :title="theme === 'dark' ? '浅色模式' : '深色模式'" @click="toggleTheme">
|
||||
{{ theme === 'dark' ? '☀️' : '🌙' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="chat" ref="chatEl">
|
||||
<div v-if="messages.length === 0" class="empty">
|
||||
<div class="empty-icon">💬</div>
|
||||
<h2>开始对话</h2>
|
||||
<p>你可以让我读取文件、处理 Excel、执行命令(需审批)等。</p>
|
||||
<div class="suggest">
|
||||
<button v-for="s in suggestions" :key="s" @click="send(s)">{{ s }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageList
|
||||
v-else
|
||||
:messages="messages"
|
||||
@approve="onApprove"
|
||||
@reject="onReject"
|
||||
/>
|
||||
<main class="content">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<InputArea :disabled="busy" @send="send" />
|
||||
|
||||
<SettingsModal v-if="showSettings" :api-base="API_BASE" @close="showSettings = false" />
|
||||
</div>
|
||||
<SettingsDrawer :open="settingsOpen" @close="settingsOpen = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted } from "vue";
|
||||
import MessageList from "./components/MessageList.vue";
|
||||
import InputArea from "./components/InputArea.vue";
|
||||
import SettingsModal from "./components/SettingsModal.vue";
|
||||
import { ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useTheme, initTheme } from "./composables/useTheme";
|
||||
import SettingsDrawer from "./components/SettingsDrawer.vue";
|
||||
import AssistantAvatar from "./components/AssistantAvatar.vue";
|
||||
|
||||
const showSettings = ref(false);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { theme, toggle: toggleTheme } = useTheme();
|
||||
const settingsOpen = ref(false);
|
||||
|
||||
type Role = "user" | "assistant" | "system";
|
||||
interface Msg {
|
||||
id: number;
|
||||
role: Role;
|
||||
content: string;
|
||||
loading?: boolean;
|
||||
approval?: {
|
||||
tool: string;
|
||||
args: any;
|
||||
command: string;
|
||||
tool_call_id: string;
|
||||
};
|
||||
}
|
||||
initTheme();
|
||||
|
||||
const messages = ref<Msg[]>([]);
|
||||
const conversations = ref<{ id: string; title: string; updatedAt: number; count: number }[]>([]);
|
||||
const conversationId = ref("");
|
||||
const theme = ref<"light" | "dark">("light");
|
||||
const busy = ref(false);
|
||||
const chatEl = ref<HTMLElement | null>(null);
|
||||
const suggestions = [
|
||||
"列出当前目录的文件",
|
||||
"读取 data.xlsx 并告诉我前两行",
|
||||
"帮我创建一个 hello.txt 写入你好",
|
||||
];
|
||||
|
||||
let idSeq = 1;
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
chatEl.value?.scrollTo({ top: chatEl.value.scrollHeight, behavior: "smooth" });
|
||||
};
|
||||
|
||||
function pushMsg(role: Role, content = ""): Msg {
|
||||
const m: Msg = { id: idSeq++, role, content };
|
||||
messages.value.push(m);
|
||||
return messages.value[messages.value.length - 1];
|
||||
function gotoHome() {
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
// API 地址:开发环境直连后端(绕过 Vite 代理以保证 SSE 流式正常),生产可改为相对路径
|
||||
const API_BASE: string = (import.meta as any).env?.VITE_API_BASE || (import.meta as any).env?.DEV ? "http://127.0.0.1:3100" : "";
|
||||
|
||||
// 读取 SSE 流
|
||||
async function consumeStream(response: Response, target: Msg) {
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith("data:")) continue;
|
||||
const data = t.slice(5).trim();
|
||||
let ev: any;
|
||||
try { ev = JSON.parse(data); } catch { continue; }
|
||||
if (ev.type === "token" && ev.content) {
|
||||
target.content += ev.content;
|
||||
scrollToBottom();
|
||||
} else if (ev.type === "approval_required") {
|
||||
target.loading = false;
|
||||
target.approval = {
|
||||
tool: ev.tool,
|
||||
args: ev.args,
|
||||
command: ev.command,
|
||||
tool_call_id: ev.tool_call_id,
|
||||
};
|
||||
busy.value = false;
|
||||
return;
|
||||
} else if (ev.type === "done") {
|
||||
target.loading = false;
|
||||
busy.value = false;
|
||||
scrollToBottom();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
target.loading = false;
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function send(text: string) {
|
||||
const content = text.trim();
|
||||
if (!content || busy.value) return;
|
||||
if (!conversationId.value) await newConversation();
|
||||
busy.value = true;
|
||||
pushMsg("user", content);
|
||||
const assistant = pushMsg("assistant", "");
|
||||
assistant.loading = true;
|
||||
scrollToBottom();
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages: messages.value
|
||||
.filter((m) => !m.approval && m.content && m.role !== "assistant")
|
||||
.map((m) => ({ role: m.role, content: m.content })),
|
||||
}),
|
||||
});
|
||||
await consumeStream(resp, assistant);
|
||||
await saveConversation();
|
||||
} catch (e: any) {
|
||||
assistant.content = "⚠️ 请求失败:" + e.message;
|
||||
assistant.loading = false;
|
||||
busy.value = false;
|
||||
await saveConversation();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecision(approved: boolean, m: Msg) {
|
||||
if (!m.approval) return;
|
||||
const toolCallId = m.approval.tool_call_id;
|
||||
// 移除审批卡片消息,避免留下空气泡
|
||||
messages.value = messages.value.filter((x) => x.id !== m.id);
|
||||
busy.value = true;
|
||||
const assistant = pushMsg("assistant", "");
|
||||
assistant.loading = true;
|
||||
scrollToBottom();
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ approved, tool_call_id: toolCallId }),
|
||||
});
|
||||
await consumeStream(resp, assistant);
|
||||
await saveConversation();
|
||||
} catch (e: any) {
|
||||
assistant.content = "⚠️ 请求失败:" + e.message;
|
||||
assistant.loading = false;
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const onApprove = (m: Msg) => handleDecision(true, m);
|
||||
const onReject = (m: Msg) => handleDecision(false, m);
|
||||
|
||||
function clearChat() {
|
||||
messages.value = [];
|
||||
saveConversation();
|
||||
}
|
||||
|
||||
// ---------- 会话记忆 ----------
|
||||
async function refreshList() {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/conversations`);
|
||||
conversations.value = await r.json();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function newConversation() {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/conversations`, { method: "POST" });
|
||||
const conv = await r.json();
|
||||
conversationId.value = conv.id;
|
||||
messages.value = [];
|
||||
await refreshList();
|
||||
} catch (e: any) {
|
||||
alert("新建对话失败:" + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectConversation(id: string) {
|
||||
if (id === conversationId.value) return;
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/conversations/${id}`);
|
||||
if (!r.ok) return;
|
||||
const conv = await r.json();
|
||||
conversationId.value = id;
|
||||
messages.value = (conv.messages || []).map((x: any) => ({
|
||||
id: idSeq++,
|
||||
role: x.role === "assistant" ? "assistant" : "user",
|
||||
content: x.content || "",
|
||||
}));
|
||||
await refreshList();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function deleteConversation(id: string) {
|
||||
await fetch(`${API_BASE}/api/conversations/${id}`, { method: "DELETE" });
|
||||
if (id === conversationId.value) {
|
||||
conversationId.value = "";
|
||||
messages.value = [];
|
||||
}
|
||||
await refreshList();
|
||||
}
|
||||
|
||||
async function saveConversation() {
|
||||
if (!conversationId.value) return;
|
||||
const msgs = messages.value
|
||||
.filter((m) => !m.approval)
|
||||
.map((m) => ({ role: m.role, content: m.content || "" }));
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/conversations/${conversationId.value}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: msgs }),
|
||||
});
|
||||
await refreshList();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
theme.value = theme.value === "dark" ? "light" : "dark";
|
||||
document.documentElement.classList.toggle("dark", theme.value === "dark");
|
||||
localStorage.setItem("theme", theme.value);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const saved = localStorage.getItem("theme");
|
||||
if (saved === "dark") {
|
||||
theme.value = "dark";
|
||||
document.documentElement.classList.add("dark");
|
||||
}
|
||||
await refreshList();
|
||||
if (conversations.value.length) {
|
||||
await selectConversation(conversations.value[0].id);
|
||||
} else {
|
||||
await newConversation();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4f6fb;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f0f2f7;
|
||||
--text: #1f2733;
|
||||
--text-dim: #6b7686;
|
||||
--border: #e6e9f0;
|
||||
--primary: #4f6ef7;
|
||||
--primary-hover: #3d5be0;
|
||||
--user-bubble: #4f6ef7;
|
||||
--user-text: #ffffff;
|
||||
--assistant-bubble: #ffffff;
|
||||
--code-bg: #1e2430;
|
||||
--code-text: #e6edf3;
|
||||
--shadow: 0 1px 3px rgba(20, 30, 50, 0.08), 0 8px 24px rgba(20, 30, 50, 0.06);
|
||||
--radius: 16px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg: #0f141b;
|
||||
--surface: #171d27;
|
||||
--surface-2: #1e2530;
|
||||
--text: #e6eaf0;
|
||||
--text-dim: #8a93a3;
|
||||
--border: #2a323f;
|
||||
--primary: #5b7cfa;
|
||||
--primary-hover: #6f8cff;
|
||||
--user-bubble: #3d5be0;
|
||||
--user-text: #ffffff;
|
||||
--assistant-bubble: #1e2530;
|
||||
--code-bg: #0b0e14;
|
||||
--code-text: #e6edf3;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px; flex-shrink: 0;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.new-chat {
|
||||
padding: 9px; border: 1px solid var(--primary);
|
||||
background: var(--primary); color: #fff; border-radius: 10px;
|
||||
font-weight: 600; font-size: 14px; cursor: pointer; transition: 0.15s;
|
||||
}
|
||||
.new-chat:hover { background: var(--primary-hover); }
|
||||
.conv-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.conv-item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 6px;
|
||||
padding: 8px 10px; border-radius: 9px; cursor: pointer;
|
||||
background: var(--surface-2); transition: 0.15s;
|
||||
}
|
||||
.conv-item:hover { background: var(--border); }
|
||||
.conv-item.active { background: var(--primary); color: #fff; }
|
||||
.conv-title {
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.conv-item .del {
|
||||
background: none; border: none; color: inherit; opacity: 0.6;
|
||||
cursor: pointer; font-size: 12px; flex-shrink: 0;
|
||||
}
|
||||
.conv-item .del:hover { opacity: 1; }
|
||||
|
||||
.main-col {
|
||||
flex: 1; min-width: 0;
|
||||
display: flex; flex-direction: column; height: 100%;
|
||||
max-width: 880px; margin: 0 auto; width: 100%;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
gap: 20px;
|
||||
padding: 10px 20px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.logo {
|
||||
width: 40px; height: 40px; border-radius: 12px; overflow: hidden;
|
||||
display: grid; place-items: center; background: transparent; padding: 0;
|
||||
}
|
||||
.brand-title { font-weight: 700; font-size: 16px; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.logo { width: 40px; height: 40px; border-radius: 12px; overflow: hidden; display: grid; place-items: center; flex-shrink: 0; }
|
||||
.brand-text { min-width: 0; }
|
||||
.brand-title { font-weight: 700; font-size: 16px; line-height: 1.2; }
|
||||
.brand-sub { font-size: 12px; color: var(--text-dim); }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.brand-sub .version {
|
||||
margin-left: 6px; cursor: pointer; font-weight: 600; opacity: 0.75;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.brand-sub .version:hover { opacity: 1; }
|
||||
|
||||
.nav { display: flex; gap: 4px; margin: 0 auto; }
|
||||
.nav-item {
|
||||
padding: 7px 18px; border-radius: 99px; text-decoration: none;
|
||||
color: var(--text-dim); font-size: 14px; font-weight: 500;
|
||||
transition: 0.15s;
|
||||
}
|
||||
.nav-item:hover { color: var(--text); background: var(--surface-2); }
|
||||
.nav-item.active { background: var(--primary); color: #fff; }
|
||||
|
||||
.actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.icon-btn {
|
||||
width: 38px; height: 38px; border-radius: 10px;
|
||||
border: 1px solid var(--border); background: var(--surface-2);
|
||||
@@ -410,21 +100,6 @@ body {
|
||||
}
|
||||
.icon-btn:hover { background: var(--border); }
|
||||
|
||||
.chat {
|
||||
flex: 1; overflow-y: auto; padding: 24px 20px 8px;
|
||||
display: flex; flex-direction: column; gap: 18px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: auto; text-align: center; color: var(--text-dim); max-width: 420px;
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty h2 { color: var(--text); margin: 0 0 8px; }
|
||||
.suggest { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 18px; }
|
||||
.suggest button {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
color: var(--text); padding: 8px 14px; border-radius: 20px;
|
||||
cursor: pointer; font-size: 13px; transition: 0.15s;
|
||||
}
|
||||
.suggest button:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.content { flex: 1; min-height: 0; overflow: hidden; display: flex; }
|
||||
.content :deep(.chat-page) { width: 100%; }
|
||||
</style>
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// API 地址:开发环境走 Vite 代理(/api → 后端,SSE 同样支持),生产用相对路径由后端托管静态资源
|
||||
export const API_BASE: string = (import.meta as any).env?.VITE_API_BASE || "";
|
||||
|
||||
export async function api<T = any>(path: string, init?: RequestInit): Promise<T> {
|
||||
const r = await fetch(`${API_BASE}${path}`, init);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export function apiJson(path: string, body: any): Promise<any> {
|
||||
return api(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div class="input-bar">
|
||||
<div class="input-box">
|
||||
<textarea
|
||||
ref="ta"
|
||||
v-model="text"
|
||||
@@ -15,6 +16,8 @@
|
||||
<span v-else class="spinner"></span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="tip">小鑫可以读写文件、分析 Excel、执行命令(危险操作需你审批)</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -44,21 +47,30 @@ function submit() {
|
||||
|
||||
<style scoped>
|
||||
.input-bar {
|
||||
padding: 12px 20px 16px;
|
||||
}
|
||||
.input-box {
|
||||
display: flex; align-items: flex-end; gap: 10px;
|
||||
padding: 14px 20px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 8px 8px 8px 16px;
|
||||
box-shadow: var(--shadow);
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.input-box:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: var(--shadow), 0 0 0 3px var(--primary-soft);
|
||||
}
|
||||
.ta {
|
||||
flex: 1; resize: none; border: 1px solid var(--border);
|
||||
background: var(--surface-2); color: var(--text);
|
||||
border-radius: 14px; padding: 12px 16px; font: inherit; font-size: 14.5px;
|
||||
line-height: 1.5; max-height: 160px; outline: none; transition: border-color 0.15s;
|
||||
flex: 1; resize: none; border: none; outline: none;
|
||||
background: transparent; color: var(--text);
|
||||
font: inherit; font-size: 14.5px; line-height: 1.5;
|
||||
max-height: 160px; padding: 6px 0;
|
||||
}
|
||||
.ta:focus { border-color: var(--primary); }
|
||||
.ta:disabled { opacity: 0.6; }
|
||||
.send {
|
||||
width: 44px; height: 44px; flex-shrink: 0; border: none; border-radius: 12px;
|
||||
width: 42px; height: 42px; flex-shrink: 0; border: none; border-radius: 12px;
|
||||
background: var(--primary); color: #fff; cursor: pointer; font-size: 16px;
|
||||
display: grid; place-items: center; transition: 0.15s;
|
||||
}
|
||||
@@ -69,4 +81,5 @@ function submit() {
|
||||
border-top-color: #fff; border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.tip { text-align: center; font-size: 12px; color: var(--text-dim); margin: 8px 0 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,70 @@
|
||||
<template>
|
||||
<div class="bubble" :class="msg.role">
|
||||
<!-- 审批卡片 -->
|
||||
<div v-if="msg.approval" class="approval">
|
||||
<div class="approval-head">⚠️ 需要审批的操作</div>
|
||||
<div class="approval-tool">{{ toolLabel }}</div>
|
||||
<pre class="approval-cmd">{{ msg.approval.command }}</pre>
|
||||
<div class="approval-btns">
|
||||
<button class="allow" @click="$emit('approve')">✅ 允许</button>
|
||||
<button class="deny" @click="$emit('reject')">❌ 拒绝</button>
|
||||
<!-- 工具操作卡片 -->
|
||||
<div v-if="msg.tools?.length" class="tools">
|
||||
<ToolCard
|
||||
v-for="(t, i) in msg.tools"
|
||||
:key="i"
|
||||
:tool="t"
|
||||
@approve="$emit('approve', t)"
|
||||
@reject="$emit('reject', t)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="msg.approval" class="tools">
|
||||
<ToolCard
|
||||
:tool="{ tool: msg.approval.tool, args: msg.approval.args, command: msg.approval.command, status: 'approval' }"
|
||||
@approve="$emit('approve', msg)"
|
||||
@reject="$emit('reject', msg)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 普通消息(Markdown) -->
|
||||
<div v-else class="md" v-html="rendered"></div>
|
||||
<div v-if="msg.content" class="md" v-html="rendered" @click="onClick"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { marked } from "marked";
|
||||
import hljs from "highlight.js";
|
||||
import hljs from "../lib/hljs";
|
||||
import ToolCard from "./ToolCard.vue";
|
||||
|
||||
export interface ToolEvent {
|
||||
tool: string;
|
||||
args?: any;
|
||||
command?: string;
|
||||
status: "approval" | "running" | "done" | "failed" | "denied";
|
||||
result?: string;
|
||||
}
|
||||
|
||||
interface Msg {
|
||||
id: number;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
approval?: { tool: string; command: string; args: any };
|
||||
loading?: boolean;
|
||||
tools?: ToolEvent[];
|
||||
approval?: { tool: string; command: string; args: any; tool_call_id: string };
|
||||
}
|
||||
|
||||
const props = defineProps<{ msg: Msg }>();
|
||||
defineEmits<{ approve: []; reject: [] }>();
|
||||
defineEmits<{ approve: [payload: any]; reject: [payload: any] }>();
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.code = (code, infostring) => {
|
||||
const lang = (infostring || "").trim().split(/\s+/)[0];
|
||||
const highlighted =
|
||||
lang && hljs.getLanguage(lang)
|
||||
? hljs.highlight(code, { language: lang }).value
|
||||
: hljs.highlightAuto(code).value;
|
||||
return (
|
||||
`<div class="code-block"><div class="code-head"><span class="code-lang">${lang}</span>` +
|
||||
`<button class="copy-btn" data-code="${encodeURIComponent(code)}">复制</button></div>` +
|
||||
`<pre><code class="hljs">${highlighted}</code></pre></div>`
|
||||
);
|
||||
};
|
||||
|
||||
marked.setOptions({
|
||||
highlight(code: string, lang: string) {
|
||||
if (lang && hljs.getLanguage(lang)) return hljs.highlight(code, { language: lang }).value;
|
||||
return hljs.highlightAuto(code).value;
|
||||
},
|
||||
renderer,
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
});
|
||||
@@ -49,13 +78,18 @@ const rendered = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const toolLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
delete_file: "删除文件",
|
||||
execute_command: "执行命令",
|
||||
};
|
||||
return map[props.msg.approval?.tool || ""] || props.msg.approval?.tool || "";
|
||||
});
|
||||
async function onClick(e: MouseEvent) {
|
||||
const btn = (e.target as HTMLElement).closest(".copy-btn") as HTMLElement | null;
|
||||
if (!btn) return;
|
||||
const code = decodeURIComponent(btn.dataset.code || "");
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
btn.textContent = "已复制";
|
||||
setTimeout(() => (btn.textContent = "复制"), 1500);
|
||||
} catch {
|
||||
/* 剪贴板不可用时忽略 */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -63,7 +97,7 @@ const toolLabel = computed(() => {
|
||||
.bubble.user :deep(.md) {
|
||||
background: var(--user-bubble); color: var(--user-text);
|
||||
padding: 10px 14px; border-radius: 14px 14px 4px 14px;
|
||||
border-top-right-radius: 4px; word-break: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
.bubble.assistant :deep(.md) {
|
||||
background: var(--assistant-bubble); color: var(--text);
|
||||
@@ -72,6 +106,10 @@ const toolLabel = computed(() => {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tools { display: flex; flex-direction: column; gap: 8px; }
|
||||
.bubble.user .tools { align-items: flex-end; }
|
||||
.bubble.user .tools :deep(.tool-card) { width: 100%; max-width: 460px; }
|
||||
|
||||
/* Markdown 元素 */
|
||||
.bubble :deep(p) { margin: 0.5em 0; }
|
||||
.bubble :deep(p:first-child) { margin-top: 0; }
|
||||
@@ -85,12 +123,22 @@ const toolLabel = computed(() => {
|
||||
background: var(--surface-2); padding: 1px 6px; border-radius: 5px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.88em;
|
||||
}
|
||||
.bubble :deep(.code-block) { margin: 0.6em 0; border-radius: 10px; overflow: hidden; background: var(--code-bg); }
|
||||
.bubble :deep(.code-head) {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 6px 12px; background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.bubble :deep(.code-lang) { font-size: 11px; color: rgba(255, 255, 255, 0.6); text-transform: lowercase; }
|
||||
.bubble :deep(.copy-btn) {
|
||||
background: rgba(255, 255, 255, 0.1); border: none; color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 11px; padding: 3px 10px; border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
.bubble :deep(.copy-btn:hover) { background: rgba(255, 255, 255, 0.2); color: #fff; }
|
||||
.bubble :deep(pre) {
|
||||
background: var(--code-bg); color: var(--code-text);
|
||||
padding: 14px 16px; border-radius: 10px; overflow-x: auto;
|
||||
margin: 0.6em 0; position: relative;
|
||||
padding: 14px 16px; overflow-x: auto; margin: 0;
|
||||
}
|
||||
.bubble :deep(pre code) { font-family: "SFMono-Regular", Consolas, monospace; font-size: 13px; line-height: 1.6; }
|
||||
.bubble :deep(pre code) { font-family: "SFMono-Regular", Consolas, monospace; font-size: 13px; line-height: 1.6; padding: 0; background: transparent; }
|
||||
.bubble :deep(table) {
|
||||
border-collapse: collapse; margin: 0.6em 0; font-size: 13px; width: 100%;
|
||||
}
|
||||
@@ -99,27 +147,4 @@ const toolLabel = computed(() => {
|
||||
.bubble :deep(blockquote) {
|
||||
border-left: 3px solid var(--primary); margin: 0.5em 0; padding: 2px 12px; color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* 审批卡片 */
|
||||
.approval {
|
||||
background: var(--surface); border: 1px solid #f0a020;
|
||||
border-radius: 14px; padding: 14px 16px; box-shadow: var(--shadow);
|
||||
max-width: 460px;
|
||||
}
|
||||
.approval-head { font-weight: 700; color: #d97706; margin-bottom: 8px; }
|
||||
.approval-tool { font-size: 13px; color: var(--text-dim); margin-bottom: 6px; }
|
||||
.approval-cmd {
|
||||
background: var(--code-bg); color: var(--code-text); padding: 10px 12px;
|
||||
border-radius: 8px; font-family: monospace; font-size: 12.5px;
|
||||
white-space: pre-wrap; word-break: break-all; margin: 0 0 12px;
|
||||
}
|
||||
.approval-btns { display: flex; gap: 10px; }
|
||||
.approval-btns button {
|
||||
flex: 1; padding: 9px; border: none; border-radius: 9px; cursor: pointer;
|
||||
font-weight: 600; font-size: 13px; transition: 0.15s;
|
||||
}
|
||||
.approval-btns .allow { background: #16a34a; color: #fff; }
|
||||
.approval-btns .allow:hover { background: #15803d; }
|
||||
.approval-btns .deny { background: #dc2626; color: #fff; }
|
||||
.approval-btns .deny:hover { background: #b91c1c; }
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="meta">{{ m.role === "user" ? "你" : "小鑫" }}</div>
|
||||
<MessageBubble :msg="m" @approve="() => $emit('approve', m)" @reject="() => $emit('reject', m)" />
|
||||
<MessageBubble :msg="m" @approve="(p: any) => $emit('approve', p)" @reject="(p: any) => $emit('reject', p)" />
|
||||
<div v-if="m.loading" class="typing"><span></span><span></span><span></span></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -24,10 +24,11 @@ interface Msg {
|
||||
content: string;
|
||||
loading?: boolean;
|
||||
approval?: any;
|
||||
tools?: any[];
|
||||
}
|
||||
|
||||
defineProps<{ messages: Msg[] }>();
|
||||
defineEmits<{ approve: [m: Msg]; reject: [m: Msg] }>();
|
||||
defineEmits<{ approve: [payload: any]; reject: [payload: any] }>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<transition name="slide">
|
||||
<div v-if="open" class="overlay" @click.self="$emit('close')">
|
||||
<aside class="drawer">
|
||||
<header class="d-head">
|
||||
<h3>⚙️ 设置</h3>
|
||||
<button class="x" @click="$emit('close')">✕</button>
|
||||
</header>
|
||||
|
||||
<div class="d-body">
|
||||
<section>
|
||||
<h4>模型</h4>
|
||||
<label>
|
||||
<span>接口地址</span>
|
||||
<input v-model="form.BASE_URL" placeholder="https://.../chat/completions 的上级路径" />
|
||||
</label>
|
||||
<label>
|
||||
<span>API Key</span>
|
||||
<input v-model="form.API_KEY" type="password" placeholder="留空则不修改" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>模型名称</span>
|
||||
<input v-model="form.MODEL" placeholder="如 ark-code-latest" />
|
||||
</label>
|
||||
<label>
|
||||
<span>温度</span>
|
||||
<input v-model.number="form.TEMPERATURE" type="number" min="0" max="2" step="0.1" />
|
||||
</label>
|
||||
<button class="test" :disabled="testing" @click="testModel">
|
||||
{{ testing ? "测试中…" : "测试模型可用性" }}
|
||||
</button>
|
||||
<div v-if="testMsg" class="test-result" :class="testOk ? 'ok' : 'err'">
|
||||
<div>{{ testMsg }}</div>
|
||||
<div v-if="testReply" class="test-reply">{{ testReply }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>命令授权级别</h4>
|
||||
<label>
|
||||
<span>级别</span>
|
||||
<select v-model="form.CMD_APPROVE_LEVEL">
|
||||
<option value="high">高 · 每个命令都审核</option>
|
||||
<option value="medium">中 · 仅删除等危险命令审核(默认)</option>
|
||||
<option value="low">低 · 全部不审核</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="hint">控制执行 Shell 命令时是否需要你审批。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>日志</h4>
|
||||
<label>
|
||||
<span>日志级别</span>
|
||||
<select v-model="form.LOG_LEVEL">
|
||||
<option value="debug">debug(记录全部对话)</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error(仅错误)</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="hint">debug 级别会把所有对话、工具调用、审批记录到 <code>logs/</code> 目录。</p>
|
||||
<label>
|
||||
<span>远程日志地址</span>
|
||||
<input v-model="form.REMOTE_LOG_URL" placeholder="http://127.0.0.1:3200/log,留空则只写本地" />
|
||||
</label>
|
||||
<p class="hint">填写远程日志接收器的 HTTP 地址后,每条日志会同时推送到该地址。</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="d-foot">
|
||||
<span v-if="msg" class="msg" :class="ok ? 'ok' : 'err'">{{ msg }}</span>
|
||||
<button class="save" :disabled="saving" @click="save">保存</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch } from "vue";
|
||||
import { api } from "../api";
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
defineEmits<{ close: [] }>();
|
||||
|
||||
const form = reactive({
|
||||
BASE_URL: "",
|
||||
API_KEY: "",
|
||||
MODEL: "",
|
||||
CMD_APPROVE_LEVEL: "medium",
|
||||
LOG_LEVEL: "debug",
|
||||
TEMPERATURE: 0.3,
|
||||
REMOTE_LOG_URL: "",
|
||||
});
|
||||
const saving = ref(false);
|
||||
const msg = ref("");
|
||||
const ok = ref(true);
|
||||
const testing = ref(false);
|
||||
const testMsg = ref("");
|
||||
const testOk = ref(true);
|
||||
const testReply = ref("");
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(v) => {
|
||||
if (v) load();
|
||||
}
|
||||
);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const d = await api("/api/settings");
|
||||
form.BASE_URL = d.BASE_URL || "";
|
||||
form.MODEL = d.MODEL || "";
|
||||
form.CMD_APPROVE_LEVEL = d.CMD_APPROVE_LEVEL || "medium";
|
||||
form.LOG_LEVEL = d.LOG_LEVEL || "debug";
|
||||
form.TEMPERATURE = typeof d.TEMPERATURE === "number" ? d.TEMPERATURE : 0.3;
|
||||
form.REMOTE_LOG_URL = d.REMOTE_LOG_URL || "";
|
||||
form.API_KEY = "";
|
||||
} catch {
|
||||
msg.value = "加载设置失败";
|
||||
ok.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testModel() {
|
||||
testing.value = true;
|
||||
testMsg.value = "";
|
||||
testReply.value = "";
|
||||
try {
|
||||
const d = await api("/api/test_model", { method: "POST" });
|
||||
testOk.value = !!d.ok;
|
||||
if (d.ok) {
|
||||
testMsg.value = `✅ 模型可用 · ${d.model || "未知"} · 延迟 ${d.latencyMs}ms`;
|
||||
testReply.value = d.reply || "";
|
||||
} else {
|
||||
testMsg.value = `❌ ${d.error || "测试失败"}` + (d.status ? ` · HTTP ${d.status}` : "");
|
||||
}
|
||||
} catch (e: any) {
|
||||
testOk.value = false;
|
||||
testMsg.value = "❌ 请求失败:" + e.message;
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
msg.value = "";
|
||||
try {
|
||||
const d = await api("/api/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...form }),
|
||||
});
|
||||
if (d.ok) {
|
||||
msg.value = "已保存";
|
||||
ok.value = true;
|
||||
form.API_KEY = "";
|
||||
} else {
|
||||
msg.value = "保存失败";
|
||||
ok.value = false;
|
||||
}
|
||||
} catch (e: any) {
|
||||
msg.value = "保存失败:" + e.message;
|
||||
ok.value = false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed; inset: 0; background: rgba(15, 20, 27, 0.45);
|
||||
backdrop-filter: blur(2px); display: flex; justify-content: flex-end;
|
||||
z-index: 100;
|
||||
}
|
||||
.drawer {
|
||||
width: min(440px, 92vw); height: 100%;
|
||||
background: var(--surface); color: var(--text);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.d-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.d-head h3 { margin: 0; font-size: 16px; }
|
||||
.x { background: none; border: none; font-size: 18px; cursor: pointer; color: var(--text-dim); }
|
||||
|
||||
.d-body { padding: 8px 20px; overflow-y: auto; flex: 1; }
|
||||
section { padding: 14px 0; border-bottom: 1px solid var(--border); }
|
||||
section:last-child { border-bottom: none; }
|
||||
h4 { margin: 0 0 10px; font-size: 13px; color: var(--text-dim); font-weight: 600; letter-spacing: 0.5px; }
|
||||
label { display: block; margin: 10px 0; }
|
||||
label > span { display: block; font-size: 13px; margin-bottom: 5px; }
|
||||
label input, label select {
|
||||
width: 100%; padding: 8px 10px; border: 1px solid var(--border);
|
||||
background: var(--surface-2); color: var(--text);
|
||||
border-radius: 8px; font: inherit; font-size: 13px; outline: none;
|
||||
}
|
||||
label input:focus, label select:focus { border-color: var(--primary); }
|
||||
.hint { font-size: 12px; color: var(--text-dim); margin: 6px 0 0; }
|
||||
.hint code { background: var(--surface-2); padding: 1px 5px; border-radius: 4px; }
|
||||
|
||||
.test {
|
||||
width: 100%; margin-top: 4px; padding: 8px 0; border: 1px solid var(--border);
|
||||
background: var(--surface-2); color: var(--text); border-radius: 8px;
|
||||
font: inherit; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.test:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.test:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.test-result { margin-top: 8px; padding: 8px 12px; border-radius: 8px; font-size: 12px; line-height: 1.5; word-break: break-all; }
|
||||
.test-result.ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.test-result.err { background: var(--err-soft); color: var(--err); }
|
||||
.test-reply { margin-top: 4px; color: var(--text-dim); }
|
||||
|
||||
.d-foot {
|
||||
display: flex; align-items: center; gap: 12px; justify-content: flex-end;
|
||||
padding: 14px 20px; border-top: 1px solid var(--border); flex-shrink: 0;
|
||||
}
|
||||
.msg { font-size: 13px; margin-right: auto; }
|
||||
.msg.ok { color: var(--ok); }
|
||||
.msg.err { color: var(--err); }
|
||||
.save { padding: 9px 22px; border: none; border-radius: 9px; background: var(--primary); color: #fff; font-weight: 600; cursor: pointer; }
|
||||
.save:hover { background: var(--primary-hover); }
|
||||
.save:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.slide-enter-active, .slide-leave-active { transition: transform 0.25s ease, opacity 0.25s ease; }
|
||||
.slide-enter-from, .slide-leave-to { transform: translateX(100%); }
|
||||
</style>
|
||||
@@ -1,233 +0,0 @@
|
||||
<template>
|
||||
<div class="overlay" @click.self="$emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h3>⚙️ 设置</h3>
|
||||
<button class="x" @click="$emit('close')">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<section>
|
||||
<h4>日志</h4>
|
||||
<label class="row">
|
||||
<span>日志级别</span>
|
||||
<select v-model="form.LOG_LEVEL">
|
||||
<option value="debug">debug(记录全部对话)</option>
|
||||
<option value="info">info</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="error">error(仅错误)</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="hint">debug 级别会把所有对话、工具调用、审批记录到 <code>logs/</code> 目录。</p>
|
||||
<label class="row">
|
||||
<span>远程日志地址</span>
|
||||
<input v-model="form.REMOTE_LOG_URL" placeholder="http://127.0.0.1:3200/log,留空则只写本地" />
|
||||
</label>
|
||||
<p class="hint">填写远程日志接收器的 HTTP 地址后,每条日志会同时推送到该地址。</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>模型</h4>
|
||||
<label class="row">
|
||||
<span>接口地址</span>
|
||||
<input v-model="form.BASE_URL" placeholder="https://.../chat/completions 的上级路径" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span>API Key</span>
|
||||
<input v-model="form.API_KEY" type="password" placeholder="留空则不修改" autocomplete="off" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span>模型名称</span>
|
||||
<input v-model="form.MODEL" placeholder="如 ark-code-latest" />
|
||||
</label>
|
||||
<label class="row">
|
||||
<span>温度</span>
|
||||
<input v-model.number="form.TEMPERATURE" type="number" min="0" max="2" step="0.1" />
|
||||
</label>
|
||||
<div class="row test-row">
|
||||
<span></span>
|
||||
<button class="test" :disabled="testing" @click="testModel">
|
||||
{{ testing ? "测试中…" : "测试模型可用性" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="testMsg" class="test-result" :class="testOk ? 'ok' : 'err'">
|
||||
<div>{{ testMsg }}</div>
|
||||
<div v-if="testReply" class="test-reply">{{ testReply }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4>命令授权级别</h4>
|
||||
<label class="row">
|
||||
<span>级别</span>
|
||||
<select v-model="form.CMD_APPROVE_LEVEL">
|
||||
<option value="high">高 · 每个命令都审核</option>
|
||||
<option value="medium">中 · 仅删除等危险命令审核(默认)</option>
|
||||
<option value="low">低 · 全部不审核</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="hint">控制执行 Shell 命令时是否需要你审批。</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<span v-if="msg" class="msg" :class="ok ? 'ok' : 'err'">{{ msg }}</span>
|
||||
<button class="save" :disabled="saving" @click="save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue";
|
||||
|
||||
const props = defineProps<{ apiBase: string }>();
|
||||
defineEmits<{ close: [] }>();
|
||||
|
||||
const form = reactive({
|
||||
BASE_URL: "",
|
||||
API_KEY: "",
|
||||
MODEL: "",
|
||||
CMD_APPROVE_LEVEL: "medium",
|
||||
LOG_LEVEL: "debug",
|
||||
TEMPERATURE: 0.3,
|
||||
REMOTE_LOG_URL: "",
|
||||
});
|
||||
const saving = ref(false);
|
||||
const msg = ref("");
|
||||
const ok = ref(true);
|
||||
const testing = ref(false);
|
||||
const testMsg = ref("");
|
||||
const testOk = ref(true);
|
||||
const testReply = ref("");
|
||||
|
||||
async function testModel() {
|
||||
testing.value = true;
|
||||
testMsg.value = "";
|
||||
testReply.value = "";
|
||||
try {
|
||||
const r = await fetch(`${props.apiBase}/api/test_model`, { method: "POST" });
|
||||
const d = await r.json();
|
||||
testOk.value = !!d.ok;
|
||||
if (d.ok) {
|
||||
testMsg.value = `✅ 模型可用 · ${d.model || "未知"} · 延迟 ${d.latencyMs}ms`;
|
||||
testReply.value = d.reply || "";
|
||||
} else {
|
||||
testMsg.value = `❌ ${d.error || "测试失败"}` + (d.status ? ` · HTTP ${d.status}` : "");
|
||||
}
|
||||
} catch (e: any) {
|
||||
testOk.value = false;
|
||||
testMsg.value = "❌ 请求失败:" + e.message;
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await fetch(`${props.apiBase}/api/settings`);
|
||||
const d = await r.json();
|
||||
form.BASE_URL = d.BASE_URL || "";
|
||||
form.MODEL = d.MODEL || "";
|
||||
form.CMD_APPROVE_LEVEL = d.CMD_APPROVE_LEVEL || "medium";
|
||||
form.LOG_LEVEL = d.LOG_LEVEL || "debug";
|
||||
form.TEMPERATURE = typeof d.TEMPERATURE === "number" ? d.TEMPERATURE : 0.3;
|
||||
form.REMOTE_LOG_URL = d.REMOTE_LOG_URL || "";
|
||||
// API_KEY 返回脱敏值,不回填明文
|
||||
} catch {
|
||||
msg.value = "加载设置失败";
|
||||
ok.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
saving.value = true;
|
||||
msg.value = "";
|
||||
try {
|
||||
const r = await fetch(`${props.apiBase}/api/settings`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
BASE_URL: form.BASE_URL,
|
||||
API_KEY: form.API_KEY,
|
||||
MODEL: form.MODEL,
|
||||
CMD_APPROVE_LEVEL: form.CMD_APPROVE_LEVEL,
|
||||
LOG_LEVEL: form.LOG_LEVEL,
|
||||
TEMPERATURE: form.TEMPERATURE,
|
||||
REMOTE_LOG_URL: form.REMOTE_LOG_URL,
|
||||
}),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.ok) {
|
||||
msg.value = "已保存";
|
||||
ok.value = true;
|
||||
form.API_KEY = "";
|
||||
} else {
|
||||
msg.value = "保存失败";
|
||||
ok.value = false;
|
||||
}
|
||||
} catch (e: any) {
|
||||
msg.value = "保存失败:" + e.message;
|
||||
ok.value = false;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed; inset: 0; background: rgba(15, 20, 27, 0.55);
|
||||
backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal {
|
||||
width: min(520px, 92vw); max-height: 88vh; overflow: hidden;
|
||||
background: var(--surface); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 16px; box-shadow: var(--shadow);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.modal-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-head h3 { margin: 0; font-size: 16px; }
|
||||
.x { background: none; border: none; font-size: 18px; cursor: pointer; color: var(--text-dim); }
|
||||
.body { padding: 8px 20px; overflow-y: auto; }
|
||||
section { padding: 12px 0; border-bottom: 1px solid var(--border); }
|
||||
section:last-child { border-bottom: none; }
|
||||
h4 { margin: 0 0 10px; font-size: 13px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.row { display: flex; align-items: center; gap: 12px; margin: 8px 0; }
|
||||
.row > span { width: 84px; flex-shrink: 0; font-size: 13px; }
|
||||
.row.check { gap: 8px; }
|
||||
.row.check > span { width: auto; }
|
||||
.row input, .row select {
|
||||
flex: 1; padding: 8px 10px; border: 1px solid var(--border);
|
||||
background: var(--surface-2); color: var(--text);
|
||||
border-radius: 8px; font: inherit; font-size: 13px; outline: none;
|
||||
}
|
||||
.row input:focus, .row select:focus { border-color: var(--primary); }
|
||||
.hint { font-size: 12px; color: var(--text-dim); margin: 4px 0 0; }
|
||||
.hint code { background: var(--surface-2); padding: 1px 5px; border-radius: 4px; }
|
||||
.footer { display: flex; align-items: center; gap: 12px; justify-content: flex-end; padding: 14px 20px; border-top: 1px solid var(--border); }
|
||||
.msg { font-size: 13px; margin-right: auto; }
|
||||
.msg.ok { color: #16a34a; }
|
||||
.msg.err { color: #dc2626; }
|
||||
.save { padding: 9px 20px; border: none; border-radius: 9px; background: var(--primary); color: #fff; font-weight: 600; cursor: pointer; }
|
||||
.save:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.test-row { margin-top: 10px; }
|
||||
.test {
|
||||
flex: 0 0 auto; padding: 8px 14px; border: 1px solid var(--border);
|
||||
background: var(--surface-2); color: var(--text); border-radius: 8px;
|
||||
font: inherit; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.test:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.test:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.test-result {
|
||||
margin: 6px 0 0 96px; padding: 8px 12px; border-radius: 8px;
|
||||
font-size: 12px; line-height: 1.5; word-break: break-all;
|
||||
}
|
||||
.test-result.ok { background: rgba(22, 163, 74, 0.12); color: #16a34a; }
|
||||
.test-result.err { background: rgba(220, 38, 38, 0.12); color: #dc2626; }
|
||||
.test-reply { margin-top: 4px; color: var(--text-dim); }
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="tool-card" :class="statusClass">
|
||||
<div class="tc-head">
|
||||
<span class="tc-icon">{{ info?.icon || "🛠️" }}</span>
|
||||
<span class="tc-name">{{ info?.label || tool }}</span>
|
||||
<span class="tc-status" :class="statusClass">{{ statusText }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="command" class="tc-cmd"><pre>{{ command }}</pre></div>
|
||||
<div v-else-if="hasArgs" class="tc-args">{{ fmtArgs }}</div>
|
||||
|
||||
<div v-if="status === 'failed' && result" class="tc-result err">{{ result }}</div>
|
||||
<div v-else-if="status === 'done' && result" class="tc-result ok">{{ result }}</div>
|
||||
|
||||
<div v-if="status === 'approval'" class="tc-btns">
|
||||
<button class="allow" @click="$emit('approve')">允许</button>
|
||||
<button class="deny" @click="$emit('reject')">拒绝</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { TOOLS } from "../data/tools";
|
||||
|
||||
interface ToolEvent {
|
||||
tool: string;
|
||||
args?: any;
|
||||
command?: string;
|
||||
status: "approval" | "running" | "done" | "failed" | "denied";
|
||||
result?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{ tool: ToolEvent }>();
|
||||
defineEmits<{ approve: []; reject: [] }>();
|
||||
|
||||
const info = computed(() => TOOLS.find((t) => t.name === props.tool.tool));
|
||||
|
||||
const statusText = computed(() => {
|
||||
const map: Record<ToolEvent["status"], string> = {
|
||||
approval: "等待审批",
|
||||
running: "执行中",
|
||||
done: "已完成",
|
||||
failed: "失败",
|
||||
denied: "已拒绝",
|
||||
};
|
||||
return map[props.tool.status];
|
||||
});
|
||||
|
||||
const statusClass = computed(() => props.tool.status);
|
||||
|
||||
const hasArgs = computed(() => {
|
||||
const a = props.tool.args;
|
||||
return a && typeof a === "object" && Object.keys(a).length > 0;
|
||||
});
|
||||
|
||||
const fmtArgs = computed(() => {
|
||||
try {
|
||||
return JSON.stringify(props.tool.args, null, 2);
|
||||
} catch {
|
||||
return String(props.tool.args ?? "");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tool-card {
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--text-dim);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 10px 12px;
|
||||
max-width: 460px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tool-card.running { border-left-color: var(--primary); }
|
||||
.tool-card.done { border-left-color: var(--ok); }
|
||||
.tool-card.failed { border-left-color: var(--err); }
|
||||
.tool-card.denied { border-left-color: var(--text-dim); }
|
||||
.tool-card.approval { border-left-color: var(--warn); background: var(--warn-soft); }
|
||||
|
||||
.tc-head { display: flex; align-items: center; gap: 8px; }
|
||||
.tc-icon { font-size: 16px; line-height: 1; }
|
||||
.tc-name { font-weight: 600; }
|
||||
.tc-status {
|
||||
margin-left: auto; font-size: 11px; font-weight: 600;
|
||||
padding: 2px 8px; border-radius: 99px; flex-shrink: 0;
|
||||
}
|
||||
.tc-status.running { background: var(--primary-soft); color: var(--primary); }
|
||||
.tc-status.done { background: var(--ok-soft); color: var(--ok); }
|
||||
.tc-status.failed { background: var(--err-soft); color: var(--err); }
|
||||
.tc-status.denied { background: var(--surface-2); color: var(--text-dim); }
|
||||
.tc-status.approval { background: var(--warn-soft); color: var(--warn); }
|
||||
|
||||
.tc-cmd pre {
|
||||
margin: 8px 0 0; background: var(--code-bg); color: var(--code-text);
|
||||
border-radius: 8px; padding: 8px 10px; font-size: 12px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
.tc-args {
|
||||
margin-top: 8px; font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px; color: var(--text-dim); white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
.tc-result {
|
||||
margin-top: 8px; padding: 8px 10px; border-radius: 8px;
|
||||
font-size: 12px; line-height: 1.5; max-height: 160px; overflow-y: auto;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
.tc-result.ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.tc-result.err { background: var(--err-soft); color: var(--err); }
|
||||
|
||||
.tc-btns { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.tc-btns button {
|
||||
flex: 1; padding: 7px 0; border: none; border-radius: 8px;
|
||||
font-weight: 600; font-size: 13px; cursor: pointer; transition: 0.15s;
|
||||
}
|
||||
.tc-btns .allow { background: var(--ok); color: #fff; }
|
||||
.tc-btns .allow:hover { filter: brightness(1.1); }
|
||||
.tc-btns .deny { background: var(--err); color: #fff; }
|
||||
.tc-btns .deny:hover { filter: brightness(1.1); }
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
const theme = ref<"light" | "dark">("light");
|
||||
|
||||
function applyTheme(t: "light" | "dark") {
|
||||
document.documentElement.classList.toggle("dark", t === "dark");
|
||||
}
|
||||
|
||||
export function initTheme() {
|
||||
const saved = localStorage.getItem("theme");
|
||||
if (saved === "dark" || saved === "light") theme.value = saved;
|
||||
applyTheme(theme.value);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
function toggle() {
|
||||
theme.value = theme.value === "dark" ? "light" : "dark";
|
||||
localStorage.setItem("theme", theme.value);
|
||||
applyTheme(theme.value);
|
||||
}
|
||||
return { theme, toggle };
|
||||
}
|
||||
+67
-4
@@ -1,4 +1,60 @@
|
||||
/* Mini AI Assistant - 全局基础样式 */
|
||||
/* MoneyPrinterClaw 风格全局主题
|
||||
设计令牌:浅色/暗色双主题,红色主色 + 低饱和灰阶,圆角卡片。
|
||||
参照 Streamlit 默认主题(MPC WebUI 所用):主色 #FF4B4B */
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f0f2f6;
|
||||
--surface-3: #fafbfc;
|
||||
--text: #31333f;
|
||||
--text-dim: #7c8194;
|
||||
--border: #e6eaf1;
|
||||
--primary: #ff4b4b;
|
||||
--primary-hover: #e13b3b;
|
||||
--primary-soft: #fff0f0;
|
||||
--ok: #2ba04a;
|
||||
--ok-soft: #e8f7ec;
|
||||
--warn: #d97706;
|
||||
--warn-soft: #fdf3e3;
|
||||
--err: #d94848;
|
||||
--err-soft: #fdeaea;
|
||||
--code-bg: #1e2430;
|
||||
--code-text: #e6edf3;
|
||||
--user-bubble: #ff4b4b;
|
||||
--user-text: #ffffff;
|
||||
--assistant-bubble: #ffffff;
|
||||
--shadow: 0 1px 2px rgba(20, 26, 40, 0.05), 0 6px 20px rgba(20, 26, 40, 0.05);
|
||||
--shadow-lg: 0 4px 12px rgba(20, 26, 40, 0.08), 0 16px 40px rgba(20, 26, 40, 0.1);
|
||||
--radius: 12px;
|
||||
--radius-lg: 16px;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--bg: #0e1117;
|
||||
--surface: #161a22;
|
||||
--surface-2: #1f242e;
|
||||
--surface-3: #181d26;
|
||||
--text: #f0f2f6;
|
||||
--text-dim: #9aa2b3;
|
||||
--border: #2a303c;
|
||||
--primary: #ff5c5c;
|
||||
--primary-hover: #ff7070;
|
||||
--primary-soft: #3a2226;
|
||||
--ok: #3fb964;
|
||||
--ok-soft: #173023;
|
||||
--warn: #e8a33d;
|
||||
--warn-soft: #352a14;
|
||||
--err: #ff6b6b;
|
||||
--err-soft: #3a1f1f;
|
||||
--code-bg: #0b0e14;
|
||||
--code-text: #e6edf3;
|
||||
--user-bubble: #ff4b4b;
|
||||
--user-text: #ffffff;
|
||||
--assistant-bubble: #1c212b;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 6px 20px rgba(0, 0, 0, 0.35);
|
||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.5), 0 16px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
@@ -11,8 +67,15 @@ body {
|
||||
"Microsoft YaHei", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border, #ccc); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-dim, #999); }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; border: 2px solid transparent; background-clip: content-box; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); background-clip: content-box; }
|
||||
|
||||
button { font-family: inherit; }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// 按需加载 highlight.js(仅注册本助手常用语言,显著减小打包体积)
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import javascript from "highlight.js/lib/languages/javascript";
|
||||
import typescript from "highlight.js/lib/languages/typescript";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
import sql from "highlight.js/lib/languages/sql";
|
||||
import bash from "highlight.js/lib/languages/bash";
|
||||
import powershell from "highlight.js/lib/languages/powershell";
|
||||
import python from "highlight.js/lib/languages/python";
|
||||
import xml from "highlight.js/lib/languages/xml";
|
||||
import css from "highlight.js/lib/languages/css";
|
||||
import markdown from "highlight.js/lib/languages/markdown";
|
||||
import yaml from "highlight.js/lib/languages/yaml";
|
||||
import java from "highlight.js/lib/languages/java";
|
||||
import csharp from "highlight.js/lib/languages/csharp";
|
||||
import cpp from "highlight.js/lib/languages/cpp";
|
||||
|
||||
hljs.registerLanguage("javascript", javascript);
|
||||
hljs.registerLanguage("typescript", typescript);
|
||||
hljs.registerLanguage("json", json);
|
||||
hljs.registerLanguage("sql", sql);
|
||||
hljs.registerLanguage("bash", bash);
|
||||
hljs.registerLanguage("powershell", powershell);
|
||||
hljs.registerLanguage("python", python);
|
||||
hljs.registerLanguage("xml", xml);
|
||||
hljs.registerLanguage("css", css);
|
||||
hljs.registerLanguage("markdown", markdown);
|
||||
hljs.registerLanguage("yaml", yaml);
|
||||
hljs.registerLanguage("java", java);
|
||||
hljs.registerLanguage("csharp", csharp);
|
||||
hljs.registerLanguage("cpp", cpp);
|
||||
|
||||
export default hljs;
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import "./index.css";
|
||||
import "highlight.js/styles/atom-one-dark.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
createApp(App).use(router).mount("#app");
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div class="chat-page">
|
||||
<aside class="sidebar">
|
||||
<button class="new-chat" @click="newConversation">+ 新建对话</button>
|
||||
<div class="conv-list">
|
||||
<div
|
||||
v-for="c in conversations"
|
||||
:key="c.id"
|
||||
class="conv-item"
|
||||
:class="{ active: c.id === conversationId }"
|
||||
@click="selectConversation(c.id)"
|
||||
>
|
||||
<span class="conv-title">{{ c.title }}</span>
|
||||
<button class="del" title="删除对话" @click.stop="deleteConversation(c.id)">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-foot">共 {{ conversations.length }} 个会话</div>
|
||||
</aside>
|
||||
|
||||
<div class="main-col">
|
||||
<div class="chat-toolbar">
|
||||
<span class="conv-name">{{ currentTitle }}</span>
|
||||
<button class="clear-btn" title="清空当前对话" @click="clearChat">清空</button>
|
||||
</div>
|
||||
|
||||
<main class="chat" ref="chatEl">
|
||||
<div v-if="messages.length === 0" class="empty">
|
||||
<div class="empty-icon">💬</div>
|
||||
<h2>开始对话</h2>
|
||||
<p>小鑫可以读取文件、处理 Excel、执行命令(危险操作需审批)。</p>
|
||||
<div class="suggest">
|
||||
<button v-for="s in suggestions" :key="s" @click="send(s)">{{ s }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageList
|
||||
v-else
|
||||
:messages="messages"
|
||||
@approve="onApprove"
|
||||
@reject="onReject"
|
||||
/>
|
||||
</main>
|
||||
|
||||
<InputArea :disabled="busy" @send="send" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted } from "vue";
|
||||
import { API_BASE } from "../api";
|
||||
import MessageList from "../components/MessageList.vue";
|
||||
import InputArea from "../components/InputArea.vue";
|
||||
import type { ToolEvent } from "../components/MessageBubble.vue";
|
||||
|
||||
type Role = "user" | "assistant" | "system";
|
||||
interface Msg {
|
||||
id: number;
|
||||
role: Role;
|
||||
content: string;
|
||||
loading?: boolean;
|
||||
tools?: ToolEvent[];
|
||||
approval?: {
|
||||
tool: string;
|
||||
args: any;
|
||||
command: string;
|
||||
tool_call_id: string;
|
||||
};
|
||||
}
|
||||
|
||||
const messages = ref<Msg[]>([]);
|
||||
const conversations = ref<{ id: string; title: string; updatedAt: number; count: number }[]>([]);
|
||||
const conversationId = ref("");
|
||||
const busy = ref(false);
|
||||
const chatEl = ref<HTMLElement | null>(null);
|
||||
const suggestions = [
|
||||
"列出当前目录的文件",
|
||||
"读取 data.xlsx 并告诉我前两行",
|
||||
"帮我创建一个 hello.txt 写入你好",
|
||||
];
|
||||
|
||||
let idSeq = 1;
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
chatEl.value?.scrollTo({ top: chatEl.value.scrollHeight, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const currentTitle = computed(
|
||||
() => conversations.value.find((c) => c.id === conversationId.value)?.title || ""
|
||||
);
|
||||
|
||||
function pushMsg(role: Role, content = ""): Msg {
|
||||
const m: Msg = { id: idSeq++, role, content };
|
||||
messages.value.push(m);
|
||||
return messages.value[messages.value.length - 1];
|
||||
}
|
||||
|
||||
// 读取 SSE 流
|
||||
async function consumeStream(response: Response, target: Msg) {
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith("data:")) continue;
|
||||
const data = t.slice(5).trim();
|
||||
let ev: any;
|
||||
try { ev = JSON.parse(data); } catch { continue; }
|
||||
|
||||
if (ev.type === "token" && ev.content) {
|
||||
target.content += ev.content;
|
||||
scrollToBottom();
|
||||
} else if (ev.type === "tool_start") {
|
||||
if (!target.tools) target.tools = [];
|
||||
target.tools.push({
|
||||
tool: ev.tool,
|
||||
args: ev.args,
|
||||
command: ev.command,
|
||||
status: "running",
|
||||
});
|
||||
scrollToBottom();
|
||||
} else if (ev.type === "tool_result") {
|
||||
const card = findToolCard(target, ev);
|
||||
if (card) {
|
||||
card.status = ev.ok ? "done" : "failed";
|
||||
card.result = typeof ev.result === "string" ? ev.result.slice(0, 400) : safeStr(ev.result);
|
||||
}
|
||||
scrollToBottom();
|
||||
} else if (ev.type === "tool_error") {
|
||||
const card = findToolCard(target, ev);
|
||||
if (card) {
|
||||
card.status = "failed";
|
||||
card.result = ev.content || "工具执行失败";
|
||||
}
|
||||
scrollToBottom();
|
||||
} else if (ev.type === "approval_required") {
|
||||
target.loading = false;
|
||||
target.approval = {
|
||||
tool: ev.tool,
|
||||
args: ev.args,
|
||||
command: ev.command,
|
||||
tool_call_id: ev.tool_call_id,
|
||||
};
|
||||
busy.value = false;
|
||||
return;
|
||||
} else if (ev.type === "done") {
|
||||
target.loading = false;
|
||||
busy.value = false;
|
||||
scrollToBottom();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
target.loading = false;
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
function findToolCard(target: Msg, ev: any) {
|
||||
if (!target.tools?.length) return null;
|
||||
if (ev.id) {
|
||||
const byId = target.tools.find((c) => (c as any).id === ev.id);
|
||||
if (byId) return byId;
|
||||
}
|
||||
return [...target.tools].reverse().find((c) => c.tool === ev.tool && c.status === "running") || null;
|
||||
}
|
||||
|
||||
function safeStr(v: any): string {
|
||||
try {
|
||||
return typeof v === "string" ? v : JSON.stringify(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
|
||||
async function send(text: string) {
|
||||
const content = text.trim();
|
||||
if (!content || busy.value) return;
|
||||
if (!conversationId.value) await newConversation();
|
||||
busy.value = true;
|
||||
pushMsg("user", content);
|
||||
const assistant = pushMsg("assistant", "");
|
||||
assistant.loading = true;
|
||||
scrollToBottom();
|
||||
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
conversationId: conversationId.value || undefined,
|
||||
messages: messages.value
|
||||
.filter((m) => !m.approval && m.content && m.role !== "assistant")
|
||||
.map((m) => ({ role: m.role, content: m.content })),
|
||||
}),
|
||||
});
|
||||
await consumeStream(resp, assistant);
|
||||
await saveConversation();
|
||||
} catch (e: any) {
|
||||
assistant.content = "⚠️ 请求失败:" + e.message;
|
||||
assistant.loading = false;
|
||||
busy.value = false;
|
||||
await saveConversation();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecision(approved: boolean, m: Msg) {
|
||||
if (!m.approval) return;
|
||||
const toolCallId = m.approval.tool_call_id;
|
||||
// 移除审批卡片消息,避免留下空气泡
|
||||
messages.value = messages.value.filter((x) => x.id !== m.id);
|
||||
busy.value = true;
|
||||
const assistant = pushMsg("assistant", "");
|
||||
assistant.loading = true;
|
||||
scrollToBottom();
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ approved, tool_call_id: toolCallId, conversationId: conversationId.value || undefined }),
|
||||
});
|
||||
await consumeStream(resp, assistant);
|
||||
await saveConversation();
|
||||
} catch (e: any) {
|
||||
assistant.content = "⚠️ 请求失败:" + e.message;
|
||||
assistant.loading = false;
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const onApprove = (payload: any) => {
|
||||
const m = payload && payload.tool_call_id ? payload : messages.value.find((x) => x.approval);
|
||||
if (m) handleDecision(true, m);
|
||||
};
|
||||
const onReject = (payload: any) => {
|
||||
const m = payload && payload.tool_call_id ? payload : messages.value.find((x) => x.approval);
|
||||
if (m) handleDecision(false, m);
|
||||
};
|
||||
|
||||
function clearChat() {
|
||||
messages.value = [];
|
||||
saveConversation();
|
||||
}
|
||||
|
||||
// ---------- 会话记忆 ----------
|
||||
async function refreshList() {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/conversations`);
|
||||
conversations.value = await r.json();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function newConversation() {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/conversations`, { method: "POST" });
|
||||
const conv = await r.json();
|
||||
conversationId.value = conv.id;
|
||||
messages.value = [];
|
||||
await refreshList();
|
||||
} catch (e: any) {
|
||||
alert("新建对话失败:" + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectConversation(id: string) {
|
||||
if (id === conversationId.value) return;
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/conversations/${id}`);
|
||||
if (!r.ok) return;
|
||||
const conv = await r.json();
|
||||
conversationId.value = id;
|
||||
messages.value = (conv.messages || []).map((x: any) => ({
|
||||
id: idSeq++,
|
||||
role: x.role === "assistant" ? "assistant" : "user",
|
||||
content: x.content || "",
|
||||
}));
|
||||
await refreshList();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function deleteConversation(id: string) {
|
||||
await fetch(`${API_BASE}/api/conversations/${id}`, { method: "DELETE" });
|
||||
if (id === conversationId.value) {
|
||||
conversationId.value = "";
|
||||
messages.value = [];
|
||||
}
|
||||
await refreshList();
|
||||
}
|
||||
|
||||
async function saveConversation() {
|
||||
if (!conversationId.value) return;
|
||||
const msgs = messages.value
|
||||
.filter((m) => !m.approval)
|
||||
.map((m) => ({ role: m.role, content: m.content || "" }));
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/conversations/${conversationId.value}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: msgs }),
|
||||
});
|
||||
await refreshList();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshList();
|
||||
if (conversations.value.length) {
|
||||
await selectConversation(conversations.value[0].id);
|
||||
} else {
|
||||
await newConversation();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-page { display: flex; height: 100%; width: 100%; min-height: 0; }
|
||||
|
||||
.sidebar {
|
||||
width: 250px; flex-shrink: 0;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.new-chat {
|
||||
padding: 9px; border: 1px solid var(--primary);
|
||||
background: var(--primary); color: #fff; border-radius: 10px;
|
||||
font-weight: 600; font-size: 14px; cursor: pointer; transition: 0.15s;
|
||||
}
|
||||
.new-chat:hover { background: var(--primary-hover); }
|
||||
.conv-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.conv-item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 6px;
|
||||
padding: 8px 10px; border-radius: 9px; cursor: pointer;
|
||||
background: var(--surface-2); transition: 0.15s;
|
||||
}
|
||||
.conv-item:hover { background: var(--border); }
|
||||
.conv-item.active { background: var(--primary); color: #fff; }
|
||||
.conv-title {
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
.conv-item .del {
|
||||
background: none; border: none; color: inherit; opacity: 0.6;
|
||||
cursor: pointer; font-size: 12px; flex-shrink: 0;
|
||||
}
|
||||
.conv-item .del:hover { opacity: 1; }
|
||||
.sidebar-foot { margin-top: auto; text-align: center; font-size: 11px; color: var(--text-dim); }
|
||||
|
||||
.main-col {
|
||||
flex: 1; min-width: 0;
|
||||
display: flex; flex-direction: column; height: 100%;
|
||||
max-width: 900px; margin: 0 auto; width: 100%;
|
||||
}
|
||||
|
||||
.chat-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 20px; flex-shrink: 0;
|
||||
}
|
||||
.conv-name {
|
||||
font-size: 13px; color: var(--text-dim); font-weight: 500;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.clear-btn {
|
||||
border: 1px solid var(--border); background: var(--surface-2);
|
||||
color: var(--text-dim); font-size: 12px; padding: 5px 12px;
|
||||
border-radius: 8px; cursor: pointer; transition: 0.15s;
|
||||
}
|
||||
.clear-btn:hover { border-color: var(--err); color: var(--err); }
|
||||
|
||||
.chat {
|
||||
flex: 1; overflow-y: auto; padding: 12px 20px 8px;
|
||||
display: flex; flex-direction: column; gap: 18px;
|
||||
}
|
||||
|
||||
.empty { margin: auto; text-align: center; color: var(--text-dim); max-width: 420px; }
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty h2 { color: var(--text); margin: 0 0 8px; }
|
||||
.suggest { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 18px; }
|
||||
.suggest button {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
color: var(--text); padding: 8px 14px; border-radius: 20px;
|
||||
cursor: pointer; font-size: 13px; transition: 0.15s;
|
||||
}
|
||||
.suggest button:hover { border-color: var(--primary); color: var(--primary); }
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<template>
|
||||
<div class="skills">
|
||||
<div class="skills-head">
|
||||
<h2>🛠️ 技能管理</h2>
|
||||
<p>小鑫可调用的工具清单。审批类操作在执行前会先征得你的同意。</p>
|
||||
<div class="search">
|
||||
<input v-model="kw" placeholder="搜索技能名称或用途…" />
|
||||
<span class="count">{{ merged.length }} / {{ TOOLS.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-for="cat in categoriesShown" :key="cat" class="cat">
|
||||
<h3>{{ cat }}</h3>
|
||||
<div class="grid">
|
||||
<div v-for="t in byCategoryMerged(cat)" :key="t.name" class="card" :class="{ open: expanded === t.name }" @click="expanded = expanded === t.name ? '' : t.name">
|
||||
<div class="card-head">
|
||||
<span class="icon">{{ t.icon }}</span>
|
||||
<div class="title">
|
||||
<div class="name">{{ t.label }} <code>{{ t.name }}</code></div>
|
||||
<div class="desc">{{ t.description }}</div>
|
||||
</div>
|
||||
<span v-if="t.approval" class="badge warn">审批</span>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="expanded === t.name" class="detail" @click.stop>
|
||||
<div class="params">
|
||||
<div v-for="p in t.params" :key="p.name" class="param">
|
||||
<code>{{ p.name }}</code>
|
||||
<span v-if="p.required" class="req">必填</span>
|
||||
<span v-else class="opt">可选</span>
|
||||
<em>{{ p.desc }}</em>
|
||||
</div>
|
||||
</div>
|
||||
<div class="examples">
|
||||
<div v-for="(ex, i) in t.examples" :key="i" class="ex">
|
||||
<code>{{ ex }}</code>
|
||||
<button @click="copy(ex)">复制</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { API_BASE } from "../api";
|
||||
import { TOOLS, CATEGORIES, type ToolInfo } from "../data/tools";
|
||||
|
||||
const kw = ref("");
|
||||
const expanded = ref("");
|
||||
const apiTools = ref<any[] | null>(null);
|
||||
|
||||
const filtered = computed(() => {
|
||||
const k = kw.value.trim().toLowerCase();
|
||||
if (!k) return TOOLS;
|
||||
return TOOLS.filter(
|
||||
(t) =>
|
||||
t.label.toLowerCase().includes(k) ||
|
||||
t.name.toLowerCase().includes(k) ||
|
||||
t.description.toLowerCase().includes(k) ||
|
||||
t.category.toLowerCase().includes(k)
|
||||
);
|
||||
});
|
||||
|
||||
function byCategory(cat: string) {
|
||||
return filtered.value.filter((t) => t.category === cat);
|
||||
}
|
||||
|
||||
async function copy(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 优先读取后端 /api/tools 的权威元数据,合并静态目录中的示例
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/api/tools`);
|
||||
if (r.ok) apiTools.value = await r.json();
|
||||
} catch {}
|
||||
});
|
||||
|
||||
const merged = computed<ToolInfo[]>(() => {
|
||||
if (!apiTools.value) return TOOLS;
|
||||
return apiTools.value.map((at: any) => {
|
||||
const st = TOOLS.find((t) => t.name === at.name);
|
||||
const params = at.parameters?.properties
|
||||
? Object.entries(at.parameters.properties).map(([n, p]: [string, any]) => ({
|
||||
name: n,
|
||||
required: at.parameters.required?.includes(n),
|
||||
desc: p.description || "",
|
||||
}))
|
||||
: [];
|
||||
const cat = CATEGORIES.includes(at.category) ? at.category : "其他";
|
||||
return {
|
||||
name: at.name,
|
||||
label: at.label || at.name,
|
||||
icon: at.icon || "🛠️",
|
||||
category: cat,
|
||||
description: at.description || "",
|
||||
params,
|
||||
approval: at.approvalNote,
|
||||
examples: st?.examples || [],
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// 分组展示使用合并后的数据;保留静态类别顺序
|
||||
const categoriesShown = computed(() => {
|
||||
const cats = new Set<string>();
|
||||
merged.value.forEach((t) => cats.add(t.category));
|
||||
return CATEGORIES.filter((c) => cats.has(c)).concat(
|
||||
[...cats].filter((c) => !CATEGORIES.includes(c))
|
||||
);
|
||||
});
|
||||
|
||||
function byCategoryMerged(cat: string) {
|
||||
const k = kw.value.trim().toLowerCase();
|
||||
return merged.value.filter(
|
||||
(t) =>
|
||||
t.category === cat &&
|
||||
(!k ||
|
||||
t.label.toLowerCase().includes(k) ||
|
||||
t.name.toLowerCase().includes(k) ||
|
||||
t.description.toLowerCase().includes(k))
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.skills { padding: 24px 28px 40px; max-width: 960px; margin: 0 auto; }
|
||||
.skills-head h2 { margin: 0 0 4px; font-size: 20px; }
|
||||
.skills-head p { margin: 0 0 14px; color: var(--text-dim); font-size: 13px; }
|
||||
.search {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 4px 6px 4px 12px; box-shadow: var(--shadow);
|
||||
}
|
||||
.search input { flex: 1; border: none; outline: none; background: transparent; color: var(--text); font: inherit; font-size: 14px; padding: 8px 0; }
|
||||
.search .count { font-size: 12px; color: var(--text-dim); padding-right: 8px; }
|
||||
|
||||
.cat { margin-top: 24px; }
|
||||
.cat h3 { font-size: 13px; color: var(--text-dim); font-weight: 600; letter-spacing: 0.5px; margin: 0 0 10px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 14px 16px; cursor: pointer;
|
||||
box-shadow: var(--shadow); transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.card:hover { border-color: var(--primary); }
|
||||
.card.open { border-color: var(--primary); box-shadow: var(--shadow-lg); }
|
||||
.card-head { display: flex; gap: 12px; align-items: flex-start; }
|
||||
.icon { font-size: 22px; line-height: 1; margin-top: 2px; }
|
||||
.title { flex: 1; min-width: 0; }
|
||||
.name { font-weight: 600; font-size: 14px; }
|
||||
.name code { font-size: 11px; color: var(--text-dim); font-weight: 400; margin-left: 4px; }
|
||||
.desc { font-size: 12.5px; color: var(--text-dim); margin-top: 3px; line-height: 1.5; }
|
||||
.badge {
|
||||
flex-shrink: 0; font-size: 11px; font-weight: 600;
|
||||
padding: 2px 8px; border-radius: 99px;
|
||||
}
|
||||
.badge.warn { background: var(--warn-soft); color: var(--warn); }
|
||||
|
||||
.detail { border-top: 1px solid var(--border); margin-top: 12px; padding-top: 10px; }
|
||||
.param { display: flex; align-items: center; gap: 8px; margin: 6px 0; font-size: 12.5px; }
|
||||
.param code { background: var(--surface-2); padding: 2px 6px; border-radius: 5px; font-size: 12px; }
|
||||
.param .req { color: var(--err); font-size: 11px; }
|
||||
.param .opt { color: var(--text-dim); font-size: 11px; }
|
||||
.param em { color: var(--text-dim); font-style: normal; margin-left: auto; text-align: right; }
|
||||
.examples { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.ex { display: flex; align-items: center; gap: 8px; background: var(--code-bg); border-radius: 8px; padding: 6px 8px 6px 10px; }
|
||||
.ex code { color: var(--code-text); font-size: 12px; flex: 1; word-break: break-all; }
|
||||
.ex button {
|
||||
flex-shrink: 0; background: rgba(255, 255, 255, 0.1); border: none;
|
||||
color: rgba(255, 255, 255, 0.8); font-size: 11px; padding: 3px 10px; border-radius: 6px; cursor: pointer;
|
||||
}
|
||||
.ex button:hover { background: rgba(255, 255, 255, 0.2); color: #fff; }
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
|
||||
const ChatPage = () => import("./pages/ChatPage.vue");
|
||||
const SkillsPage = () => import("./pages/SkillsPage.vue");
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: ChatPage },
|
||||
{ path: "/skills", component: SkillsPage },
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { createServer } from "http";
|
||||
import { existsSync, statSync, readFileSync } from "fs";
|
||||
import { readFile } from "fs/promises";
|
||||
import { resolve, extname, sep } from "path";
|
||||
import { config, saveEnv } from "./config";
|
||||
import { log, clip } from "./logger";
|
||||
import { listToolsForApi } from "./tools";
|
||||
import { runConversation, runApproved } from "./chat";
|
||||
import { conversationStore } from "./store";
|
||||
import type { ChatMsg } from "./types";
|
||||
|
||||
// ---------- 静态文件服务(生产构建) ----------
|
||||
const DIST = resolve("dist");
|
||||
const MIME: Record<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;
|
||||
}
|
||||
|
||||
async function readBody(req: any): Promise<string> {
|
||||
let body = "";
|
||||
for await (const c of req) body += c;
|
||||
return body;
|
||||
}
|
||||
|
||||
export function startServer() {
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url || "", `http://localhost:${config.PORT}`);
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
|
||||
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
||||
|
||||
if (req.method === "GET" && !url.pathname.startsWith("/api")) {
|
||||
if (await serveStatic(res, url)) return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok", model: config.MODEL || "default", workspace: config.WORKSPACE }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取设置
|
||||
if (req.method === "GET" && url.pathname === "/api/settings") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
BASE_URL: config.BASE_URL,
|
||||
API_KEY: config.API_KEY ? "******" + config.API_KEY.slice(-4) : "",
|
||||
MODEL: config.MODEL,
|
||||
CMD_APPROVE_LEVEL: config.CMD_APPROVE_LEVEL,
|
||||
LOG_LEVEL: config.LOG_LEVEL,
|
||||
TEMPERATURE: config.TEMPERATURE,
|
||||
REMOTE_LOG_URL: config.REMOTE_LOG_URL,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存设置(.env 按 key 增量更新)
|
||||
if (req.method === "POST" && url.pathname === "/api/settings") {
|
||||
let payload: any;
|
||||
try { payload = JSON.parse(await readBody(req)); } catch { res.writeHead(400); res.end("bad json"); return; }
|
||||
if (typeof payload.BASE_URL === "string" && payload.BASE_URL.trim()) config.BASE_URL = payload.BASE_URL.trim();
|
||||
// token 为空时不覆盖已有配置(保留原 token)
|
||||
if (typeof payload.API_KEY === "string" && payload.API_KEY.trim() && !payload.API_KEY.startsWith("******")) config.API_KEY = payload.API_KEY.trim();
|
||||
if (typeof payload.MODEL === "string" && payload.MODEL.trim()) config.MODEL = payload.MODEL.trim();
|
||||
if (payload.CMD_APPROVE_LEVEL === "high" || payload.CMD_APPROVE_LEVEL === "medium" || payload.CMD_APPROVE_LEVEL === "low") {
|
||||
config.CMD_APPROVE_LEVEL = payload.CMD_APPROVE_LEVEL;
|
||||
}
|
||||
if (typeof payload.LOG_LEVEL === "string") config.LOG_LEVEL = payload.LOG_LEVEL.toLowerCase();
|
||||
if (typeof payload.TEMPERATURE === "number") config.TEMPERATURE = payload.TEMPERATURE;
|
||||
if (typeof payload.REMOTE_LOG_URL === "string") config.REMOTE_LOG_URL = payload.REMOTE_LOG_URL.trim();
|
||||
saveEnv();
|
||||
log("info", "settings_updated", { CMD_APPROVE_LEVEL: config.CMD_APPROVE_LEVEL, MODEL: config.MODEL, LOG_LEVEL: config.LOG_LEVEL, REMOTE_LOG_URL: config.REMOTE_LOG_URL });
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 模型可用性测试
|
||||
if (req.method === "POST" && url.pathname === "/api/test_model") {
|
||||
const urlLLM = config.BASE_URL.replace(/\/$/, "") + "/chat/completions";
|
||||
const testBody: any = {
|
||||
messages: [{ role: "user", content: "你好,请回复:测试通过" }],
|
||||
stream: false,
|
||||
max_tokens: 32,
|
||||
temperature: config.TEMPERATURE,
|
||||
};
|
||||
if (config.MODEL) testBody.model = config.MODEL;
|
||||
if (!config.API_KEY) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, error: "未配置 API_KEY" }));
|
||||
return;
|
||||
}
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const r = await fetch(urlLLM, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.API_KEY}` },
|
||||
body: JSON.stringify(testBody),
|
||||
});
|
||||
const ms = Date.now() - t0;
|
||||
const txt = await r.text().catch(() => "");
|
||||
let reply = "";
|
||||
let errMsg = "";
|
||||
try {
|
||||
const j = JSON.parse(txt);
|
||||
reply = j.choices?.[0]?.message?.content || "";
|
||||
errMsg = j.error?.message || "";
|
||||
} catch {}
|
||||
if (r.ok) {
|
||||
log("info", "model_test", { ok: true, model: config.MODEL, latencyMs: ms, reply: clip(reply, 100) });
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, model: config.MODEL, latencyMs: ms, reply: reply.slice(0, 200) }));
|
||||
} else {
|
||||
log("warn", "model_test", { ok: false, status: r.status, error: errMsg || txt.slice(0, 200) });
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, status: r.status, latencyMs: ms, error: errMsg || txt.slice(0, 200) }));
|
||||
}
|
||||
} catch (e: any) {
|
||||
log("warn", "model_test", { ok: false, error: e.message });
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: false, latencyMs: Date.now() - t0, error: "无法连接模型服务: " + e.message }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 工具目录(A2:前端技能页数据源)
|
||||
if (req.method === "GET" && url.pathname === "/api/tools") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(listToolsForApi()));
|
||||
return;
|
||||
}
|
||||
|
||||
// 会话列表
|
||||
if (req.method === "GET" && url.pathname === "/api/conversations") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(conversationStore.listConvs()));
|
||||
return;
|
||||
}
|
||||
// 新建会话
|
||||
if (req.method === "POST" && url.pathname === "/api/conversations") {
|
||||
const conv = { id: conversationStore.genConvId(), title: "新对话", messages: [], updatedAt: Date.now() };
|
||||
conversationStore.writeConv(conv.id, conv);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(conv));
|
||||
return;
|
||||
}
|
||||
// 单个会话:读取 / 保存 / 删除
|
||||
const convMatch = url.pathname.match(/^\/api\/conversations\/([^/]+)$/);
|
||||
if (convMatch) {
|
||||
const cid = convMatch[1];
|
||||
if (req.method === "GET") {
|
||||
const conv = conversationStore.readConv(cid);
|
||||
if (!conv) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); return; }
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(conv));
|
||||
return;
|
||||
}
|
||||
if (req.method === "DELETE") {
|
||||
conversationStore.deleteConv(cid);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
let payload: any;
|
||||
try { payload = JSON.parse(await readBody(req)); } catch { res.writeHead(400); res.end("bad json"); return; }
|
||||
const old = conversationStore.readConv(cid) || { id: cid, messages: [] };
|
||||
const msgs = Array.isArray(payload.messages) ? payload.messages : old.messages;
|
||||
const first = msgs.find((x: any) => x.role === "user");
|
||||
const title = typeof payload.title === "string" && payload.title
|
||||
? payload.title
|
||||
: old.title && old.title !== "新对话"
|
||||
? old.title
|
||||
: first ? String(first.content || "").slice(0, 20) : "新对话";
|
||||
conversationStore.writeConv(cid, { id: cid, title, messages: msgs, updatedAt: Date.now() });
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ ok: true, title }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 聊天:流式 SSE
|
||||
if (req.method === "POST" && url.pathname === "/api/chat") {
|
||||
let payload: any;
|
||||
try { payload = JSON.parse(await readBody(req)); } catch { res.writeHead(400); res.end("bad json"); return; }
|
||||
const history: ChatMsg[] = (payload.messages || []).map((m: any) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
await runConversation(history, res, { conversationId: payload.conversationId });
|
||||
return;
|
||||
}
|
||||
|
||||
// 审批反馈:流式 SSE(继续对话)
|
||||
if (req.method === "POST" && url.pathname === "/api/approve") {
|
||||
let payload: any;
|
||||
try { payload = JSON.parse(await readBody(req)); } catch { res.writeHead(400); res.end("bad json"); return; }
|
||||
const approved = !!payload.approved;
|
||||
await runApproved(payload.conversationId, payload.tool_call_id, approved, res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("Not Found");
|
||||
});
|
||||
|
||||
server.listen(config.PORT, "127.0.0.1", () => {
|
||||
console.log(`Mini AI Assistant 后端已启动`);
|
||||
console.log(` API: http://127.0.0.1:${config.PORT}`);
|
||||
console.log(` 模型: ${config.BASE_URL}`);
|
||||
console.log(` 工作区: ${config.WORKSPACE}`);
|
||||
console.log(` 日志级别: ${config.LOG_LEVEL} → logs/`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve, dirname } from "path";
|
||||
import { config } from "./config";
|
||||
import { log, clip, safeStringify, fmtMessages } from "./logger";
|
||||
import { streamLLM, MAX_TOOL_ROUNDS } from "./llm";
|
||||
import { execTool, type ToolContext } from "./tools";
|
||||
import { needsApproval, storeApproval, takeApproval } from "./security";
|
||||
import { DuckDBAdapter } from "./duckdb";
|
||||
import type { ChatMsg } from "./types";
|
||||
|
||||
const duckdb = new DuckDBAdapter(config.DUCKDB_EXE, config.DUCKDB_FILE);
|
||||
|
||||
// 系统提示:优先读取项目根目录 system.md(详细工具使用指南),缺失时用内置提示
|
||||
const BASE_SYSTEM_PROMPT = "你是小鑫,一个专为冯雅鑫定制的本地 AI 助手,可以读写文件、处理 Excel、用 DuckDB 分析数据、列出目录、执行命令(需审批)等。请用简体中文友好、简洁地回答。";
|
||||
let SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
|
||||
try {
|
||||
const md = readFileSync(resolve(import.meta.dir ?? ".", "system.md"), "utf-8").trim();
|
||||
if (md) SYSTEM_PROMPT = md;
|
||||
} catch {
|
||||
/* 无 system.md 时使用内置提示 */
|
||||
}
|
||||
|
||||
function withSystem(msgs: ChatMsg[]): ChatMsg[] {
|
||||
if (msgs.length && msgs[0].role === "system") return msgs;
|
||||
return [{ role: "system", content: SYSTEM_PROMPT }, ...msgs];
|
||||
}
|
||||
|
||||
// ---------- 工具参数容错解析与路径自动补全 ----------
|
||||
// 解析模型返回的工具参数;非法 JSON 尝试修复,裸路径转为 {path}
|
||||
function parseToolArgs(raw: string | undefined): { args: any; note: string } {
|
||||
if (!raw || !raw.trim()) return { args: {}, note: "空参数" };
|
||||
try {
|
||||
const a = JSON.parse(raw);
|
||||
if (typeof a === "string") return { args: { path: a.trim() }, note: "裸路径转为 path" };
|
||||
return { args: a && typeof a === "object" && !Array.isArray(a) ? a : {}, note: "" };
|
||||
} catch {}
|
||||
// 尝试转义单反斜杠(模型常把 Windows 路径写成 D:\xxx)
|
||||
const fixed = raw.replace(/\\(?![\\"])/g, "\\\\");
|
||||
if (fixed !== raw) {
|
||||
try {
|
||||
const a = JSON.parse(fixed);
|
||||
return { args: a && typeof a === "object" && !Array.isArray(a) ? a : {}, note: "已修复非法反斜杠" };
|
||||
} catch {}
|
||||
}
|
||||
// 裸字符串(去掉引号)→ 作为 path(若含 JSON 残留字符则视为解析失败)
|
||||
const trimmed = raw.trim().replace(/^["']|["']$/g, "");
|
||||
if (trimmed && !/[{}"]/.test(trimmed)) return { args: { path: trimmed }, note: "裸路径转为 path" };
|
||||
return { args: {}, note: "参数无法解析" };
|
||||
}
|
||||
|
||||
// 工具缺 path 时,用本对话已知的最近文件/目录补全(仅限非破坏性工具)
|
||||
function fillPathArgs(name: string, args: any, ctx: { lastDir: string; lastFile: string }): { args: any; note: string } {
|
||||
if (args.path && String(args.path).trim()) return { args, note: "" };
|
||||
if (["read_file", "read_excel", "load_excel"].includes(name)) {
|
||||
if (ctx.lastFile) return { args: { ...args, path: ctx.lastFile }, note: `自动补全 path=${ctx.lastFile}` };
|
||||
if (ctx.lastDir) return { args: { ...args, path: ctx.lastDir }, note: `自动补全 path=${ctx.lastDir}` };
|
||||
return { args, note: "" };
|
||||
}
|
||||
if (name === "list_directory") {
|
||||
if (ctx.lastDir) return { args: { ...args, path: ctx.lastDir }, note: `自动补全 path=${ctx.lastDir}` };
|
||||
return { args: { ...args, path: "." }, note: "" };
|
||||
}
|
||||
return { args, note: "" };
|
||||
}
|
||||
|
||||
// 从工具执行结果中学习已知文件/目录,供后续补全
|
||||
function absorbToolContext(name: string, args: any, exec: any, ctx: { lastDir: string; lastFile: string }) {
|
||||
const p = args.path;
|
||||
if (p && typeof p === "string") {
|
||||
if (name === "list_directory") {
|
||||
ctx.lastDir = p;
|
||||
} else {
|
||||
ctx.lastFile = p;
|
||||
ctx.lastDir = dirname(p);
|
||||
}
|
||||
}
|
||||
if (name === "list_directory" && Array.isArray(exec.result)) {
|
||||
for (const it of exec.result) {
|
||||
if (it?.type === "file") ctx.lastFile = resolve(ctx.lastDir || ".", it.name);
|
||||
}
|
||||
}
|
||||
if (name === "execute_command" && typeof exec.result === "string") {
|
||||
const m = exec.result.split(/\r?\n/).find((l) => /\.(xlsx?|xls|csv|txt|json)$/i.test(l.trim()));
|
||||
if (m) ctx.lastFile = m.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// B5:工具结果在进入模型上下文前做压缩,避免多轮后 token 爆炸
|
||||
const TOOL_CONTENT_LIMIT = 6000;
|
||||
function summarizeToolContent(exec: any): string {
|
||||
let s = safeStringify(exec);
|
||||
if (s.length > TOOL_CONTENT_LIMIT) {
|
||||
s = s.slice(0, TOOL_CONTENT_LIMIT) + `\n…[工具结果过长(共 ${s.length} 字符),已截断]`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------- SSE 辅助 ----------
|
||||
function sseInit(res: any) {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
}
|
||||
function sseSend(res: any, obj: any) {
|
||||
if (res.writableEnded) return;
|
||||
res.write(`data: ${JSON.stringify(obj)}\n\n`);
|
||||
}
|
||||
|
||||
export interface ConversationOptions {
|
||||
conversationId?: string;
|
||||
fromApproval?: boolean;
|
||||
}
|
||||
|
||||
// ---------- 对话处理(带工具循环) ----------
|
||||
export async function runConversation(initialMessages: ChatMsg[], res: any, opts: ConversationOptions = {}) {
|
||||
sseInit(res);
|
||||
const ac = new AbortController();
|
||||
let ended = false;
|
||||
// B4:客户端断连时取消上游(LLM / 命令 / duckdb)
|
||||
res.on("close", () => {
|
||||
if (!ended) ac.abort();
|
||||
});
|
||||
const signal = ac.signal;
|
||||
|
||||
let messages = withSystem(initialMessages);
|
||||
|
||||
const userInput = initialMessages
|
||||
.filter((m) => m.role === "user")
|
||||
.map((m) => clip(m.content ?? "", 2000))
|
||||
.join(" | ");
|
||||
log("info", "对话开始", opts.fromApproval ? "审批后继续" : "新对话");
|
||||
if (userInput) log("info", "用户输入", userInput);
|
||||
|
||||
const toolCtx: ToolContext = { workspace: config.WORKSPACE, duckdb, signal };
|
||||
// 本对话已知的最近文件/目录(供工具缺 path 时自动补全)
|
||||
const pathCtx = { lastDir: "", lastFile: "" };
|
||||
|
||||
const finish = (extraToken?: string) => {
|
||||
if (ended || res.writableEnded) return;
|
||||
ended = true;
|
||||
if (extraToken) sseSend(res, { type: "token", content: extraToken });
|
||||
sseSend(res, { type: "done" });
|
||||
res.end();
|
||||
};
|
||||
|
||||
try {
|
||||
// 工具循环(最多 MAX_TOOL_ROUNDS 轮避免死循环)
|
||||
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
log("info", "发送给AI", `第${round + 1}轮 model=${config.MODEL} temperature=${config.TEMPERATURE}\n${fmtMessages(messages)}`);
|
||||
const gen = streamLLM(messages, { signal });
|
||||
let content = "";
|
||||
let errored = false;
|
||||
let llmErr = "";
|
||||
const toolCalls: any[] = [];
|
||||
|
||||
for await (const ev of gen) {
|
||||
if (signal.aborted) break;
|
||||
if (ev.type === "token") {
|
||||
content += ev.content;
|
||||
sseSend(res, { type: "token", content: ev.content });
|
||||
} else if (ev.type === "toolcall") {
|
||||
toolCalls.push(ev.toolcall);
|
||||
} else if (ev.type === "error") {
|
||||
errored = true;
|
||||
llmErr = ev.content;
|
||||
log("error", "AI错误", ev.content);
|
||||
sseSend(res, { type: "token", content: "\n\n⚠️ " + ev.content });
|
||||
}
|
||||
}
|
||||
if (signal.aborted) break;
|
||||
|
||||
if (errored) {
|
||||
log("info", "返回界面", "⚠️ " + clip(llmErr, 1000));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// 需要工具调用:本轮把模型返回的多个工具调用全部执行,统一把结果返回给模型
|
||||
const calls = toolCalls.filter((tc) => tc?.function?.name);
|
||||
if (calls.length) {
|
||||
const assistantMsg = { role: "assistant", content: content || null, tool_calls: calls.map((tc) => ({ id: tc.id, type: tc.type, function: tc.function })) };
|
||||
const toolResults: ChatMsg[] = [];
|
||||
|
||||
for (const tc of calls) {
|
||||
if (signal.aborted) break;
|
||||
const name = tc.function.name;
|
||||
const parsed = parseToolArgs(tc.function.arguments);
|
||||
let args = parsed.args;
|
||||
// AI 返回:决定调用工具
|
||||
log("info", "AI返回", `调用工具 ${name},参数 ${safeStringify(args)}${parsed.note ? `(${parsed.note})` : ""}`);
|
||||
|
||||
// 路径自动补全:工具缺 path 时用本对话已知的最近文件/目录
|
||||
const filled = fillPathArgs(name, args, pathCtx);
|
||||
if (filled.note) log("info", "路径补全", filled.note);
|
||||
args = filled.args;
|
||||
|
||||
if (needsApproval(name, args)) {
|
||||
// 请求审批,保存上下文等待 /api/approve(之前已执行的结果一并保留)
|
||||
log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(等待用户审批,其余 ${calls.length - 1} 个调用一并暂停)`);
|
||||
storeApproval(opts.conversationId, tc.id, {
|
||||
messages: [...messages, assistantMsg, ...toolResults],
|
||||
toolCall: tc,
|
||||
args,
|
||||
});
|
||||
sseSend(res, {
|
||||
type: "approval_required",
|
||||
tool_call_id: tc.id,
|
||||
tool: name,
|
||||
args,
|
||||
command: name === "execute_command" ? args.command : `${name}(${JSON.stringify(args)})`,
|
||||
});
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行命令/工具
|
||||
log("info", "执行命令", `${name} 参数 ${safeStringify(args)}`);
|
||||
const exec = await execTool(name, args, toolCtx);
|
||||
// 执行结果
|
||||
log("info", "执行结果", `ok=${exec.ok} ${clip(exec.result, 1000)}`);
|
||||
// 从结果中学习已知路径(供后续工具缺 path 时补全)
|
||||
absorbToolContext(name, args, exec, pathCtx);
|
||||
toolResults.push({ role: "tool", name, content: summarizeToolContent(exec), tool_call_id: tc.id });
|
||||
}
|
||||
|
||||
if (signal.aborted) break;
|
||||
if (content) log("info", "返回界面", clip(content, 2000));
|
||||
if (calls.length > 1) log("info", "工具调用", `本轮共执行 ${calls.length} 个工具调用`);
|
||||
messages = [...messages, assistantMsg, ...toolResults];
|
||||
// 继续循环,让模型基于全部工具结果生成最终回复
|
||||
continue;
|
||||
}
|
||||
|
||||
// 无工具调用,正常结束
|
||||
log("info", "AI返回", clip(content, 2000));
|
||||
log("info", "返回界面", clip(content, 2000));
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
log("info", "对话结束", "客户端已断开,取消上游请求");
|
||||
} else {
|
||||
log("warn", "对话结束", `达到最大工具调用轮数(${MAX_TOOL_ROUNDS}),强制结束`);
|
||||
finish("\n[达到最大工具调用轮数]");
|
||||
}
|
||||
} catch (e: any) {
|
||||
log("error", "对话异常", e.message);
|
||||
if (!ended && !signal.aborted) finish("\n⚠️ 处理出错:" + e.message);
|
||||
} finally {
|
||||
// 兜底:任何路径都确保响应结束
|
||||
if (!ended) finish();
|
||||
}
|
||||
}
|
||||
|
||||
// 供 /api/approve 使用:取出待审批上下文并继续对话
|
||||
export async function runApproved(conversationId: string | undefined, toolCallId: string, approved: boolean, res: any) {
|
||||
const pending = takeApproval(conversationId, toolCallId);
|
||||
if (!pending) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "无待审批操作或已过期" }));
|
||||
return;
|
||||
}
|
||||
const { messages, toolCall, args } = pending;
|
||||
const name = toolCall.function.name;
|
||||
log("info", "审批执行", `${name} 参数 ${safeStringify(args)}`);
|
||||
|
||||
let exec;
|
||||
if (approved) {
|
||||
log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(已批准)`);
|
||||
exec = await execTool(name, args, { workspace: config.WORKSPACE, duckdb });
|
||||
log("info", "执行结果", `ok=${exec.ok} ${clip(exec.result, 1000)}`);
|
||||
} else {
|
||||
log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(已拒绝)`);
|
||||
exec = { ok: false, result: "用户已拒绝该操作" };
|
||||
}
|
||||
const nextMessages: ChatMsg[] = [
|
||||
...messages,
|
||||
{ role: "tool", name, content: safeStringify(exec), tool_call_id: toolCall.id },
|
||||
];
|
||||
await runConversation(nextMessages, res, { conversationId, fromApproval: true });
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
// ---------- 配置加载 ----------
|
||||
function loadEnv(): Record<string, string> {
|
||||
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 修改并持久化)
|
||||
export const config = {
|
||||
BASE_URL: process.env.BASE_URL || ENV.BASE_URL || "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
API_KEY: process.env.API_KEY || ENV.API_KEY || "",
|
||||
MODEL: process.env.MODEL || ENV.MODEL || "",
|
||||
WORKSPACE: process.env.WORKSPACE || ENV.WORKSPACE || process.cwd(),
|
||||
CMD_APPROVE_LEVEL: (process.env.CMD_APPROVE_LEVEL || ENV.CMD_APPROVE_LEVEL || "medium").toLowerCase(),
|
||||
PORT: parseInt(process.env.PORT || ENV.PORT || "3000", 10),
|
||||
LOG_LEVEL: (process.env.LOG_LEVEL || ENV.LOG_LEVEL || "debug").toLowerCase(),
|
||||
TEMPERATURE: parseFloat(process.env.TEMPERATURE || ENV.TEMPERATURE || "0.3"),
|
||||
DUCKDB_EXE: process.env.DUCKDB_EXE || ENV.DUCKDB_EXE || resolve("bin", "duckdb.exe"),
|
||||
DUCKDB_FILE: process.env.DUCKDB_FILE || ENV.DUCKDB_FILE || resolve("data", "duck.db"),
|
||||
REMOTE_LOG_URL: process.env.REMOTE_LOG_URL || ENV.REMOTE_LOG_URL || "",
|
||||
};
|
||||
|
||||
// 持久化配置到 .env(WORKSPACE 为内部默认值,不在设置页暴露)
|
||||
const ENV_KEYS = ["BASE_URL", "API_KEY", "MODEL", "WORKSPACE", "CMD_APPROVE_LEVEL", "PORT", "LOG_LEVEL", "TEMPERATURE", "DUCKDB_EXE", "DUCKDB_FILE", "REMOTE_LOG_URL"] as const;
|
||||
|
||||
// 按 key 增量更新 .env:保留注释与未知键,只覆盖 ENV_KEYS 中已有的行
|
||||
export function saveEnv() {
|
||||
const header = "# Mini AI Assistant 配置(可由设置页面修改)\n";
|
||||
const existing = existsSync(".env") ? readFileSync(".env", "utf-8").split(/\r?\n/) : [];
|
||||
const out: string[] = [];
|
||||
const written = new Set<string>();
|
||||
for (const line of existing) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx > 0) {
|
||||
const k = line.slice(0, idx).trim();
|
||||
if ((ENV_KEYS as readonly string[]).includes(k)) {
|
||||
out.push(`${k}=${String((config as any)[k])}`);
|
||||
written.add(k);
|
||||
} else {
|
||||
out.push(line);
|
||||
}
|
||||
} else {
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
for (const k of ENV_KEYS) {
|
||||
if (!written.has(k)) out.push(`${k}=${String((config as any)[k])}`);
|
||||
}
|
||||
try {
|
||||
writeFileSync(".env", header + out.join("\n") + "\n", "utf-8");
|
||||
} catch (e) {
|
||||
console.error("保存配置失败:", (e as any).message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { spawn } from "child_process";
|
||||
import { mkdirSync } from "fs";
|
||||
import { dirname } from "path";
|
||||
|
||||
// ---------- DuckDB(长驻 duckdb.exe 子进程,避免每次查询新建进程) ----------
|
||||
// 采用「SQL + 哨兵查询」的方式在 stdout 上做结果分帧:
|
||||
// 发送: <sql>\nSELECT '__mpc_sentinel__' AS s;\n
|
||||
// duckdb -json 模式逐条打印 SELECT 结果,最后一个就是哨兵行 [{"s":"__mpc_sentinel__"}],
|
||||
// 取其之前的内容即为本次查询结果;DDL 语句无输出则只有哨兵行。
|
||||
// duckdb 遇错会停止执行后续语句并写入 stderr → 感知到 stderr 即失败,随后重建进程。
|
||||
|
||||
const SENTINEL = '[{"s":"__mpc_sentinel__"}]';
|
||||
const DEFAULT_TIMEOUT_MS = 60000;
|
||||
|
||||
export interface DuckRunOptions {
|
||||
timeout?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface DuckRunResult {
|
||||
ok: boolean;
|
||||
output: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
// CSV 单元格转义(供导入表时写 CSV 使用)
|
||||
export function csvCell(v: any): string {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (typeof v === "number") return Number.isFinite(v) ? String(v) : "";
|
||||
if (v instanceof Date) return '"' + v.toISOString().replace(/"/g, '""') + '"';
|
||||
const s = String(v);
|
||||
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
||||
}
|
||||
|
||||
export class DuckDBAdapter {
|
||||
private proc: any = null;
|
||||
// 串行队列:DuckDB 为单引擎,并发请求按到达顺序排队执行
|
||||
private chain: Promise<any> = Promise.resolve();
|
||||
|
||||
constructor(private exe: string, private dbFile: string) {}
|
||||
|
||||
get dbFilePath(): string {
|
||||
return this.dbFile;
|
||||
}
|
||||
|
||||
private ensureProc() {
|
||||
if (this.proc && this.proc.exitCode === null) return;
|
||||
mkdirSync(dirname(this.dbFile), { recursive: true });
|
||||
const p = spawn(this.exe, [this.dbFile, "-json"], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
this.proc = p;
|
||||
p.on("error", () => { if (this.proc === p) this.proc = null; });
|
||||
p.on("close", () => { if (this.proc === p) this.proc = null; });
|
||||
try { p.stdin.on("error", () => {}); } catch {}
|
||||
}
|
||||
|
||||
private killProc() {
|
||||
try { this.proc?.kill(); } catch {}
|
||||
this.proc = null;
|
||||
}
|
||||
|
||||
// 排队执行(并发安全)
|
||||
run(sql: string, opts: DuckRunOptions = {}): Promise<DuckRunResult> {
|
||||
const task = () => this._run(sql, opts);
|
||||
const result = this.chain.then(task, task);
|
||||
this.chain = result.then(
|
||||
() => {},
|
||||
() => {}
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private _run(sql: string, opts: DuckRunOptions): Promise<DuckRunResult> {
|
||||
return new Promise((resolve) => {
|
||||
this.ensureProc();
|
||||
const proc = this.proc;
|
||||
if (!proc) {
|
||||
resolve({ ok: false, output: "", stderr: "无法启动 duckdb 进程" });
|
||||
return;
|
||||
}
|
||||
let stdoutBuf = "";
|
||||
let stderrBuf = "";
|
||||
let done = false;
|
||||
const timeout = opts.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||
let timer: any = null;
|
||||
let onData: (d: Buffer) => void;
|
||||
let onErr: (d: Buffer) => void;
|
||||
let onAbort: () => void;
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
opts.signal?.removeEventListener("abort", onAbort);
|
||||
proc.stdout.off("data", onData);
|
||||
proc.stderr.off("data", onErr);
|
||||
};
|
||||
const finish = (ok: boolean, output: string, stderr: string) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
cleanup();
|
||||
if (!ok) this.killProc();
|
||||
resolve({ ok, output, stderr });
|
||||
};
|
||||
|
||||
onData = (d: Buffer) => {
|
||||
if (done) return;
|
||||
stdoutBuf += d.toString("utf8");
|
||||
const idx = stdoutBuf.indexOf(SENTINEL);
|
||||
if (idx >= 0) {
|
||||
const output = stdoutBuf.slice(0, idx).trim();
|
||||
const err = stderrBuf.trim();
|
||||
if (err) finish(false, "", err);
|
||||
else finish(true, output, "");
|
||||
}
|
||||
};
|
||||
onErr = (d: Buffer) => {
|
||||
if (done) return;
|
||||
stderrBuf += d.toString("utf8");
|
||||
if (stderrBuf.trim()) finish(false, "", stderrBuf.trim());
|
||||
};
|
||||
onAbort = () => finish(false, "", "已取消");
|
||||
|
||||
proc.stdout.on("data", onData);
|
||||
proc.stderr.on("data", onErr);
|
||||
if (opts.signal) {
|
||||
if (opts.signal.aborted) { finish(false, "", "已取消"); return; }
|
||||
opts.signal.addEventListener("abort", onAbort);
|
||||
}
|
||||
timer = setTimeout(() => finish(false, "", `duckdb 执行超时(${timeout}ms)`), timeout);
|
||||
try {
|
||||
let s = sql.trim();
|
||||
if (!/;\s*$/.test(s)) s += ";";
|
||||
proc.stdin.write(s + "\nSELECT '__mpc_sentinel__' AS s;\n");
|
||||
} catch (e: any) {
|
||||
finish(false, "", "写入 duckdb 失败: " + e.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 执行 SELECT,返回行数组(空结果返回 [])
|
||||
async query(sql: string, opts?: DuckRunOptions): Promise<any[]> {
|
||||
const r = await this.run(sql, opts);
|
||||
if (!r.ok) throw new Error(r.stderr);
|
||||
const t = r.output.trim();
|
||||
if (!t) return [];
|
||||
try {
|
||||
return JSON.parse(t);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 执行 DDL / COPY 等无返回语句
|
||||
async exec(sql: string, opts?: DuckRunOptions): Promise<void> {
|
||||
const r = await this.run(sql, opts);
|
||||
if (!r.ok) throw new Error(r.stderr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { readFile, mkdir } from "fs/promises";
|
||||
import { writeFileSync } from "fs";
|
||||
import { resolve, normalize } from "path";
|
||||
import { read as xlsxRead, write as xlsxWrite, utils } from "xlsx";
|
||||
import { config } from "./config";
|
||||
|
||||
// ---------- Excel 读写(纯 JS,无原生依赖) ----------
|
||||
|
||||
// 读取 Excel,默认仅前 5 行,用于理解数据结构
|
||||
export async function readExcel(
|
||||
path: string,
|
||||
sheet?: string,
|
||||
limit = 5
|
||||
): Promise<{ totalRows: number; columns: any[]; data: any[][] }> {
|
||||
const buf = await readFile(resolve(config.WORKSPACE, normalize(path)));
|
||||
const wb = xlsxRead(buf, { type: "buffer" });
|
||||
const sheetName = sheet || wb.SheetNames[0];
|
||||
const ws = wb.Sheets[sheetName];
|
||||
const rows = utils.sheet_to_json(ws, { header: 1 });
|
||||
return {
|
||||
totalRows: rows.length,
|
||||
columns: rows[0] || [],
|
||||
data: rows.slice(0, Math.min(limit || 5, 200)),
|
||||
};
|
||||
}
|
||||
|
||||
// 将 JSON 数组写入 Excel
|
||||
export async function writeExcel(path: string, data: any[], sheet?: string): Promise<string> {
|
||||
const rows = (data || []).map((o: any) => Object.values(o));
|
||||
const headers = data?.length ? Object.keys(data[0]) : [];
|
||||
const ws = utils.aoa_to_sheet([headers, ...rows]);
|
||||
const wb = { Sheets: {} as Record<string, any>, SheetNames: [] as string[] };
|
||||
wb.SheetNames.push(sheet || "Sheet1");
|
||||
wb.Sheets[wb.SheetNames[0]] = ws;
|
||||
const abs = resolve(config.WORKSPACE, normalize(path));
|
||||
await mkdir(resolve(abs, ".."), { recursive: true }).catch(() => {});
|
||||
const out = xlsxWrite(wb, { type: "buffer", bookType: "xlsx" });
|
||||
writeFileSync(abs, out);
|
||||
return `已写入 Excel: ${path} (${rows.length} 行)`;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { config } from "./config";
|
||||
import { getToolDefinitions } from "./tools";
|
||||
import type { ChatMsg } from "./types";
|
||||
|
||||
// ---------- LLM 调用(流式,带重试与断连取消) ----------
|
||||
export interface StreamLLMOptions {
|
||||
signal?: AbortSignal;
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
const TOOL_CALL_LIMIT = 20;
|
||||
|
||||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) return resolve();
|
||||
const onAbort = () => { clearTimeout(t); resolve(); };
|
||||
const t = setTimeout(() => { signal?.removeEventListener("abort", onAbort); resolve(); }, ms);
|
||||
signal?.addEventListener("abort", onAbort);
|
||||
});
|
||||
}
|
||||
|
||||
export async function* streamLLM(
|
||||
messages: ChatMsg[],
|
||||
opts: StreamLLMOptions = {}
|
||||
): AsyncGenerator<{ type: "token" | "toolcall" | "error"; content?: string; toolcall?: any }> {
|
||||
const url = config.BASE_URL.replace(/\/$/, "") + "/chat/completions";
|
||||
const body: any = {
|
||||
messages: messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
||||
...(m.name ? { name: m.name } : {}),
|
||||
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
||||
})),
|
||||
tools: getToolDefinitions(),
|
||||
tool_choice: "auto",
|
||||
stream: true,
|
||||
temperature: config.TEMPERATURE,
|
||||
};
|
||||
if (config.MODEL) body.model = config.MODEL;
|
||||
|
||||
// 重试瞬时错误(网络失败 / 5xx / 429),4xx 不重试
|
||||
const maxRetries = opts.maxRetries ?? 2;
|
||||
let resp: any = null;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.API_KEY}` },
|
||||
body: JSON.stringify(body),
|
||||
signal: opts.signal,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (opts.signal?.aborted) {
|
||||
yield { type: "error", content: "请求已取消" };
|
||||
return;
|
||||
}
|
||||
if (attempt >= maxRetries) {
|
||||
yield { type: "error", content: "无法连接模型服务: " + e.message };
|
||||
return;
|
||||
}
|
||||
await sleep(Math.min(500 * 2 ** attempt, 5000), opts.signal);
|
||||
continue;
|
||||
}
|
||||
if (resp.ok || (resp.status < 500 && resp.status !== 429)) break;
|
||||
if (attempt >= maxRetries) break;
|
||||
await sleep(Math.min(500 * 2 ** attempt, 5000), opts.signal);
|
||||
}
|
||||
|
||||
if (!resp || !resp.ok) {
|
||||
const txt = await resp?.text().catch(() => "") || "";
|
||||
yield { type: "error", content: `模型返回错误 (${resp?.status ?? "?"}): ${txt.slice(0, 300)}` };
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
// 支持一次响应返回多个工具调用:按 index 聚合,各自独立
|
||||
const toolCalls = new Map<string, any>();
|
||||
let anonIdx = 0;
|
||||
|
||||
while (true) {
|
||||
let chunk: Uint8Array | null = null;
|
||||
try {
|
||||
const r = await reader.read();
|
||||
if (r.done) break;
|
||||
chunk = r.value;
|
||||
} catch (e: any) {
|
||||
if (opts.signal?.aborted) {
|
||||
yield { type: "error", content: "请求已取消" };
|
||||
return;
|
||||
}
|
||||
yield { type: "error", content: "读取模型响应中断: " + e.message };
|
||||
return;
|
||||
}
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith("data:")) continue;
|
||||
const data = t.slice(5).trim();
|
||||
if (data === "[DONE]") continue;
|
||||
let json: any;
|
||||
try { json = JSON.parse(data); } catch { continue; }
|
||||
const delta = json.choices?.[0]?.delta || {};
|
||||
if (delta.content) yield { type: "token", content: delta.content };
|
||||
if (delta.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
// OpenAI 兼容流:首个分片带 id+name,延续分片 id 为 null 且只带 arguments,
|
||||
// 必须按 index 聚合,否则每个分片被拆成独立调用导致参数残缺/为空
|
||||
const key = (tc.index !== undefined ? String(tc.index) : tc.id) || `call_anon_${anonIdx++}`;
|
||||
if (!toolCalls.has(key)) toolCalls.set(key, { id: tc.id || undefined, type: "function", function: { name: "", arguments: "" } });
|
||||
const acc = toolCalls.get(key);
|
||||
if (tc.function?.name) acc.function.name = tc.function.name;
|
||||
if (tc.function?.arguments) acc.function.arguments += tc.function.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opts.signal?.aborted) {
|
||||
yield { type: "error", content: "请求已取消" };
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const tc of toolCalls.values()) {
|
||||
if (!tc.id || tc.id.startsWith("call_anon_")) tc.id = "call_" + Date.now() + "_" + Math.random().toString(36).slice(2, 7);
|
||||
yield { type: "toolcall", toolcall: tc };
|
||||
}
|
||||
}
|
||||
|
||||
export const MAX_TOOL_ROUNDS = TOOL_CALL_LIMIT;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { existsSync, appendFileSync, mkdirSync } from "fs";
|
||||
import { mkdir, appendFile } from "fs/promises";
|
||||
import { resolve } from "path";
|
||||
import { config } from "./config";
|
||||
import type { ChatMsg } from "./types";
|
||||
|
||||
// ---------- 日志系统 ----------
|
||||
// 本地写入采用「内存队列 + 周期批量落盘」,避免 debug 级别高频小写造成 I/O 压力;
|
||||
// 日志文件按天轮转,避免单个 global.log 无限增长。
|
||||
const LOG_DIR = "logs";
|
||||
const LEVELS: Record<string, number> = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||
const FLUSH_INTERVAL_MS = 800;
|
||||
|
||||
function shouldLog(level: string): boolean {
|
||||
const lv = LEVELS[level] ?? 1;
|
||||
return lv >= (LEVELS[config.LOG_LEVEL] ?? 0);
|
||||
}
|
||||
|
||||
function logFileName(): string {
|
||||
const d = new Date().toISOString().slice(0, 10);
|
||||
return resolve(LOG_DIR, `global-${d}.log`);
|
||||
}
|
||||
|
||||
const queue: string[] = [];
|
||||
let timer: any = null;
|
||||
|
||||
function enqueue(line: string) {
|
||||
queue.push(line);
|
||||
if (!timer) timer = setTimeout(() => { timer = null; void flush(); }, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
if (!queue.length) return;
|
||||
const lines = queue.splice(0, queue.length).join("");
|
||||
try {
|
||||
if (!existsSync(LOG_DIR)) await mkdir(LOG_DIR, { recursive: true });
|
||||
await appendFile(logFileName(), lines, "utf-8");
|
||||
} catch {
|
||||
/* 忽略日志写入失败 */
|
||||
}
|
||||
}
|
||||
|
||||
// 进程退出时兜底落盘(同步写)
|
||||
process.on("exit", () => {
|
||||
try {
|
||||
if (queue.length) {
|
||||
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
|
||||
appendFileSync(logFileName(), queue.join(""));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
export function log(level: string, category: string, data: any) {
|
||||
if (!shouldLog(level)) return;
|
||||
const ts = new Date().toISOString();
|
||||
const text = typeof data === "string" ? data : safeStringify(data);
|
||||
const line = `[${ts}] [${level.toUpperCase().padEnd(5)}] [${category}] ${text}\n`;
|
||||
enqueue(line);
|
||||
pushRemote(level, category, text);
|
||||
}
|
||||
|
||||
// 推送到远程日志接收器(仅配置了地址时启用;失败静默忽略)
|
||||
function pushRemote(level: string, category: string, message: string) {
|
||||
const url = config.REMOTE_LOG_URL;
|
||||
if (!url) return;
|
||||
try {
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ level, category, message, ts: new Date().toISOString() }),
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
/* 忽略推送失败 */
|
||||
}
|
||||
}
|
||||
|
||||
// 对话消息列表的可读格式化(供“发送给AI”日志使用)
|
||||
export function fmtMessages(msgs: ChatMsg[]): string {
|
||||
return msgs
|
||||
.map((m) => {
|
||||
if (m.role === "tool") return ` [tool ${m.name}] ${clip(m.content ?? "", 600)}`;
|
||||
if (m.tool_calls?.length) return ` [assistant→tool] ${m.tool_calls.map((tc: any) => tc.function?.name).join(", ")}`;
|
||||
return ` [${m.role}] ${clip(m.content ?? "", 1000)}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// 可安全序列化 BigInt 等类型
|
||||
export function safeStringify(s: any): string {
|
||||
try {
|
||||
return JSON.stringify(s, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 0);
|
||||
} catch {
|
||||
try { return String(s); } catch { return "[unserializable]"; }
|
||||
}
|
||||
}
|
||||
|
||||
// 安全截断
|
||||
export function clip(s: any, n = 600): string {
|
||||
const str = typeof s === "string" ? s : safeStringify(s);
|
||||
return str.length > n ? str.slice(0, n) + "…" : str;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { config } from "./config";
|
||||
import type { ChatMsg } from "./types";
|
||||
|
||||
// ---------- 危险命令检测 ----------
|
||||
const DANGEROUS_PATTERNS = [
|
||||
"rm -rf /", "rd /s", "format", "mkfs", "shutdown", "reboot", "poweroff",
|
||||
"dd if=", "fdisk", ":(){:|:&};:", "chmod -r", "del /s", "attrib",
|
||||
];
|
||||
|
||||
export function isDangerousCommand(cmd: string): boolean {
|
||||
const lower = cmd.toLowerCase();
|
||||
if (DANGEROUS_PATTERNS.some((p) => lower.includes(p))) return true;
|
||||
// 递归删除(开关顺序不定):del/rmdir 带 /s,rm 带 -rf/-fr 等组合
|
||||
if (/\bdel\b[^\r\n]*?\/s\b/.test(lower)) return true;
|
||||
if (/\brmdir\b[^\r\n]*?\/s\b/.test(lower)) return true;
|
||||
if (/\brm\b[^\r\n]*?-(?:rf|fr|r\s+f|f\s+r)\b/.test(lower)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 需要审批的工具:delete_file 始终需要;execute_command 依据授权级别
|
||||
const NEEDS_APPROVAL = new Set(["delete_file"]);
|
||||
|
||||
// 命令授权级别:high=每个命令都审核, medium=仅危险命令审核, low=全部不审核(默认 medium)
|
||||
export function needsApproval(name: string, args: any): boolean {
|
||||
if (NEEDS_APPROVAL.has(name)) return true;
|
||||
if (name === "execute_command") {
|
||||
const lv = config.CMD_APPROVE_LEVEL;
|
||||
if (lv === "high") return true;
|
||||
if (lv === "low") return false;
|
||||
return isDangerousCommand(String(args?.command || ""));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- 审批上下文(按会话 + tool_call_id 绑定,带过期时间) ----------
|
||||
// 修复原先全局单槽的问题:多会话 / 多标签页的审批互不干扰,且过期自动清理。
|
||||
const TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface PendingApproval {
|
||||
messages: ChatMsg[];
|
||||
toolCall: any;
|
||||
args: any;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const pendingApprovals = new Map<string, PendingApproval>();
|
||||
|
||||
function approvalKey(conversationId: string | undefined, toolCallId: string): string {
|
||||
return `${conversationId || "default"}::${toolCallId}`;
|
||||
}
|
||||
|
||||
function cleanupExpired() {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of pendingApprovals) {
|
||||
if (v.expiresAt < now) pendingApprovals.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
export function storeApproval(conversationId: string | undefined, toolCallId: string, data: Omit<PendingApproval, "expiresAt">) {
|
||||
cleanupExpired();
|
||||
pendingApprovals.set(approvalKey(conversationId, toolCallId), {
|
||||
...data,
|
||||
expiresAt: Date.now() + TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function takeApproval(conversationId: string | undefined, toolCallId: string): PendingApproval | null {
|
||||
const k = approvalKey(conversationId, toolCallId);
|
||||
const v = pendingApprovals.get(k);
|
||||
if (!v) return null;
|
||||
if (v.expiresAt < Date.now()) {
|
||||
pendingApprovals.delete(k);
|
||||
return null;
|
||||
}
|
||||
pendingApprovals.delete(k);
|
||||
return v;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, readdirSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
// ---------- 会话记忆(本地 JSON 文件,按会话 id 持久化) ----------
|
||||
const CONVERSATIONS_DIR = resolve("conversations");
|
||||
|
||||
function convFile(id: string): string {
|
||||
const safe = String(id).replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
return resolve(CONVERSATIONS_DIR, safe + ".json");
|
||||
}
|
||||
|
||||
function genConvId(): string {
|
||||
return "c_" + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
}
|
||||
|
||||
function readConv(id: string): any {
|
||||
try { return JSON.parse(readFileSync(convFile(id), "utf-8")); } catch { return null; }
|
||||
}
|
||||
|
||||
function writeConv(id: string, data: any) {
|
||||
mkdirSync(CONVERSATIONS_DIR, { recursive: true });
|
||||
writeFileSync(convFile(id), JSON.stringify(data, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
function listConvs(): { id: string; title: string; updatedAt: number; count: number }[] {
|
||||
try {
|
||||
if (!existsSync(CONVERSATIONS_DIR)) return [];
|
||||
return readdirSync(CONVERSATIONS_DIR)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => {
|
||||
try {
|
||||
const d = JSON.parse(readFileSync(resolve(CONVERSATIONS_DIR, f), "utf-8"));
|
||||
return { id: d.id || f.replace(".json", ""), title: d.title || "对话", updatedAt: d.updatedAt || 0, count: (d.messages || []).length };
|
||||
} catch { return null; }
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a: any, b: any) => b.updatedAt - a.updatedAt);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
function deleteConv(id: string) {
|
||||
try { unlinkSync(convFile(id)); } catch {}
|
||||
}
|
||||
|
||||
export const conversationStore = { convFile, genConvId, readConv, writeConv, listConvs, deleteConv };
|
||||
@@ -0,0 +1,366 @@
|
||||
import { readFile, writeFile, readdir, rename, unlink, mkdir } from "fs/promises";
|
||||
import { writeFileSync, unlinkSync, mkdirSync } from "fs";
|
||||
import { resolve, normalize, dirname } from "path";
|
||||
import { spawn } from "child_process";
|
||||
import { read as xlsxRead, utils } from "xlsx";
|
||||
import { config } from "./config";
|
||||
import { DuckDBAdapter, csvCell } from "./duckdb";
|
||||
import { readExcel, writeExcel } from "./excel";
|
||||
|
||||
// ---------- 工具注册表(A2:定义 + 执行 + 元数据合并为一处注册) ----------
|
||||
export interface ToolContext {
|
||||
workspace: string;
|
||||
duckdb: DuckDBAdapter;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface ToolExecutor {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: any;
|
||||
approvalNote?: string;
|
||||
meta?: { label: string; icon: string; category: string };
|
||||
execute: (args: any, ctx: ToolContext, opts?: ToolExecOpts) => Promise<{ ok: boolean; result: any }>;
|
||||
}
|
||||
|
||||
export interface ToolExecOpts {
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
const registry = new Map<string, ToolExecutor>();
|
||||
|
||||
export function registerTool(t: ToolExecutor) {
|
||||
registry.set(t.name, t);
|
||||
}
|
||||
|
||||
export function getTool(name: string): ToolExecutor | undefined {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
// 供 LLM function calling 使用的 OpenAI 工具定义
|
||||
export function getToolDefinitions() {
|
||||
return [...registry.values()].map((t) => ({
|
||||
type: "function",
|
||||
function: { name: t.name, description: t.description, parameters: t.parameters },
|
||||
}));
|
||||
}
|
||||
|
||||
// 供前端技能页 /api/tools 使用的元数据
|
||||
export function listToolsForApi() {
|
||||
return [...registry.values()].map((t) => ({
|
||||
name: t.name,
|
||||
label: t.meta?.label || t.name,
|
||||
icon: t.meta?.icon || "🛠️",
|
||||
category: t.meta?.category || "其他",
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
approvalNote: t.approvalNote,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function execTool(name: string, args: any, ctx: ToolContext, opts?: ToolExecOpts): Promise<{ ok: boolean; result: any }> {
|
||||
const t = registry.get(name);
|
||||
if (!t) return { ok: false, result: "未知工具: " + name };
|
||||
try {
|
||||
return await t.execute(args, ctx, opts);
|
||||
} catch (e: any) {
|
||||
return { ok: false, result: "执行失败: " + e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 命令执行:超时 + 输出上限 + 终止进程树(B1) ----------
|
||||
const COMMAND_TIMEOUT_MS = 120000;
|
||||
const COMMAND_MAX_OUTPUT = 512 * 1024;
|
||||
|
||||
function killTree(proc: any) {
|
||||
if (process.platform === "win32") {
|
||||
try { spawn("taskkill", ["/pid", String(proc.pid), "/T", "/F"]); } catch {}
|
||||
} else {
|
||||
try { proc.kill("SIGKILL"); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function executeCommand(command: string, ctx: ToolContext, timeoutMs: number = COMMAND_TIMEOUT_MS): Promise<{ ok: boolean; result: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(String(command || ""), { cwd: ctx.workspace, shell: true, env: { ...process.env } });
|
||||
let stdout = "", stderr = "", truncated = false;
|
||||
let settled = false;
|
||||
|
||||
const push = (d: Buffer, target: "out" | "err") => {
|
||||
let s = d.toString();
|
||||
const cur = target === "out" ? stdout : stderr;
|
||||
if (s.length + cur.length > COMMAND_MAX_OUTPUT) {
|
||||
s = s.slice(0, Math.max(0, COMMAND_MAX_OUTPUT - cur.length));
|
||||
truncated = true;
|
||||
}
|
||||
if (target === "out") stdout += s;
|
||||
else stderr += s;
|
||||
};
|
||||
|
||||
proc.stdout?.on("data", (d: Buffer) => push(d, "out"));
|
||||
proc.stderr?.on("data", (d: Buffer) => push(d, "err"));
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
ctx.signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const settle = (ok: boolean, result: string) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve({ ok, result });
|
||||
};
|
||||
const onAbort = () => {
|
||||
killTree(proc);
|
||||
settle(false, "命令执行已取消");
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
killTree(proc);
|
||||
settle(false, `命令执行超时(${Math.round(timeoutMs / 1000)}s),已终止${truncated ? "(输出已截断)" : ""}`);
|
||||
}, timeoutMs);
|
||||
|
||||
if (ctx.signal) {
|
||||
if (ctx.signal.aborted) { onAbort(); return; }
|
||||
ctx.signal.addEventListener("abort", onAbort);
|
||||
}
|
||||
|
||||
proc.on("close", (code: number) => {
|
||||
const out = stdout || stderr;
|
||||
settle(code === 0, out + (truncated ? "\n[输出已截断]" : ""));
|
||||
});
|
||||
proc.on("error", (e: Error) => settle(false, e.message));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 工具定义与执行 ----------
|
||||
|
||||
function param(name: string, desc: string, required = false) {
|
||||
const p: any = { type: "string", description: desc };
|
||||
return { name, desc, required, p };
|
||||
}
|
||||
|
||||
function objParams(props: any[], required: string[] = []) {
|
||||
const o: any = { type: "object", properties: {}, required: required.length ? required : undefined };
|
||||
for (const p of props) o.properties[p.name] = p.p;
|
||||
return o;
|
||||
}
|
||||
|
||||
registerTool({
|
||||
name: "read_file",
|
||||
description: "读取文本文件内容(txt、js、md、log 等,上限 5 万字符)",
|
||||
parameters: objParams([param("path", "文件路径")], ["path"]),
|
||||
meta: { label: "读取文件", icon: "📄", category: "文件操作" },
|
||||
execute: async (args, ctx) => {
|
||||
const content = await readFile(resolve(ctx.workspace, normalize(args.path)), "utf-8");
|
||||
return { ok: true, result: content.slice(0, 50000) };
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: "write_file",
|
||||
description: "写入文本内容到文件(覆盖)",
|
||||
parameters: objParams([param("path", "文件路径"), param("content", "写入内容,覆盖原文件")], ["path", "content"]),
|
||||
meta: { label: "写入文件", icon: "✍️", category: "文件操作" },
|
||||
execute: async (args, ctx) => {
|
||||
const abs = resolve(ctx.workspace, normalize(args.path));
|
||||
await mkdir(resolve(abs, ".."), { recursive: true }).catch(() => {});
|
||||
await writeFile(abs, args.content ?? "", "utf-8");
|
||||
return { ok: true, result: `已写入 ${args.path} (${((args.content ?? "").length)} 字符)` };
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: "list_directory",
|
||||
description: "列出目录内容,返回 [{name, type}]",
|
||||
parameters: objParams([param("path", "目录路径,默认当前工作区")]),
|
||||
meta: { label: "列出目录", icon: "📂", category: "文件操作" },
|
||||
execute: async (args, ctx) => {
|
||||
const target = args.path ? normalize(args.path) : ".";
|
||||
const entries = await readdir(resolve(ctx.workspace, target), { withFileTypes: true });
|
||||
return {
|
||||
ok: true,
|
||||
result: entries.map((e) => ({ name: e.name, type: e.isDirectory() ? "dir" : "file" })),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: "copy_file",
|
||||
description: "复制文件到目标位置",
|
||||
parameters: objParams([param("source", "源路径"), param("destination", "目标路径")], ["source", "destination"]),
|
||||
meta: { label: "复制", icon: "📋", category: "文件操作" },
|
||||
execute: async (args, ctx) => {
|
||||
const src = resolve(ctx.workspace, normalize(args.source));
|
||||
const dst = resolve(ctx.workspace, normalize(args.destination));
|
||||
const content = await readFile(src);
|
||||
await mkdir(resolve(dst, ".."), { recursive: true }).catch(() => {});
|
||||
await writeFile(dst, content);
|
||||
return { ok: true, result: `已复制 ${args.source} -> ${args.destination}` };
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: "move_file",
|
||||
description: "移动/重命名文件或目录",
|
||||
parameters: objParams([param("source", "源路径"), param("destination", "目标路径")], ["source", "destination"]),
|
||||
meta: { label: "移动 / 重命名", icon: "📦", category: "文件操作" },
|
||||
execute: async (args, ctx) => {
|
||||
const src = resolve(ctx.workspace, normalize(args.source));
|
||||
const dst = resolve(ctx.workspace, normalize(args.destination));
|
||||
await mkdir(resolve(dst, ".."), { recursive: true }).catch(() => {});
|
||||
await rename(src, dst);
|
||||
return { ok: true, result: `已移动 ${args.source} -> ${args.destination}` };
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: "delete_file",
|
||||
description: "删除文件或空目录(需审批)",
|
||||
parameters: objParams([param("path", "文件路径")], ["path"]),
|
||||
approvalNote: "需要审批",
|
||||
meta: { label: "删除", icon: "🗑️", category: "文件操作" },
|
||||
execute: async (args, ctx) => {
|
||||
await unlink(resolve(ctx.workspace, normalize(args.path)));
|
||||
return { ok: true, result: `已删除 ${args.path}` };
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: "execute_command",
|
||||
description: "执行 Shell 命令(需审批,120 秒超时)",
|
||||
parameters: objParams([param("command", "要执行的命令")], ["command"]),
|
||||
approvalNote: "按授权级别",
|
||||
meta: { label: "执行命令", icon: "⌨️", category: "命令执行" },
|
||||
execute: async (args, ctx, opts) => executeCommand(args.command, ctx, opts?.timeout),
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: "read_excel",
|
||||
description: "读取 Excel 返回 JSON(默认仅前 5 行,用于理解数据结构;数据分析请用 load_excel + query_duckdb)",
|
||||
parameters: objParams([
|
||||
param("path", "文件路径"),
|
||||
param("sheet", "工作表名,默认第一个"),
|
||||
{ name: "limit", desc: "返回行数,默认 5", required: false, p: { type: "number", description: "返回行数,默认 5" } },
|
||||
], ["path"]),
|
||||
meta: { label: "预览 Excel", icon: "🔎", category: "Excel / 数据" },
|
||||
execute: async (args) => {
|
||||
const r = await readExcel(args.path, args.sheet, args.limit);
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
...r,
|
||||
note: "read_excel 仅用于理解数据结构;真实数据分析请用 load_excel + query_duckdb",
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: "write_excel",
|
||||
description: "将 JSON 数组写入 Excel(每个对象的键为列名)",
|
||||
parameters: objParams([
|
||||
param("path", "输出文件路径"),
|
||||
{ name: "data", desc: "JSON 对象数组,每项一行", required: true, p: { type: "array", description: "JSON 对象数组" } },
|
||||
param("sheet", "工作表名,默认 Sheet1"),
|
||||
], ["path", "data"]),
|
||||
meta: { label: "写出 Excel", icon: "📈", category: "Excel / 数据" },
|
||||
execute: async (args) => {
|
||||
const r = await writeExcel(args.path, args.data, args.sheet);
|
||||
return { ok: true, result: r };
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: "load_excel",
|
||||
description: "将 Excel 加载进 DuckDB 表以便用 SQL 分析数据。返回表名、列名与类型、前 5 行预览(仅用于理解格式);加载完成后用 query_duckdb 对该表执行任意 SQL(筛选/聚合/排序/连接等)。",
|
||||
parameters: objParams([
|
||||
param("path", "Excel 文件路径"),
|
||||
param("sheet", "工作表名,默认第一个"),
|
||||
param("table", "目标表名,默认 excel_data"),
|
||||
], ["path"]),
|
||||
meta: { label: "载入 DuckDB", icon: "🗃️", category: "Excel / 数据" },
|
||||
execute: async (args, ctx) => {
|
||||
const buf = await readFile(resolve(ctx.workspace, normalize(args.path)));
|
||||
const wb = xlsxRead(buf, { type: "buffer" });
|
||||
const sheetName = args.sheet || wb.SheetNames[0];
|
||||
const ws = wb.Sheets[sheetName];
|
||||
if (!ws) throw new Error("工作表不存在: " + sheetName);
|
||||
const rows: any[] = utils.sheet_to_json(ws, { defval: null });
|
||||
const table = args.table || "excel_data";
|
||||
if (!rows.length) return { ok: true, result: `工作表 "${sheetName}" 无数据行` };
|
||||
|
||||
const rawCols = Object.keys(rows[0]);
|
||||
const seen = new Set<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(", ");
|
||||
|
||||
// 超过上限时截断加载,并明确警告,避免 AI 基于不完整数据下结论
|
||||
const MAX = 50000;
|
||||
const slice = rows.slice(0, MAX);
|
||||
const truncated = rows.length > MAX;
|
||||
|
||||
const csvPath = resolve(ctx.duckdb.dbFilePath, "..", `load_${Date.now()}.csv`);
|
||||
mkdirSync(dirname(csvPath), { recursive: true });
|
||||
const csvLines = [cols.join(",")];
|
||||
for (const r of slice) csvLines.push(cols.map((c) => csvCell(r[c])).join(","));
|
||||
writeFileSync(csvPath, csvLines.join("\n"), "utf-8");
|
||||
const csvSql = csvPath.replace(/\\/g, "/");
|
||||
try {
|
||||
await ctx.duckdb.exec(
|
||||
`CREATE OR REPLACE TABLE "${table}" (${colDefs}); COPY "${table}" FROM '${csvSql}' (FORMAT csv, HEADER true, DELIMITER ',');`
|
||||
);
|
||||
} finally {
|
||||
try { unlinkSync(csvPath); } catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
result: {
|
||||
table,
|
||||
sheet: sheetName,
|
||||
rowCount: rows.length,
|
||||
truncated,
|
||||
columns: cols.map((c) => ({ name: c, type: types[c] })),
|
||||
preview: slice.slice(0, 5),
|
||||
note: `已加载为 DuckDB 表 "${table}"。以上仅展示前 5 行用于理解数据结构(列名/类型),真实数据分析请用 query_duckdb 对该表执行 SQL(列名用双引号引用,例如 SELECT * FROM "${table}" LIMIT 10)` +
|
||||
(truncated ? `;注意:行数 ${rows.length} 超过 ${MAX},已截断加载,分析结论可能不完整` : ""),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
registerTool({
|
||||
name: "query_duckdb",
|
||||
description: "在已加载的 DuckDB 表上执行 SQL 查询(SELECT 等)。列名用双引号引用,例如 SELECT \"姓名\", SUM(\"金额\") FROM excel_data GROUP BY \"姓名\"。",
|
||||
parameters: objParams([param("sql", "要执行的 SQL 语句")], ["sql"]),
|
||||
meta: { label: "SQL 查询", icon: "🧮", category: "Excel / 数据" },
|
||||
execute: async (args, ctx) => {
|
||||
const sql = String(args.sql || "").trim();
|
||||
if (!sql) throw new Error("缺少 SQL 语句");
|
||||
const rows = await ctx.duckdb.query(sql, { signal: ctx.signal });
|
||||
const cols = rows.length ? Object.keys(rows[0]) : [];
|
||||
return {
|
||||
ok: true,
|
||||
result: { rowCount: rows.length, columns: cols, data: rows.slice(0, 100) },
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface ChatMsg {
|
||||
role: string;
|
||||
content: any;
|
||||
tool_calls?: any[];
|
||||
name?: string;
|
||||
tool_call_id?: string;
|
||||
}
|
||||
@@ -58,8 +58,8 @@
|
||||
|
||||
### 10. load_excel — 把 Excel 加载进 DuckDB(数据分析第一步)
|
||||
- 参数:`path`(必填,Excel 文件路径)、`sheet`(可选,工作表名,默认第一个)、`table`(可选,目标表名,默认 `excel_data`)
|
||||
- 用途:把 Excel 导入 DuckDB 内存表,返回表名、列名与类型、前几行预览。
|
||||
- 之后即可用 `query_duckdb` 对这张表执行任意 SQL。
|
||||
- 用途:把 Excel 导入 DuckDB 表,返回表名、列名与类型、**前 5 行预览**。预览仅供理解数据结构(列名/类型/样例),**真实数据分析一律用 `query_duckdb` 对该表执行 SQL**。
|
||||
- 注意:行数超过 5 万时会截断加载并在结果中明确警告,此时分析结论可能不完整。
|
||||
- 示例:`{"path": "D:/workbench/上海付迅...对账单.xlsx", "table": "bill"}`
|
||||
|
||||
### 11. query_duckdb — 在 DuckDB 上执行 SQL(数据分析核心)
|
||||
|
||||
+8
-3
@@ -1,14 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
// 代理目标端口与后端一致(读取 .env 的 PORT,默认 3000)
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const port = parseInt(env.PORT || "3000", 10);
|
||||
return {
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
target: `http://127.0.0.1:${port}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
@@ -17,4 +21,5 @@ export default defineConfig({
|
||||
outDir: "dist",
|
||||
base: "./",
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user