feat: 日志六环节记录与远程推送、工具参数容错/多调用批量执行/路径自动补全、token空值保护;提交内置 duckdb.exe;移除含密钥的 .env

This commit is contained in:
Cheney 2026-08-16 23:54:01 +08:00
parent 8ce9361d96
commit e848ac91a7
4 changed files with 132 additions and 65 deletions

12
.env
View File

@ -1,12 +0,0 @@
# Mini AI Assistant 配置(可由设置页面修改)
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
.gitignore vendored
View File

@ -14,7 +14,6 @@ _be.*
conversations/
# 内置 duckdb 可执行文件与运行时数据库
bin/
data/
# 环境变量(含密钥,勿提交;参考 .env.example

BIN
bin/duckdb.exe Normal file

Binary file not shown.

184
server.ts
View File

@ -393,7 +393,9 @@ async function* streamLLM(messages: ChatMsg[]): AsyncGenerator<{ type: "token" |
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let toolAcc: any = null;
// 支持一次响应返回多个工具调用:按 id 聚合,各自独立
const toolCalls = new Map<string, any>();
let anonIdx = 0;
while (true) {
const { done, value } = await reader.read();
@ -412,17 +414,18 @@ async function* streamLLM(messages: ChatMsg[]): AsyncGenerator<{ type: "token" |
if (delta.content) yield { type: "token", content: delta.content };
if (delta.tool_calls) {
for (const tc of delta.tool_calls) {
if (!toolAcc) toolAcc = { id: "", type: "function", function: { name: "", arguments: "" } };
if (tc.id) toolAcc.id = tc.id;
if (tc.function?.name) toolAcc.function.name = tc.function.name;
if (tc.function?.arguments) toolAcc.function.arguments += tc.function.arguments;
const id = tc.id || `call_anon_${anonIdx++}`;
if (!toolCalls.has(id)) toolCalls.set(id, { id, type: "function", function: { name: "", arguments: "" } });
const acc = toolCalls.get(id);
if (tc.function?.name) acc.function.name = tc.function.name;
if (tc.function?.arguments) acc.function.arguments += tc.function.arguments;
}
}
}
}
if (toolAcc) {
if (!toolAcc.id) toolAcc.id = "call_" + Date.now() + "_" + Math.random().toString(36).slice(2, 7);
yield { type: "toolcall", toolcall: toolAcc };
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 };
}
}
@ -449,6 +452,64 @@ function withSystem(msgs: ChatMsg[]): ChatMsg[] {
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);
@ -461,21 +522,24 @@ async function runConversation(initialMessages: ChatMsg[], res: any, fromApprova
log("info", "对话开始", fromApproval ? "审批后继续" : "新对话");
if (userInput) log("info", "用户输入", userInput);
// 本对话已知的最近文件/目录(供工具缺 path 时自动补全)
const ctx = { lastDir: "", lastFile: "" };
// 工具循环(最多 10 轮避免死循环)
for (let round = 0; round < 10; round++) {
// 发送给 AI 的请求
log("info", "发送给AI", `${round + 1}轮 model=${config.MODEL} temperature=${config.TEMPERATURE}\n${fmtMessages(messages)}`);
const gen = streamLLM(messages);
let content = "";
let toolCall: any = null;
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") {
toolCall = ev.toolcall;
toolCalls.push(ev.toolcall);
} else if (ev.type === "error") {
errored = true;
llmErr = ev.content;
@ -491,48 +555,62 @@ async function runConversation(initialMessages: ChatMsg[], res: any, fromApprova
return;
}
// 需要工具调用
if (toolCall && toolCall.function?.name) {
const name = toolCall.function.name;
let args = {};
try { args = toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {}; } catch { args = {}; }
// AI 返回:决定调用工具
log("info", "AI返回", `调用工具 ${name},参数 ${safeStringify(args)}`);
if (content) log("info", "返回界面", clip(content, 2000));
// 需要工具调用:本轮把模型返回的多个工具调用全部执行,统一把结果返回给模型
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[] = [];
if (needsApproval(name, args)) {
// 请求审批,保存上下文等待 /api/approve
log("info", "执行命令", `${name} 参数 ${safeStringify(args)}(等待用户审批)`);
pendingApproval = {
messages: [
...messages,
{ role: "assistant", content: content || null, tool_calls: [toolCall] },
],
toolCall,
};
sseSend(res, {
type: "approval_required",
tool_call_id: toolCall.id,
tool: name,
args,
command: name === "execute_command" ? args.command : `${name}(${JSON.stringify(args)})`,
});
sseSend(res, { type: "done" });
res.end();
return;
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 });
}
// 执行命令/工具
log("info", "执行命令", `${name} 参数 ${safeStringify(args)}`);
const exec = await execTool(name, args);
// 执行结果
log("info", "执行结果", `ok=${exec.ok} ${clip(exec.result, 1000)}`);
messages = [
...messages,
{ role: "assistant", content: content || null, tool_calls: [toolCall] },
{ role: "tool", name, content: safeStringify(exec), tool_call_id: toolCall.id },
];
// 继续循环,让模型基于工具结果生成最终回复
if (content) log("info", "返回界面", clip(content, 2000));
if (calls.length > 1) log("info", "工具调用", `本轮共执行 ${calls.length} 个工具调用`);
messages = [...messages, assistantMsg, ...toolResults];
// 继续循环,让模型基于全部工具结果生成最终回复
continue;
}
@ -815,11 +893,13 @@ const server = createServer(async (req, res) => {
res.end(JSON.stringify({ error: "无待审批操作" }));
return;
}
const { messages, toolCall } = pendingApproval;
const { messages, toolCall, args: pendingArgs } = pendingApproval;
pendingApproval = null;
const name = toolCall.function.name;
let args = {};
try { args = toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {}; } catch {}
// 优先使用审批时的已补全参数;否则重新容错解析
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) {