feat(webui): enhanced settings page with outline, reload status, hot reload

This commit is contained in:
xiaoxixi 2026-07-26 22:25:01 +08:00
parent 1e984eef66
commit 7a845cffd5

View File

@ -6,20 +6,42 @@
let { notify } = $props(); let { notify } = $props();
let tab = $state("config"); let tab = $state("config");
let content = $state(""); let content = $state("");
let original = $state("");
let path = $state(""); let path = $state("");
let loading = $state(true); let loading = $state(true);
let saving = $state(false);
let reloadStatus = $state(null);
let pollTimer = null;
const isConfig = $derived(tab === "config");
const title = $derived(tab === "config" ? "运行配置" : tab === "user" ? "用户档案" : "Agent 行为准则"); const title = $derived(tab === "config" ? "运行配置" : tab === "user" ? "用户档案" : "Agent 行为准则");
const isDirty = $derived(content !== original);
const jsonParse = $derived.by(() => {
if (!isConfig || !content.trim()) return { value: null, error: null };
try { return { value: JSON.parse(content), error: null }; } catch (e) { return { value: null, error: e.message }; }
});
const jsonError = $derived(jsonParse.error);
const outlineKeys = $derived(jsonParse.value ? Object.keys(jsonParse.value) : []);
const phaseBadge = $derived.by(() => {
if (!reloadStatus) return "ok";
const map = { steady: "ok", pending: "run", preparing: "run", activating: "run", failed: "fail" };
return map[reloadStatus.phase] || "ok";
});
async function load() { async function load() {
loading = true; loading = true;
try { try {
if (tab === "config") { if (isConfig) {
const result = await api("/api/config"); const result = await api("/api/config");
content = JSON.stringify(result.config, null, 2); content = JSON.stringify(result.config, null, 2);
original = content;
path = result.path; path = result.path;
} else { } else {
const result = await api(`/api/profiles/${tab}`); const result = await api(`/api/profiles/${tab}`);
content = result.content; content = result.content;
original = content;
path = result.path; path = result.path;
} }
} catch (caught) { notify(caught.message, true); } } catch (caught) { notify(caught.message, true); }
@ -27,22 +49,67 @@
} }
async function save() { async function save() {
if (isConfig && jsonError) return;
saving = true;
try { try {
if (tab === "config") { if (isConfig) {
let config; const config = JSON.parse(content);
try { config = JSON.parse(content); } catch (caught) { throw new Error(`JSON 格式错误:${caught.message}`); }
const result = await api("/api/config", { method: "PUT", body: JSON.stringify({ config }) }); const result = await api("/api/config", { method: "PUT", body: JSON.stringify({ config }) });
content = JSON.stringify(result.config, null, 2); content = JSON.stringify(result.config, null, 2);
notify("配置已保存,请重启 Gateway 使其生效"); original = content;
notify("配置已保存");
} else { } else {
await api(`/api/profiles/${tab}`, { method: "PUT", body: JSON.stringify({ content }) }); await api(`/api/profiles/${tab}`, { method: "PUT", body: JSON.stringify({ content }) });
original = content;
notify("助手档案已保存"); notify("助手档案已保存");
} }
} catch (caught) { notify(caught.message, true); } } catch (caught) { notify(caught.message, true); }
finally { saving = false; }
} }
function changeTab(value) { tab = value; load(); } async function saveAndReload() {
onMount(load); if (jsonError) return;
saving = true;
try {
const config = JSON.parse(content);
const result = await api("/api/config", { method: "PUT", body: JSON.stringify({ config }) });
content = JSON.stringify(result.config, null, 2);
original = content;
const reload = await api("/api/config/reload", { method: "POST" });
notify(reload.message || "配置已保存并触发热重载");
pollReloadStatus();
} catch (caught) { notify(caught.message, true); }
finally { saving = false; }
}
function discard() { content = original; }
async function pollReloadStatus() {
try { reloadStatus = await api("/api/config/reload/status"); } catch {}
}
function startPolling() {
stopPolling();
pollReloadStatus();
pollTimer = setInterval(pollReloadStatus, 3000);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
function changeTab(value) {
tab = value;
stopPolling();
load();
if (value === "config") startPolling();
}
onMount(() => {
load();
startPolling();
return stopPolling;
});
</script> </script>
<section class="page active content-page"> <section class="page active content-page">
@ -52,10 +119,84 @@
<Tabs.Trigger value="config">config.json</Tabs.Trigger><Tabs.Trigger value="user">USER.md</Tabs.Trigger><Tabs.Trigger value="agents">AGENTS.md</Tabs.Trigger> <Tabs.Trigger value="config">config.json</Tabs.Trigger><Tabs.Trigger value="user">USER.md</Tabs.Trigger><Tabs.Trigger value="agents">AGENTS.md</Tabs.Trigger>
</Tabs.List> </Tabs.List>
</Tabs.Root> </Tabs.Root>
<div class="editor-card">
<div class="editor-head"><div><strong>{title}</strong><small>{path}</small></div><button class="primary" onclick={save} disabled={loading}>保存更改</button></div> {#if isConfig}
<textarea bind:value={content} spellcheck="false" disabled={loading}></textarea> <div class="config-layout">
<div class="notice">{#if tab === "config"}API Key 等敏感字段显示为 <code>********</code>,保持不变即可保留原值。配置保存后需重启 Gateway 生效。{:else}Markdown 内容会在后续新会话和上下文构建中供 PicoBot 使用。{/if}</div> <div class="editor-card">
</div> <div class="editor-head">
<div><strong>{title}</strong><small>{path}</small></div>
<div class="editor-actions">
{#if isDirty}<span class="dirty-badge">未保存</span>{/if}
<button class="secondary" onclick={discard} disabled={!isDirty || loading || saving}>放弃修改</button>
<button class="secondary" onclick={saveAndReload} disabled={loading || saving || !!jsonError}>保存并热重载</button>
<button class="primary" onclick={save} disabled={loading || saving || !!jsonError}>保存</button>
</div>
</div>
<textarea bind:value={content} spellcheck="false" disabled={loading}></textarea>
{#if jsonError}<div class="json-error">JSON 格式错误:{jsonError}</div>{/if}
<div class="notice">API Key 等敏感字段显示为 <code>********</code>,保持不变即可保留原值。</div>
</div>
<aside class="config-sidebar">
<div class="panel sidebar-panel">
<span class="label-caps">配置大纲</span>
{#if outlineKeys.length}
<ul class="outline-list">
{#each outlineKeys as key}
<li>
<span class="mono">{key}</span>
{#if key === "gateway"}<span class="outline-note">⚠ 含重启字段</span>{:else if key === "providers"}<span class="outline-note">🔑 含密钥</span>{/if}
</li>
{/each}
</ul>
{:else}
<p class="sidebar-empty">{jsonError ? "JSON 无效" : "暂无内容"}</p>
{/if}
</div>
<div class="panel sidebar-panel">
<span class="label-caps">热重载状态</span>
{#if reloadStatus}
<div class="reload-row"><span>Generation</span><span class="mono">{reloadStatus.generation}</span></div>
<div class="reload-row"><span>Phase</span><span class="badge {phaseBadge}">{reloadStatus.phase}</span></div>
{#if reloadStatus.last_error}<div class="reload-error">{reloadStatus.last_error}</div>{/if}
{:else}
<p class="sidebar-empty">加载中…</p>
{/if}
</div>
</aside>
</div>
{:else}
<div class="editor-card">
<div class="editor-head">
<div><strong>{title}</strong><small>{path}</small></div>
<div class="editor-actions">
{#if isDirty}<span class="dirty-badge">未保存</span>{/if}
<button class="primary" onclick={save} disabled={loading || saving}>保存更改</button>
</div>
</div>
<textarea bind:value={content} spellcheck="false" disabled={loading}></textarea>
<div class="notice">Markdown 内容会在后续新会话和上下文构建中供 PicoBot 使用。</div>
</div>
{/if}
</div> </div>
</section> </section>
<style>
.config-layout { display: grid; grid-template-columns: minmax(0, 1fr) 230px; gap: 18px; align-items: start; }
.config-sidebar { display: grid; gap: 14px; }
.sidebar-panel { padding: 14px; }
.sidebar-empty { margin: 10px 0 0; color: var(--muted); font-size: 11px; }
.outline-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 7px; }
.outline-list li { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 12px; color: var(--text-soft); }
.outline-note { font-size: 10px; color: var(--muted); white-space: nowrap; }
.reload-row { display: flex; justify-content: space-between; align-items: center; padding: 7px 0; font-size: 12px; color: var(--text-soft); }
.reload-row + .reload-row { border-top: 1px solid var(--line); }
.reload-error { margin-top: 8px; padding: 8px 10px; border-radius: 8px; background: var(--danger-soft); color: var(--danger); font-size: 11px; line-height: 1.5; }
.json-error { padding: 9px 15px; color: var(--danger); font-size: 11px; background: var(--danger-soft); border-top: 1px solid var(--danger-border); }
.dirty-badge { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-weight: 600; color: var(--accent); background: var(--accent-soft); border: 1px solid var(--accent-border); border-radius: 99px; padding: 3px 9px; white-space: nowrap; }
.dirty-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
.editor-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.editor-head { min-height: 62px; height: auto; padding: 10px 15px; flex-wrap: wrap; gap: 8px; }
@media (max-width: 800px) { .config-layout { grid-template-columns: 1fr; } }
</style>