save
This commit is contained in:
parent
65e368c541
commit
a82f855743
6
.env
6
.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
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -10,6 +10,9 @@ logs/
|
||||
*.err
|
||||
_be.*
|
||||
|
||||
# 会话记忆数据
|
||||
conversations/
|
||||
|
||||
# 环境变量(含密钥,勿提交;参考 .env.example)
|
||||
.env
|
||||
.env.local
|
||||
|
||||
133
server.ts
133
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<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 = "";
|
||||
|
||||
135
src/App.vue
135
src/App.vue
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user