feat(webui): enhanced settings page with outline, reload status, hot reload
This commit is contained in:
parent
1e984eef66
commit
7a845cffd5
@ -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>
|
||||||
|
|
||||||
|
{#if isConfig}
|
||||||
|
<div class="config-layout">
|
||||||
<div class="editor-card">
|
<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>
|
<div class="editor-head">
|
||||||
<textarea bind:value={content} spellcheck="false" disabled={loading}></textarea>
|
<div><strong>{title}</strong><small>{path}</small></div>
|
||||||
<div class="notice">{#if tab === "config"}API Key 等敏感字段显示为 <code>********</code>,保持不变即可保留原值。配置保存后需重启 Gateway 生效。{:else}Markdown 内容会在后续新会话和上下文构建中供 PicoBot 使用。{/if}</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>
|
||||||
</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>
|
||||||
</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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user