Files
xianren_studio/ui/src/pages/SettingsPage.tsx
T
xianrenge 10ecf526ec feat: add MCP/skill tool protocol, tasks/tools pages, chat UX overhaul
Tools page: skill & MCP server management with [[skill:]]/[[mcp:]] protocol (up to 3 rounds). New Tasks and Tools pages. Chat: session snapshots, empty-session reuse, per-message model_id, unified JSON prediction suggestions. Settings: upload size limit, prediction options, model enable/disable. Schema additions with ensure_column migrations. Add FEATURES/ARCHITECTURE docs.
2026-08-16 19:50:41 +08:00

295 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, 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>>({});
const [recommendDirDraft, setRecommendDirDraft] = useState("");
const [uploadMsg, setUploadMsg] = useState<string | null>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
api.appInfo().then(setInfo);
api.settingsGet().then(setSettings);
}, []);
useEffect(() => {
setRecommendDirDraft(settings.recommend_dir ?? "");
}, [settings.recommend_dir]);
async function handleSave(key: string, value: string) {
await api.settingsSet(key, value);
setSettings((prev) => ({ ...prev, [key]: value }));
}
async function handleUpload(file: File) {
setUploadMsg(null);
try {
const text = await file.text();
await api.importRecommendations(file.name, text);
setUploadMsg(`已导入 ${file.name}`);
} catch (e) {
setUploadMsg(String(e));
} finally {
if (uploadInputRef.current) uploadInputRef.current.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 className="flex items-center gap-2">
<span>
日志目录:<span className="text-slate-300">{info?.logs_dir}</span>
</span>
{info?.logs_dir ? (
<button
className="text-xs text-slate-400 hover:text-slate-200"
onClick={() => api.openPath(info.logs_dir!)}
>
打开
</button>
) : null}
</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)}
/>
<SettingInput
label="上传图片/文件大小上限(MB"
value={settings.upload_max_mb ?? "10"}
onSave={(v) => handleSave("upload_max_mb", v)}
/>
</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 flex items-center justify-between rounded-lg border border-border bg-panel-2 px-3 py-2.5">
<div>
<div className="text-sm">自动生成对话标题</div>
<div className="mt-0.5 text-xs text-slate-400">
首次对话生成回复后,自动为会话生成标题并覆盖
</div>
</div>
<Toggle
checked={settings.auto_title !== "false"}
onChange={(checked) =>
handleSave("auto_title", checked ? "true" : "false")
}
/>
</div>
<div className="mt-3 flex items-center justify-between rounded-lg border border-border bg-panel-2 px-3 py-2.5">
<div>
<div className="text-sm">自动预测用户接下来说的话</div>
<div className="mt-0.5 text-xs text-slate-400">
大模型回答完成后自动预测几条用户可能要说的话,点击即可填入输入框
</div>
</div>
<Toggle
checked={settings.suggest_enabled !== "false"}
onChange={(checked) =>
handleSave("suggest_enabled", checked ? "true" : "false")
}
/>
</div>
<div className="mt-3">
<SettingInput
label="预测条数(1-5"
value={settings.suggest_count ?? "3"}
onSave={(v) => {
const n = Math.max(1, Math.min(5, Number(v) || 3));
handleSave("suggest_count", String(n));
}}
/>
</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">
<div>
<span className="text-xs text-slate-400">
JSON 文件目录(软件自带默认列表,上传的 JSON 也会保存到这里)
</span>
<div className="mt-1 flex gap-2">
<input
className="input flex-1"
value={recommendDirDraft}
onChange={(e) => setRecommendDirDraft(e.target.value)}
/>
<button
className="btn-secondary"
onClick={() => handleSave("recommend_dir", recommendDirDraft)}
disabled={recommendDirDraft === settings.recommend_dir}
>
保存
</button>
<button
className="btn-secondary"
onClick={() =>
recommendDirDraft && api.openPath(recommendDirDraft)
}
>
打开
</button>
</div>
</div>
<div className="flex items-center gap-2">
<input
ref={uploadInputRef}
type="file"
accept=".json"
className="hidden"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
handleUpload(e.target.files[0]);
}
}}
/>
<button
className="btn-secondary"
onClick={() => uploadInputRef.current?.click()}
>
上传 JSON
</button>
<span className="text-xs text-slate-500">
可上传多个推荐列表 JSON,同名文件将覆盖
</span>
</div>
{uploadMsg ? (
<div className="text-xs text-slate-400">{uploadMsg}</div>
) : null}
<div className="text-xs text-slate-500">JSON 格式示例:</div>
<pre className="mt-1 overflow-x-auto rounded-lg bg-panel-2 p-2 text-[11px] leading-relaxed text-slate-400">
{`{
"models": [
{
"id": "Qwen/Qwen2.5-7B-Instruct-GGUF",
"source": "huggingface",
"params": "7B",
"architecture": "dense",
"vision": false,
"files": [
{
"path": "xx-q4_k_m.gguf",
"quant": "Q4_K_M",
"size_gb": 4.68,
"links": [
"https://hf-mirror.com/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/xx-q4_k_m.gguf",
"https://modelscope.cn/models/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/master/xx-q4_k_m.gguf"
]
}
]
}
]
}`}
</pre>
</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 Toggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
checked ? "bg-accent" : "bg-panel-2"
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-all ${
checked ? "left-[22px]" : "left-0.5"
}`}
/>
</button>
);
}
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>
);
}