refactor: 后端拆分为 src/server 模块,前端迁移至路由分页(Chat/Skills);设置改为抽屉、新增工具卡片组件;提交测试用 .env

This commit is contained in:
cheney
2026-08-24 14:11:04 +08:00
parent d037f0d9cb
commit 06b814589f
31 changed files with 2833 additions and 1649 deletions
+11
View File
@@ -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
View File
@@ -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>
+2 -939
View File
@@ -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();
+54 -379
View File
@@ -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();
function gotoHome() {
router.push("/");
}
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];
}
// 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
View File
@@ -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),
});
}
+21 -8
View File
@@ -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>
+75 -50
View File
@@ -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>
+3 -2
View File
@@ -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>
+235
View File
@@ -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>
-233
View File
@@ -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>
+123
View File
@@ -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>
+22
View File
@@ -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
View File
@@ -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; }
+33
View File
@@ -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
View File
@@ -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");
+392
View File
@@ -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>
+185
View File
@@ -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>
+12
View File
@@ -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 },
],
});
+259
View File
@@ -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/`);
});
}
+287
View File
@@ -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 });
}
+70
View File
@@ -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);
}
}
+156
View File
@@ -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);
}
}
+40
View File
@@ -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} 行)`;
}
+132
View File
@@ -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;
+103
View File
@@ -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;
}
+77
View File
@@ -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;
}
+45
View File
@@ -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 };
+366
View File
@@ -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) },
};
},
});
+7
View File
@@ -0,0 +1,7 @@
export interface ChatMsg {
role: string;
content: any;
tool_calls?: any[];
name?: string;
tool_call_id?: string;
}
+2 -2
View File
@@ -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
View File
@@ -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: "./",
},
};
});