diff --git a/.env b/.env index 1b71d0a..0ceba47 100644 --- a/.env +++ b/.env @@ -1,7 +1,7 @@ # Mini AI Assistant 配置(可由设置页面修改) -BASE_URL=https://ark.cn-beijing.volces.com/api/coding/v3 -API_KEY=ark-8d00ad01-f5b3-4d25-8c49-5ff9c69e1308-ebe56 -MODEL=ark-code-latest +BASE_URL=https://opencode.ai/zen/v1 +API_KEY=sk-ihICiTIZTPdQ0FlhA7ru1kFMpVnxcvHNaK0oxem0ecnz7CpPx0xRoBMCPQhPDmmf +MODEL=deepseek-v4-flash-free WORKSPACE=D:\workbench\miniai CMD_APPROVE_LEVEL=medium PORT=3100 diff --git a/.gitignore b/.gitignore index 8ee8fc1..f40891a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ logs/ *.err _be.* +# 会话记忆数据 +conversations/ + # 环境变量(含密钥,勿提交;参考 .env.example) .env .env.local diff --git a/server.ts b/server.ts index 4b0eda8..9e9c4c4 100644 --- a/server.ts +++ b/server.ts @@ -1,5 +1,5 @@ import { createServer } from "http"; -import { readFileSync, existsSync, writeFileSync, statSync } from "fs"; +import { readFileSync, existsSync, writeFileSync, statSync, mkdirSync, readdirSync, unlinkSync } from "fs"; import { appendFile, mkdir, readFile } from "fs/promises"; // ---------- 配置加载 ---------- @@ -567,6 +567,38 @@ async function serveStatic(res: any, url: URL): Promise { 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}`); @@ -631,6 +663,105 @@ const server = createServer(async (req, res) => { 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 = ""; diff --git a/src/App.vue b/src/App.vue index 7a69e29..69404e7 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,22 @@ @@ -65,6 +83,8 @@ interface Msg { } const messages = ref([]); +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(null); @@ -134,6 +154,7 @@ async function consumeStream(response: Response, target: Msg) { 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", ""); @@ -151,10 +172,12 @@ async function send(text: string) { }), }); await consumeStream(resp, assistant); + await saveConversation(); } catch (e: any) { assistant.content = "⚠️ 请求失败:" + e.message; assistant.loading = false; busy.value = false; + await saveConversation(); } } @@ -174,6 +197,7 @@ async function handleDecision(approved: boolean, m: Msg) { body: JSON.stringify({ approved, tool_call_id: toolCallId }), }); await consumeStream(resp, assistant); + await saveConversation(); } catch (e: any) { assistant.content = "⚠️ 请求失败:" + e.message; assistant.loading = false; @@ -186,6 +210,67 @@ 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() { @@ -194,12 +279,18 @@ function toggleTheme() { localStorage.setItem("theme", theme.value); } -onMounted(() => { +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(); + } }); @@ -251,13 +342,49 @@ body {