feat: 日志六环节记录与远程推送、工具参数容错/多调用批量执行/路径自动补全、token空值保护;提交内置 duckdb.exe;移除含密钥的 .env
This commit is contained in:
parent
8ce9361d96
commit
e848ac91a7
12
.env
12
.env
@ -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
1
.gitignore
vendored
@ -14,7 +14,6 @@ _be.*
|
||||
conversations/
|
||||
|
||||
# 内置 duckdb 可执行文件与运行时数据库
|
||||
bin/
|
||||
data/
|
||||
|
||||
# 环境变量(含密钥,勿提交;参考 .env.example)
|
||||
|
||||
BIN
bin/duckdb.exe
Normal file
BIN
bin/duckdb.exe
Normal file
Binary file not shown.
184
server.ts
184
server.ts
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user