This commit is contained in:
Cheney 2026-08-16 20:21:00 +08:00
parent 65e368c541
commit a82f855743
5 changed files with 320 additions and 8 deletions

6
.env
View File

@ -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

3
.gitignore vendored
View File

@ -10,6 +10,9 @@ logs/
*.err
_be.*
# 会话记忆数据
conversations/
# 环境变量(含密钥,勿提交;参考 .env.example
.env
.env.local

133
server.ts
View File

@ -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<boolean> {
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 = "";

View File

@ -1,5 +1,22 @@
<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">
<header class="topbar">
<div class="brand">
<div class="logo"><AssistantAvatar /></div>
@ -38,6 +55,7 @@
<InputArea :disabled="busy" @send="send" />
<SettingsModal v-if="showSettings" :api-base="API_BASE" @close="showSettings = false" />
</div>
</div>
</template>
@ -65,6 +83,8 @@ interface Msg {
}
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);
@ -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();
}
});
</script>
@ -251,13 +342,49 @@ body {
<style scoped>
.app {
display: flex;
flex-direction: column;
height: 100vh;
max-width: 880px;
margin: 0 auto;
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;

View File

@ -39,6 +39,16 @@
<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>
@ -95,6 +105,32 @@ const form = reactive({
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 {
@ -190,4 +226,19 @@ h4 { margin: 0 0 10px; font-size: 13px; color: var(--text-dim); text-transform:
.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>