chore: scaffold Phase 0 - Tauri2 + Rust + React + llama.cpp integration

This commit is contained in:
Xianren Studio
2026-08-13 12:48:50 +08:00
commit db03f62dbf
58 changed files with 13827 additions and 0 deletions
+380
View File
@@ -0,0 +1,380 @@
import { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
api,
ChatDoneEvent,
ChatErrorEvent,
ChatParams,
ChatTokenEvent,
onEvent,
} from "../api";
import { useStore } from "../store";
interface LocalMessage {
id: string;
role: "user" | "assistant";
content: string;
streaming?: boolean;
}
const defaultParams: ChatParams = {
temperature: 0.7,
top_p: 0.9,
max_tokens: 2048,
ctx_size: 4096,
ngl: 99,
};
export default function ChatPage() {
const models = useStore((s) => s.models);
const conversations = useStore((s) => s.conversations);
const refreshConversations = useStore((s) => s.refreshConversations);
const [activeConvId, setActiveConvId] = useState<string | null>(null);
const [messages, setMessages] = useState<LocalMessage[]>([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [params, setParams] = useState<ChatParams>(defaultParams);
const [selectedModelId, setSelectedModelId] = useState<string>("");
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (models.length > 0 && !selectedModelId) {
setSelectedModelId(models[0].id);
}
}, [models, selectedModelId]);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
useEffect(() => {
const un1 = onEvent<ChatTokenEvent>("chat://token", (e) => {
if (e.conversation_id !== activeConvId) return;
setMessages((prev) => {
const last = prev[prev.length - 1];
if (!last || last.role !== "assistant") {
return [
...prev,
{ id: `tmp-${Date.now()}`, role: "assistant", content: e.text, streaming: true },
];
}
const updated = [...prev];
updated[updated.length - 1] = {
...last,
content: last.content + e.text,
streaming: true,
};
return updated;
});
});
const un2 = onEvent<ChatDoneEvent>("chat://done", (e) => {
if (e.conversation_id !== activeConvId) return;
setMessages((prev) => {
const updated = [...prev];
const last = updated[updated.length - 1];
if (last && last.role === "assistant" && last.streaming) {
updated[updated.length - 1] = {
id: e.message_id,
role: "assistant",
content: e.content,
};
}
return updated;
});
setStreaming(false);
refreshConversations();
});
const un3 = onEvent<ChatErrorEvent>("chat://error", (e) => {
if (e.conversation_id !== activeConvId) return;
setStreaming(false);
setError(e.message);
});
return () => {
un1.then((f) => f());
un2.then((f) => f());
un3.then((f) => f());
};
}, [activeConvId, refreshConversations]);
async function handleSend() {
const content = input.trim();
if (!content || streaming) return;
if (!selectedModelId) {
setError("请先在模型库导入一个模型");
return;
}
setError(null);
setInput("");
let convId = activeConvId;
if (!convId) {
const conv = await api.createConversation(content.slice(0, 30));
convId = conv.id;
setActiveConvId(convId);
await refreshConversations();
}
setMessages((prev) => [
...prev,
{ id: `user-${Date.now()}`, role: "user", content },
{ id: `assistant-${Date.now()}`, role: "assistant", content: "", streaming: true },
]);
setStreaming(true);
try {
await api.chatSend({
conversation_id: convId,
model_id: selectedModelId,
content,
params,
});
} catch (e) {
setStreaming(false);
setError(String(e));
}
}
async function handleNewConversation() {
const conv = await api.createConversation("新会话");
setActiveConvId(conv.id);
setMessages([]);
setError(null);
await refreshConversations();
}
async function handleLoadConversation(id: string) {
setActiveConvId(id);
const msgs = await api.getMessages(id);
setMessages(
msgs.map((m) => ({
id: m.id,
role: m.role as "user" | "assistant",
content: m.content,
})),
);
setError(null);
}
async function handleDeleteConversation(id: string) {
await api.deleteConversation(id);
if (activeConvId === id) {
setActiveConvId(null);
setMessages([]);
}
await refreshConversations();
}
const engineRunning = useStore((s) => s.engine?.running ?? false);
const engineLabel = useMemo(() => {
if (!models.length) return "无可用模型";
return models.find((m) => m.id === selectedModelId)?.file_name ?? "选择模型";
}, [models, selectedModelId]);
return (
<div className="flex h-full">
<div className="flex w-60 flex-col border-r border-border bg-panel p-3">
<button className="btn-primary mb-3 w-full" onClick={handleNewConversation}>
+
</button>
<div className="flex-1 space-y-1 overflow-auto">
{conversations.map((c) => (
<div
key={c.id}
className={`group flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm ${
activeConvId === c.id ? "bg-panel-2 text-white" : "text-slate-300 hover:bg-panel-2/60"
}`}
onClick={() => handleLoadConversation(c.id)}
>
<span className="truncate">{c.title}</span>
<button
className="ml-1 hidden text-xs text-slate-500 hover:text-red-400 group-hover:block"
onClick={(e) => {
e.stopPropagation();
handleDeleteConversation(c.id);
}}
>
×
</button>
</div>
))}
</div>
<div className="mt-3 border-t border-border pt-3">
<label className="mb-1 block text-xs text-slate-400"></label>
<select
className="input w-full"
value={selectedModelId}
onChange={(e) => setSelectedModelId(e.target.value)}
>
{models.map((m) => (
<option key={m.id} value={m.id}>
{m.file_name}
</option>
))}
</select>
<div className="mt-2 flex gap-2">
<button
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
onClick={() =>
api.engineStart(selectedModelId, params).catch((e) => alert(String(e)))
}
disabled={engineRunning}
>
</button>
<button
className="flex-1 rounded-lg border border-border bg-panel-2 py-1.5 text-xs hover:bg-panel-2/60"
onClick={() => api.engineStop().catch((e) => alert(String(e)))}
disabled={!engineRunning}
>
</button>
</div>
</div>
</div>
<div className="flex flex-1 flex-col">
<div className="flex-1 space-y-4 overflow-auto p-6">
{messages.length === 0 && !error ? (
<div className="mt-24 text-center text-sm text-slate-500">
<div className="mb-2 text-3xl">💬</div>
<div className="mt-1 text-xs text-slate-600">
llama-server
</div>
</div>
) : null}
{messages.map((m) => (
<div key={m.id} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[85%] rounded-2xl px-4 py-3 text-sm leading-relaxed ${
m.role === "user"
? "bg-gradient-to-br from-accent to-accent-2 text-white"
: "markdown-body border border-border bg-panel"
}`}
>
{m.role === "user" ? (
m.content
) : m.content ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{m.content}</ReactMarkdown>
) : (
<span className="inline-flex items-center gap-1 text-slate-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
</span>
)}
</div>
</div>
))}
{error ? (
<div className="rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
) : null}
<div ref={bottomRef} />
</div>
<div className="border-t border-border bg-panel p-4">
<div className="mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-slate-400">
<ParamSlider
label="temperature"
value={params.temperature}
min={0}
max={2}
step={0.05}
onChange={(v) => setParams({ ...params, temperature: v })}
/>
<ParamSlider
label="top_p"
value={params.top_p}
min={0}
max={1}
step={0.05}
onChange={(v) => setParams({ ...params, top_p: v })}
/>
<ParamSlider
label="max_tokens"
value={params.max_tokens}
min={64}
max={8192}
step={64}
onChange={(v) => setParams({ ...params, max_tokens: v })}
/>
<ParamSlider
label="ctx_size"
value={params.ctx_size}
min={512}
max={32768}
step={512}
onChange={(v) => setParams({ ...params, ctx_size: v })}
/>
<ParamSlider
label="ngl(全部=99)"
value={params.ngl}
min={0}
max={99}
step={1}
onChange={(v) => setParams({ ...params, ngl: v })}
/>
<span className="ml-auto text-slate-500">{engineLabel}</span>
</div>
<div className="flex items-end gap-3">
<textarea
className="input min-h-[72px] flex-1 resize-none"
placeholder={`向本地模型提问…(当前 ${engineRunning ? "引擎运行中" : "引擎未启动,发送时将自动启动"}`}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<button
className="btn-primary h-[72px] px-6"
onClick={handleSend}
disabled={streaming || !input.trim()}
>
{streaming ? "生成中…" : "发送"}
</button>
</div>
</div>
</div>
</div>
);
}
function ParamSlider({
label,
value,
min,
max,
step,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
onChange: (v: number) => void;
}) {
return (
<label className="flex items-center gap-2">
<span className="w-24">{label}</span>
<input
type="range"
className="w-28 accent-indigo-500"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
<span className="w-12 font-mono text-slate-300">{value}</span>
</label>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useState } from "react";
import { api, DownloadProgressEvent, onEvent } from "../api";
export default function DownloadsPage() {
const [items, setItems] = useState<Record<string, DownloadProgressEvent>>({});
useEffect(() => {
const un1 = onEvent<DownloadProgressEvent>("download://progress", (e) => {
setItems((prev) => ({ ...prev, [e.id]: { ...e, status: "downloading" } }));
});
const un2 = onEvent<DownloadProgressEvent>("download://done", (e) => {
setItems((prev) => ({ ...prev, [e.id]: { ...e, status: "done" } }));
});
const un3 = onEvent<DownloadProgressEvent>("download://error", (e) => {
setItems((prev) => ({ ...prev, [e.id]: { ...e, status: "error" } }));
});
return () => {
un1.then((f) => f());
un2.then((f) => f());
un3.then((f) => f());
};
}, []);
async function handleAdd() {
const url = prompt("请输入 GGUF 文件直链(支持 hf-mirror / huggingface / modelscope):");
if (!url) return;
const name = url.split("/").pop() || "model.gguf";
const fileName = prompt("保存为(文件名):", name);
if (!fileName) return;
const sha256 = prompt("SHA256(可选,留空跳过校验):", "") || undefined;
const id = await api.downloadEnqueue(url, fileName, sha256);
setItems((prev) => ({
...prev,
[id]: {
id,
url,
file_name: fileName,
downloaded: 0,
total: null,
percent: null,
speed_bps: 0,
status: "downloading",
},
}));
}
const list = Object.values(items).sort((a, b) => b.id.localeCompare(a.id));
return (
<div className="h-full overflow-auto p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold"></h1>
<p className="mt-1 text-sm text-slate-400">
</p>
</div>
<button className="btn-primary" onClick={handleAdd}>
+
</button>
</div>
{list.length === 0 ? (
<div className="mt-16 text-center text-sm text-slate-500">
GGUF
</div>
) : (
<div className="space-y-3">
{list.map((item) => (
<div key={item.id} className="rounded-xl border border-border bg-panel p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{item.file_name}</div>
<div className="truncate text-xs text-slate-400">{item.url}</div>
</div>
<div className="ml-3 text-right text-xs text-slate-400">
{item.status === "done"
? "已完成 ✓"
: item.status === "error"
? "失败"
: `${item.percent?.toFixed(1) ?? 0}%`}
{item.status === "downloading" && item.speed_bps > 0
? ` · ${fmtSpeed(item.speed_bps)}`
: ""}
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-panel-2">
<div
className={`h-full rounded-full ${
item.status === "error" ? "bg-red-500" : "bg-gradient-to-r from-accent to-accent-2"
}`}
style={{ width: `${item.percent ?? 0}%` }}
/>
</div>
{item.status === "downloading" && item.total ? (
<div className="mt-1 text-xs text-slate-500">
{fmtSize(item.downloaded)} / {fmtSize(item.total)}
</div>
) : null}
</div>
))}
</div>
)}
</div>
);
}
function fmtSize(bytes: number) {
const mb = bytes / 1024 / 1024;
return mb >= 1024 ? `${(mb / 1024).toFixed(2)} GB` : `${mb.toFixed(0)} MB`;
}
function fmtSpeed(bps: number) {
const mbps = bps / 1024 / 1024;
return mbps >= 1 ? `${mbps.toFixed(1)} MB/s` : `${(bps / 1024).toFixed(0)} KB/s`;
}
+115
View File
@@ -0,0 +1,115 @@
import { useEffect } from "react";
import { api } from "../api";
import { useStore } from "../store";
function fmtSize(bytes: number) {
if (!bytes) return "-";
const gb = bytes / 1024 / 1024 / 1024;
return gb >= 1 ? `${gb.toFixed(2)} GB` : `${(bytes / 1024 / 1024).toFixed(0)} MB`;
}
export default function ModelsPage() {
const models = useStore((s) => s.models);
const refreshModels = useStore((s) => s.refreshModels);
useEffect(() => {
refreshModels();
}, [refreshModels]);
async function handleImport() {
const path = prompt("请输入本地 GGUF 文件路径:");
if (!path) return;
try {
await api.importModel(path);
await refreshModels();
} catch (e) {
alert(String(e));
}
}
async function handleRemove(id: string, name: string) {
if (!confirm(`确定从列表移除「${name}」吗?(不会删除文件)`)) return;
try {
await api.removeModel(id);
await refreshModels();
} catch (e) {
alert(String(e));
}
}
async function handleOpenDir() {
const info = await api.appInfo();
await api.openPath(info.models_dir);
}
return (
<div className="h-full overflow-auto p-6">
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold"></h1>
<p className="mt-1 text-sm text-slate-400">
GGUF
</p>
</div>
<div className="flex gap-2">
<button className="btn-secondary" onClick={handleOpenDir}>
</button>
<button className="btn-primary" onClick={handleImport}>
+
</button>
</div>
</div>
{models.length === 0 ? (
<div className="mt-16 text-center text-sm text-slate-500">
GGUF
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border bg-panel">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs uppercase text-slate-400">
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{models.map((m) => (
<tr key={m.id} className="border-b border-border/60 last:border-0">
<td className="px-4 py-3">
<div className="font-medium">{m.file_name}</div>
<div className="text-xs text-slate-400">{m.file_path}</div>
</td>
<td className="px-4 py-3">
<span className="rounded bg-panel-2 px-2 py-0.5 font-mono text-xs">
{m.quant ?? "-"}
</span>
</td>
<td className="px-4 py-3 text-slate-300">{fmtSize(m.file_size)}</td>
<td className="px-4 py-3 text-slate-400">{m.source}</td>
<td className="px-4 py-3">
<span className="text-emerald-400">{m.status}</span>
</td>
<td className="px-4 py-3 text-right">
<button
className="text-xs text-slate-400 hover:text-red-400"
onClick={() => handleRemove(m.id, m.file_name)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useState } from "react";
import { api } from "../api";
import { useStore } from "../store";
export default function ServerPage() {
const server = useStore((s) => s.server);
const refreshServer = useStore((s) => s.refreshServer);
const [port, setPort] = useState("1234");
const [apiKey, setApiKey] = useState("");
const [error, setError] = useState<string | null>(null);
const running = server?.running ?? false;
async function handleStart() {
setError(null);
try {
await api.serverStart(Number(port), apiKey);
await refreshServer();
} catch (e) {
setError(String(e));
}
}
async function handleStop() {
await api.serverStop();
await refreshServer();
}
const baseUrl = running ? `http://127.0.0.1:${server!.port}` : null;
return (
<div className="h-full overflow-auto p-6">
<h1 className="text-xl font-semibold"> API </h1>
<p className="mt-1 text-sm text-slate-400">
OpenAI SDK / HTTP
</p>
<div className="mt-6 max-w-xl space-y-4">
<div className="rounded-xl border border-border bg-panel p-5">
<div className="flex items-center gap-2">
<span className={`h-2.5 w-2.5 rounded-full ${running ? "bg-emerald-400" : "bg-slate-500"}`} />
<span className="text-sm font-medium">{running ? `运行中 · ${baseUrl}` : "未启动"}</span>
</div>
<div className="mt-4 space-y-3">
<label className="block text-sm">
<span className="text-xs text-slate-400"></span>
<input
className="input mt-1 w-full"
value={port}
disabled={running}
onChange={(e) => setPort(e.target.value.replace(/\D/g, ""))}
/>
</label>
<label className="block text-sm">
<span className="text-xs text-slate-400">API Key</span>
<input
className="input mt-1 w-full"
value={apiKey}
disabled={running}
onChange={(e) => setApiKey(e.target.value)}
placeholder="sk-..."
/>
</label>
</div>
<div className="mt-4 flex gap-2">
<button className="btn-primary" onClick={handleStart} disabled={running}>
</button>
<button className="btn-secondary" onClick={handleStop} disabled={!running}>
</button>
</div>
{error ? <div className="mt-3 text-sm text-red-300">{error}</div> : null}
</div>
{running ? (
<div className="rounded-xl border border-border bg-panel p-5">
<div className="text-xs uppercase text-slate-400"></div>
<div className="mt-2 space-y-1 font-mono text-sm">
<div>GET {baseUrl}/v1/models</div>
<div>POST {baseUrl}/v1/chat/completions</div>
<div>POST {baseUrl}/v1/embeddings</div>
<div>GET {baseUrl}/health</div>
</div>
<div className="mt-3 text-xs text-slate-500">
503
</div>
</div>
) : null}
</div>
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useEffect, useState } from "react";
import { api, AppInfo } from "../api";
export default function SettingsPage() {
const [info, setInfo] = useState<AppInfo | null>(null);
const [settings, setSettings] = useState<Record<string, string>>({});
useEffect(() => {
api.appInfo().then(setInfo);
api.settingsGet().then(setSettings);
}, []);
async function handleSave(key: string, value: string) {
await api.settingsSet(key, value);
setSettings((prev) => ({ ...prev, [key]: value }));
}
return (
<div className="h-full overflow-auto p-6">
<h1 className="text-xl font-semibold"></h1>
<p className="mt-1 text-sm text-slate-400"></p>
<div className="mt-6 max-w-xl space-y-4">
<div className="rounded-xl border border-border bg-panel p-5">
<div className="text-xs uppercase text-slate-400"></div>
<div className="mt-2 space-y-1 text-sm">
<div>
<span className="text-slate-300">{info?.version}</span>{info?.platform}
</div>
<div>
<span className="text-slate-300">{info?.db_path}</span>
</div>
<div>
{info?.engine_exists ? (
<span className="text-emerald-400"></span>
) : (
<span className="text-red-400"> llama-server </span>
)}
</div>
</div>
</div>
<div className="rounded-xl border border-border bg-panel p-5">
<div className="text-xs uppercase text-slate-400"></div>
<div className="mt-3 space-y-3">
<SettingInput
label="模型目录"
value={settings.model_dir ?? ""}
onSave={(v) => handleSave("model_dir", v)}
/>
<SettingInput
label="llama-server 路径(llama-server-cpu/cuda/vulkan.exe"
value={settings.engine_bin ?? ""}
onSave={(v) => handleSave("engine_bin", v)}
/>
<SettingInput
label="模型下载源(hf-mirror.com / huggingface.co / modelscope.cn"
value={settings.hf_endpoint ?? ""}
onSave={(v) => handleSave("hf_endpoint", v)}
/>
<SettingInput
label="默认后端(auto / cpu / cuda / vulkan"
value={settings.backend ?? ""}
onSave={(v) => handleSave("backend", v)}
/>
</div>
</div>
<div className="text-xs text-slate-500">
llama-server <code className="text-slate-300">scripts/fetch-llama.ps1</code>{" "}
exe
</div>
</div>
</div>
);
}
function SettingInput({
label,
value,
onSave,
}: {
label: string;
value: string;
onSave: (v: string) => void;
}) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
return (
<label className="block text-sm">
<span className="text-xs text-slate-400">{label}</span>
<div className="mt-1 flex gap-2">
<input className="input flex-1" value={draft} onChange={(e) => setDraft(e.target.value)} />
<button
className="btn-secondary"
onClick={() => onSave(draft)}
disabled={draft === value}
>
</button>
</div>
</label>
);
}