feat(webui): editable memory page with delete confirmation

This commit is contained in:
xiaoxixi 2026-07-26 22:09:01 +08:00
parent 652a837e88
commit f0ef14099d

View File

@ -7,12 +7,23 @@
let memories = $state([]); let memories = $state([]);
let loading = $state(true); let loading = $state(true);
let error = $state(""); let error = $state("");
let limit = $state(100);
let editingKey = $state(null);
let confirmingKey = $state(null);
let draftContent = $state("");
let draftImportance = $state(0);
let saving = $state(false);
let deleting = $state(false);
let actionError = $state("");
const knowledge = $derived(memories.filter((item) => item.category === "knowledge").length); const knowledge = $derived(memories.filter((item) => item.category === "knowledge").length);
const timeline = $derived(memories.length - knowledge);
const hasMore = $derived(memories.length >= limit);
async function load() { async function load() {
loading = true; loading = true;
error = ""; error = "";
const params = new URLSearchParams({ limit: "200" }); const params = new URLSearchParams({ limit: String(limit) });
if (query.trim()) params.set("query", query.trim()); if (query.trim()) params.set("query", query.trim());
if (category) params.set("category", category); if (category) params.set("category", category);
try { memories = (await api(`/api/memories?${params}`)).memories; } try { memories = (await api(`/api/memories?${params}`)).memories; }
@ -20,19 +31,146 @@
finally { loading = false; } finally { loading = false; }
} }
function applyFilters() {
limit = 100;
editingKey = null;
confirmingKey = null;
actionError = "";
load();
}
function loadMore() {
limit += 100;
load();
}
function startEdit(memory) {
confirmingKey = null;
actionError = "";
editingKey = memory.key;
draftContent = memory.content;
draftImportance = Number(memory.importance);
}
function cancelEdit() {
editingKey = null;
actionError = "";
}
async function saveEdit(key) {
saving = true;
actionError = "";
try {
await api(`/api/memories/${encodeURIComponent(key)}`, {
method: "PUT",
body: JSON.stringify({ content: draftContent, importance: Number(draftImportance) })
});
editingKey = null;
await load();
} catch (caught) { actionError = caught.message; }
finally { saving = false; }
}
function startConfirm(memory) {
editingKey = null;
actionError = "";
confirmingKey = memory.key;
}
function cancelConfirm() {
confirmingKey = null;
actionError = "";
}
async function removeMemory(key) {
deleting = true;
actionError = "";
try {
await api(`/api/memories/${encodeURIComponent(key)}`, { method: "DELETE" });
confirmingKey = null;
await load();
} catch (caught) { actionError = caught.message; }
finally { deleting = false; }
}
onMount(load); onMount(load);
</script> </script>
<section class="page active content-page"> <section class="page active content-page">
<form class="toolbar filters" onsubmit={(event) => { event.preventDefault(); load(); }}> <form class="toolbar filters" onsubmit={(event) => { event.preventDefault(); applyFilters(); }}>
<label class="search grow"><span></span><input bind:value={query} placeholder="搜索记忆内容或键" /></label> <label class="search grow"><span></span><input bind:value={query} placeholder="搜索记忆内容或键" /></label>
<select bind:value={category}><option value="">全部类型</option><option value="knowledge">Knowledge</option><option value="timeline">Timeline</option></select> <select bind:value={category}><option value="">全部</option><option value="knowledge">Knowledge</option><option value="timeline">Timeline</option></select>
<button class="secondary" type="submit">查询</button> <button class="secondary" type="submit">查询</button>
</form> </form>
<div class="metrics"><div class="metric"><b>{memories.length}</b><small>当前结果</small></div><div class="metric"><b>{knowledge}</b><small>Knowledge</small></div><div class="metric"><b>{memories.length - knowledge}</b><small>Timeline</small></div></div> <div class="metrics"><div class="metric"><b>{memories.length}</b><small>总数</small></div><div class="metric"><b>{knowledge}</b><small>Knowledge</small></div><div class="metric"><b>{timeline}</b><small>Timeline</small></div></div>
<div class="cards"> <div class="cards">
{#if loading}<div class="loading">加载中…</div> {#if loading}<div class="loading">加载中…</div>
{:else if error}<div class="empty-card error-text">{error}</div> {:else if error}<div class="empty-card error-text">{error}</div>
{:else}{#each memories as memory (memory.id)}<article class="card"><div class="card-row"><div><h3 class="memory-key">{memory.key}</h3><p class="memory-content">{memory.content}</p><div class="meta"><span>{formatTime(memory.updated_at)}</span>{#if memory.session_id}<span>{memory.session_id}</span>{/if}<span>重要度 {Number(memory.importance).toFixed(2)}</span></div></div><span class:ok={memory.category === "knowledge"} class="badge">{memory.category}</span></div></article>{:else}<div class="empty-card">没有匹配的记忆</div>{/each}{/if} {:else}
{#each memories as memory (memory.id)}
<article class="card">
{#if editingKey === memory.key}
<div class="card-row"><h3 class="memory-key">{memory.key}</h3><span class="badge" class:ok={memory.category === "knowledge"}>{memory.category}</span></div>
<label class="edit-field"><span>内容</span><textarea bind:value={draftContent} rows="4" disabled={saving}></textarea></label>
<label class="edit-field"><span>重要度 {Number(draftImportance).toFixed(2)}</span><input type="range" min="0" max="1" step="0.05" bind:value={draftImportance} disabled={saving} /></label>
{#if actionError}<p class="error-text action-error">{actionError}</p>{/if}
<div class="card-actions">
<button class="primary" onclick={() => saveEdit(memory.key)} disabled={saving}>保存</button>
<button class="secondary" onclick={cancelEdit} disabled={saving}>取消</button>
</div>
{:else}
<div class="card-row">
<div class="card-main">
<h3 class="memory-key">{memory.key}</h3>
<p class="memory-content">{memory.content}</p>
<div class="meta"><span>重要度 {Number(memory.importance).toFixed(2)}</span><span>更新 {formatTime(memory.updated_at)}</span>{#if memory.session_id}<span>{memory.session_id}</span>{/if}</div>
</div>
<div class="card-side">
<span class="badge" class:ok={memory.category === "knowledge"}>{memory.category}</span>
<div class="card-actions">
<button class="secondary" onclick={() => startEdit(memory)}>编辑</button>
<button class="secondary danger-outline" onclick={() => startConfirm(memory)}>删除</button>
</div>
</div>
</div>
{#if confirmingKey === memory.key}
<div class="confirm" class:confirm-danger={memory.category === "timeline"}>
{#if memory.category === "timeline"}
<p class="confirm-warning">Timeline 是压缩后的历史上下文,删除后模型将永久失去该时段长期记忆且无法自动重建;原始消息仍保留在聊天历史,但不再进入模型上下文</p>
{:else}
<p class="confirm-text">删除后不可恢复,影响后续召回</p>
{/if}
{#if actionError}<p class="error-text action-error">{actionError}</p>{/if}
<div class="card-actions">
<button class="danger-solid" onclick={() => removeMemory(memory.key)} disabled={deleting}>{memory.category === "timeline" ? "我了解,确认删除" : "确认删除"}</button>
<button class="secondary" onclick={cancelConfirm} disabled={deleting}>取消</button>
</div>
</div>
{/if}
{/if}
</article>
{:else}<div class="empty-card">没有匹配的记忆</div>{/each}
{#if hasMore}<div class="load-more"><button class="secondary" onclick={loadMore}>加载更多</button></div>{/if}
{/if}
</div> </div>
</section> </section>
<style>
.card-main { min-width: 0; flex: 1; }
.card-side { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; flex: 0 0 auto; }
.card-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.card > .card-actions { margin-top: 14px; }
.danger-outline { border-color: var(--danger-border); color: var(--danger); }
.danger-solid { border-radius: 9px; padding: 9px 14px; font-weight: 650; cursor: pointer; border: 1px solid var(--danger); background: var(--danger); color: #fff; }
.danger-solid:hover { filter: brightness(1.08); }
.edit-field { display: grid; gap: 6px; margin-top: 12px; }
.edit-field > span { color: var(--muted); font-size: 11px; }
.edit-field textarea { width: 100%; min-height: 90px; resize: vertical; border: 1px solid var(--line); border-radius: 8px; background: var(--panel-2); color: var(--text); padding: 10px; font: 13px/1.55 var(--font-ui); }
.edit-field input[type="range"] { width: 100%; accent-color: var(--accent); }
.action-error { margin: 12px 0 0; font-size: 12px; }
.confirm { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 12px; display: grid; gap: 10px; }
.confirm-text { margin: 0; color: var(--muted); font-size: 12px; }
.confirm-danger { border: 1px solid var(--danger-border); border-radius: 8px; background: var(--danger-soft); padding: 12px; }
.confirm-warning { margin: 0; color: var(--danger); font-size: 12px; line-height: 1.6; }
.load-more { display: flex; justify-content: center; padding: 6px 0; }
</style>