chore: scaffold Phase 0 - Tauri2 + Rust + React + llama.cpp integration
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user