Files
xianren_studio/ui/src/pages/SettingsPage.tsx
T

475 lines
17 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, useMemo, useRef, useState } from "react";
import { api, AppInfo, ModelInfo } from "../api";
import { useStore } from "../store";
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 [startupMsg, setStartupMsg] = useState<string | null>(null);
const [autostart, setAutostart] = useState<boolean | null>(null);
const [autoLoadDraft, setAutoLoadDraft] = useState<string[]>([]);
const [pickId, setPickId] = useState("");
const uploadInputRef = useRef<HTMLInputElement>(null);
const models = useStore((s) => s.models);
const refreshModels = useStore((s) => s.refreshModels);
useEffect(() => {
api.appInfo().then(setInfo);
api.settingsGet().then(setSettings);
api.autostartStatus().then(setAutostart).catch(() => setAutostart(false));
refreshModels();
}, []);
useEffect(() => {
setRecommendDirDraft(settings.recommend_dir ?? "");
}, [settings.recommend_dir]);
useEffect(() => {
try {
const parsed = JSON.parse(settings.auto_load_models ?? "[]");
setAutoLoadDraft(Array.isArray(parsed) ? parsed : []);
} catch {
setAutoLoadDraft([]);
}
}, [settings.auto_load_models]);
async function handleSave(key: string, value: string) {
await api.settingsSet(key, value);
setSettings((prev) => ({ ...prev, [key]: value }));
}
async function handleAutostartToggle(checked: boolean) {
try {
setAutostart(await api.autostartSet(checked));
setStartupMsg(checked ? "已开启开机启动" : "已关闭开机启动");
} catch (e) {
setStartupMsg(`开机启动设置失败:${String(e)}`);
}
}
const localModels = useMemo(
() => models.filter((m) => m.kind === "local"),
[models],
);
const draftRows = useMemo(
() =>
autoLoadDraft
.map((id) => localModels.find((m) => m.id === id))
.filter((m): m is ModelInfo => Boolean(m)),
[autoLoadDraft, localModels],
);
const available = useMemo(
() => localModels.filter((m) => !autoLoadDraft.includes(m.id)),
[localModels, autoLoadDraft],
);
function moveRow(index: number, dir: -1 | 1) {
setAutoLoadDraft((prev) => {
const next = [...prev];
const target = index + dir;
if (target < 0 || target >= next.length) return prev;
[next[index], next[target]] = [next[target], next[index]];
return next;
});
}
function removeRow(index: number) {
setAutoLoadDraft((prev) => prev.filter((_, i) => i !== index));
}
async function handleSaveAutoLoad() {
await handleSave("auto_load_models", JSON.stringify(draftRows.map((m) => m.id)));
}
function modelDisplayName(m: ModelInfo) {
return m.file_name.replace(/\.gguf$/i, "");
}
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>
{startupMsg ? (
<div className="mt-2 text-xs text-slate-400">{startupMsg}</div>
) : null}
<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">
登录 Windows 后自动启动仙人工作室
</div>
</div>
<Toggle
checked={autostart === true}
disabled={autostart === null}
onChange={handleAutostartToggle}
/>
</div>
<div className="mt-4">
<div className="text-sm">自动加载模型</div>
<div className="mt-0.5 text-xs text-slate-400">
应用启动后按列表顺序逐个加载本地模型;由于同一时间只能运行一个模型,全部加载完成后保留列表最后一个在运行
</div>
<div className="mt-3 space-y-2">
{draftRows.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-panel-2/50 px-3 py-5 text-center text-xs text-slate-500">
还没有添加模型,从下方下拉框选择要自动加载的本地模型
</div>
) : (
draftRows.map((m, i) => (
<div
key={m.id}
className="flex items-center gap-2 rounded-lg border border-border bg-panel-2 px-3 py-2"
>
<span className="text-xs text-slate-500">{i + 1}</span>
<span className="min-w-0 flex-1 truncate text-sm">
{modelDisplayName(m)}
</span>
<button
className="rounded border border-border px-1.5 py-0.5 text-xs text-slate-400 hover:text-slate-200 disabled:opacity-40"
onClick={() => moveRow(i, -1)}
disabled={i === 0}
title="上移"
>
</button>
<button
className="rounded border border-border px-1.5 py-0.5 text-xs text-slate-400 hover:text-slate-200 disabled:opacity-40"
onClick={() => moveRow(i, 1)}
disabled={i === draftRows.length - 1}
title="下移"
>
</button>
<button
className="rounded border border-border px-1.5 py-0.5 text-xs text-slate-400 hover:text-red-400"
onClick={() => removeRow(i)}
title="移除"
>
×
</button>
</div>
))
)}
</div>
<div className="mt-3 flex items-center gap-2">
<select
className="input flex-1"
value={pickId}
onChange={(e) => setPickId(e.target.value)}
>
<option value="">
{available.length > 0
? "选择要添加的本地模型…"
: "没有可添加的本地模型"}
</option>
{available.map((m) => (
<option key={m.id} value={m.id}>
{modelDisplayName(m)}
</option>
))}
</select>
<button
className="btn-secondary shrink-0"
disabled={!pickId}
onClick={() => {
setAutoLoadDraft((prev) => [...prev, pickId]);
setPickId("");
}}
>
添加
</button>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<span className="text-[11px] text-slate-500">
列表顺序即加载顺序,可拖动上下按钮调整
</span>
<button
className="btn-primary px-3 py-1.5 text-xs"
onClick={handleSaveAutoLoad}
disabled={
JSON.stringify(draftRows.map((m) => m.id)) ===
JSON.stringify(autoLoadDraft)
}
>
保存列表
</button>
</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 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,
disabled = false,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${
checked ? "bg-accent" : "bg-panel-2"
} ${disabled ? "cursor-not-allowed opacity-50" : ""}`}
>
<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>
);
}