Files
deerflow-code/frontend-web/src/components/workspace/settings/memory-settings-page.tsx
T
2026-06-02 11:01:07 +08:00

751 lines
26 KiB
TypeScript

"use client";
import { PenLineIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useI18n } from "@/core/i18n/hooks";
import { searchMemory } from "@/core/memory/api";
import { useAgents } from "@/core/agents/hooks";
import {
useAddMemoryEntry,
useDeleteMemoryEntry,
useMemory,
useMemoryConfig,
usePatchMemoryConfig,
useReplaceMemoryEntry,
} from "@/core/memory/hooks";
import type {
MemoryFact,
MemorySearchMode,
MemoryTarget,
MemoryV2Config,
} from "@/core/memory/types";
import { desensitize } from "@/lib/desensitize";
import { SettingsSection } from "./settings-section";
/** 一条条目的编辑态:新增时 oldText 为 null,编辑时为原文。 */
type EntryEditState = {
target: MemoryTarget;
oldText: string | null;
content: string;
};
export function MemorySettingsPage() {
const { t } = useI18n();
const m = t.settings.memory;
// 先读配置拿到 version,再决定渲染哪套 UI
const { configResponse } = useMemoryConfig();
const memoryVersion = configResponse?.version ?? "v2";
// 工作笔记按专家隔离 —— selectedAgent 控制查看/编辑哪个专家的 MEMORY.md。
// 用户画像 (USER.md) 是 per-user 的,不受 selectedAgent 影响。
// 专家列表来自 agents 注册表 (非内建的才是用户的专家);default 是每个
// 用户都有的内置助手,始终作为下拉的第一个选项。
const { agents: allAgents } = useAgents();
const experts = useMemo(
() => allAgents.filter((a) => !a.builtin),
[allAgents],
);
const [selectedAgent, setSelectedAgent] = useState("default");
const { memory, isLoading, error } = useMemory(selectedAgent);
const addEntry = useAddMemoryEntry(selectedAgent);
const replaceEntry = useReplaceMemoryEntry(selectedAgent);
const deleteEntry = useDeleteMemoryEntry(selectedAgent);
const [editState, setEditState] = useState<EntryEditState | null>(null);
const [entryToDelete, setEntryToDelete] = useState<{
target: MemoryTarget;
content: string;
} | null>(null);
function openAddDialog(target: MemoryTarget) {
setEditState({ target, oldText: null, content: "" });
}
function openEditDialog(target: MemoryTarget, content: string) {
setEditState({ target, oldText: content, content });
}
async function handleSaveEntry() {
if (!editState) return;
const content = editState.content.trim();
if (!content) {
toast.error(m.entryValidationContent);
return;
}
try {
if (editState.oldText === null) {
await addEntry.mutateAsync({ target: editState.target, content });
} else {
await replaceEntry.mutateAsync({
target: editState.target,
oldText: editState.oldText,
content,
});
}
toast.success(m.entrySaveSuccess);
setEditState(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
async function handleDeleteEntry() {
if (!entryToDelete) return;
try {
await deleteEntry.mutateAsync({
target: entryToDelete.target,
oldText: entryToDelete.content,
});
toast.success(m.deleteEntrySuccess);
setEntryToDelete(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
const isEntryPending = addEntry.isPending || replaceEntry.isPending;
function renderEntrySection(
target: MemoryTarget,
sectionTitle: string,
sectionDesc: string,
entries: string[],
usage: string,
headerExtra?: React.ReactNode,
) {
return (
<div className="min-w-0 space-y-3 rounded-lg border p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<h3 className="text-base font-medium">{sectionTitle}</h3>
<p className="text-muted-foreground text-sm">{sectionDesc}</p>
</div>
<div className="flex items-center gap-2">
{headerExtra}
<span className="text-muted-foreground text-xs">
{m.usageLabel}: {usage}
</span>
<Button
variant="outline"
size="sm"
onClick={() => openAddDialog(target)}
>
<PlusIcon className="mr-1 h-4 w-4" />
{m.addEntry}
</Button>
</div>
</div>
{entries.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-3 text-sm">
{m.entryEmpty}
</div>
) : (
<div className="space-y-2">
{entries.map((entry, index) => (
<div
key={`${target}-${index}-${entry.slice(0, 16)}`}
className="flex items-start justify-between gap-3 rounded-md border p-3"
>
<p className="min-w-0 flex-1 whitespace-pre-wrap text-sm [overflow-wrap:anywhere]">
{desensitize(entry)}
</p>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(target, entry)}
disabled={deleteEntry.isPending}
title={t.common.edit}
aria-label={t.common.edit}
>
<PenLineIcon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => setEntryToDelete({ target, content: entry })}
disabled={deleteEntry.isPending}
title={t.common.delete}
aria-label={t.common.delete}
>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
);
}
return (
<>
<SettingsSection title={m.title} description={m.description}>
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div className="text-destructive text-sm">
{m.loadError}: {error.message}
</div>
) : !memory ? (
<div className="text-muted-foreground text-sm">{m.entryEmpty}</div>
) : memoryVersion === "v1" ? (
// V1 模式:只读展示 facts 卡片列表,支持按 agent 切换
<div className="space-y-4">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
{m.workNotesAgentLabel}:
</span>
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
<SelectTrigger className="h-8 w-44" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="default">
{m.workNotesDefaultAgent}
</SelectItem>
{experts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{desensitize(a.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<MemoryV1View memory={memory} />
</div>
) : (
// V2 模式:USER.md / MEMORY.md 条目编辑
<div className="space-y-4">
{renderEntrySection(
"user",
m.userProfileTitle,
m.userProfileDesc,
memory.user_entries,
memory.user_usage,
)}
{renderEntrySection(
"memory",
m.workNotesTitle,
m.workNotesDesc,
memory.memory_entries,
memory.memory_usage,
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-xs">
{m.workNotesAgentLabel}:
</span>
<Select value={selectedAgent} onValueChange={setSelectedAgent}>
<SelectTrigger className="h-8 w-40" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{m.workNotesDefaultAgent}
</SelectItem>
{experts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{desensitize(a.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>,
)}
</div>
)}
</SettingsSection>
{memoryVersion !== "v1" && (
<>
<div className="mt-8">
<MemoryConfigPanel />
</div>
<div className="mt-8">
<MemorySearchPanel />
</div>
</>
)}
{/* 新增 / 编辑条目对话框 */}
<Dialog
open={editState !== null}
onOpenChange={(open) => {
if (!open) setEditState(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{editState?.oldText === null ? m.addEntryTitle : m.editEntryTitle}
</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<label className="text-sm font-medium">{m.entryContentLabel}</label>
<Textarea
value={editState?.content ?? ""}
onChange={(event) =>
setEditState((current) =>
current ? { ...current, content: event.target.value } : current,
)
}
placeholder={m.entryContentPlaceholder}
rows={4}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setEditState(null)}
disabled={isEntryPending}
>
{t.common.cancel}
</Button>
<Button
onClick={() => void handleSaveEntry()}
disabled={isEntryPending}
>
{isEntryPending ? t.common.loading : m.save}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog
open={entryToDelete !== null}
onOpenChange={(open) => {
if (!open) setEntryToDelete(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{m.deleteEntryTitle}</DialogTitle>
<DialogDescription>{m.deleteEntryDesc}</DialogDescription>
</DialogHeader>
{entryToDelete ? (
<div className="bg-muted rounded-md border p-3 text-sm">
<div className="text-muted-foreground mb-1 font-medium">
{m.entryToDeleteLabel}
</div>
<p className="break-words">{desensitize(entryToDelete.content)}</p>
</div>
) : null}
<DialogFooter>
<Button
variant="outline"
onClick={() => setEntryToDelete(null)}
disabled={deleteEntry.isPending}
>
{t.common.cancel}
</Button>
<Button
variant="destructive"
onClick={() => void handleDeleteEntry()}
disabled={deleteEntry.isPending}
>
{deleteEntry.isPending ? t.common.loading : t.common.delete}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
/** 记忆设置面板:仅暴露 Hindsight 这一个可选开关,其余默认开启不展示。 */
function MemoryConfigPanel() {
const { t } = useI18n();
const m = t.settings.memory;
const { configResponse, isLoading } = useMemoryConfig();
const patchConfig = usePatchMemoryConfig();
async function patch(overrides: Record<string, unknown>) {
try {
await patchConfig.mutateAsync(overrides);
toast.success(m.settingsSaved);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
}
}
const config: MemoryV2Config | null = configResponse?.config ?? null;
const writable = new Set(configResponse?.writable_fields ?? []);
const hindsightOn = config?.injection.include_hindsight ?? false;
return (
<SettingsSection title={m.settingsTitle} description={m.settingsDesc}>
{isLoading || !config ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : (
<div className="space-y-4 rounded-lg border p-4">
{/* 只暴露 Hindsight 开关,记忆总开关和本地记忆默认开启不展示 */}
<ToggleRow
label={m.includeHindsight}
description={m.includeHindsightDesc}
checked={hindsightOn}
disabled={
!writable.has("injection.include_hindsight") || patchConfig.isPending
}
onChange={(v) => void patch({ injection: { include_hindsight: v } })}
/>
{/* Hindsight 细节设置:仅在 Hindsight 开启时展示 */}
{hindsightOn && (
<>
<div className="flex flex-wrap items-center justify-between gap-3 border-t pt-4">
<div className="min-w-0">
<label className="text-sm font-medium">{m.recallBudget}</label>
<p className="text-muted-foreground text-sm">{m.recallBudgetDesc}</p>
</div>
<Select
value={config.hindsight.recall_budget}
disabled={!writable.has("hindsight.recall_budget") || patchConfig.isPending}
onValueChange={(v) => void patch({ hindsight: { recall_budget: v } })}
>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">{m.recallBudgetLow}</SelectItem>
<SelectItem value="mid">{m.recallBudgetMid}</SelectItem>
<SelectItem value="high">{m.recallBudgetHigh}</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</div>
)}
</SettingsSection>
);
}
const CTX_LABELS: Record<string, string> = {
workContext: "工作背景",
personalContext: "个人信息",
topOfMind: "当前重点",
};
const HISTORY_LABELS: Record<string, string> = {
recentMonths: "近期",
earlierContext: "早期背景",
longTermBackground: "长期背景",
};
const CATEGORY_COLORS: Record<string, string> = {
preference: "bg-blue-100 text-blue-700",
knowledge: "bg-green-100 text-green-700",
context: "bg-yellow-100 text-yellow-700",
behavior: "bg-purple-100 text-purple-700",
goal: "bg-orange-100 text-orange-700",
correction: "bg-red-100 text-red-700",
};
/** V1 记忆只读视图:展示 memory.json 的全部字段,Tab 布局。 */
function MemoryV1View({
memory,
}: {
memory: {
facts: MemoryFact[];
user_context: Record<string, string>;
history: Record<string, string>;
memory_usage: string;
};
}) {
const ctxEntries = Object.entries(memory.user_context ?? {}).filter(([, v]) => v);
const historyEntries = Object.entries(memory.history ?? {}).filter(([, v]) => v);
return (
<div className="space-y-3">
{/* 说明横幅 */}
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground dark:border-[#213D5E] dark:bg-[#022248]">
V1 模式:记忆由系统在对话结束后自动提取,无需手动管理。
</div>
<Tabs defaultValue="facts">
<TabsList>
<TabsTrigger value="facts">
事实记录
{memory.facts.length > 0 && (
<span className="ml-1.5 rounded-full bg-primary/10 px-1.5 py-0.5 text-xs text-primary">
{memory.facts.length}
</span>
)}
</TabsTrigger>
<TabsTrigger value="profile">用户画像 &amp; 历史</TabsTrigger>
</TabsList>
{/* 事实记录 Tab */}
<TabsContent value="facts" className="mt-3">
<div className="rounded-lg border p-4 space-y-3 dark:border-[#213D5E] dark:bg-[#022248]">
<div className="flex items-center justify-between">
<h3 className="text-base font-medium">事实记录</h3>
<span className="text-xs text-muted-foreground">{memory.memory_usage}</span>
</div>
{memory.facts.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground ">
暂无事实记录,对话后系统会自动提取。
</div>
) : (
<div className="space-y-2">
{memory.facts.map((fact) => (
<div key={fact.id} className="rounded-md border p-3 space-y-1.5">
<div className="flex items-center gap-2">
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${
CATEGORY_COLORS[fact.category] ?? "bg-gray-100 text-gray-700"
}`}
>
{fact.category}
</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${Math.round(fact.confidence * 100)}%` }}
/>
</div>
<span className="text-xs text-muted-foreground shrink-0">
{Math.round(fact.confidence * 100)}%
</span>
</div>
<p className="text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(fact.content)}</p>
{fact.sourceError && (
<p className="rounded bg-red-50 px-2 py-1 text-xs text-red-600 dark:bg-[#311629] dark:text-[#EE212C]">
避免:{desensitize(fact.sourceError)}
</p>
)}
</div>
))}
</div>
)}
</div>
</TabsContent>
{/* 用户画像 & 历史摘要 Tab */}
<TabsContent value="profile" className="mt-3 space-y-4">
{/* 用户画像 (userContext) */}
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
<h3 className="text-base font-medium">用户画像</h3>
{ctxEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无画像信息,对话后系统会自动归纳。</p>
) : (
<dl className="space-y-2">
{ctxEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{CTX_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
</div>
))}
</dl>
)}
</div>
{/* 历史摘要 (history) */}
<div className="rounded-lg border p-4 space-y-2 dark:border-[#213D5E] dark:bg-[#022248]">
<h3 className="text-base font-medium">历史摘要</h3>
{historyEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">暂无历史摘要。</p>
) : (
<dl className="space-y-2">
{historyEntries.map(([key, value]) => (
<div key={key} className="text-sm">
<dt className="font-medium text-muted-foreground mb-0.5">
{HISTORY_LABELS[key] ?? key}
</dt>
<dd className="whitespace-pre-wrap [overflow-wrap:anywhere]">{desensitize(value)}</dd>
</div>
))}
</dl>
)}
</div>
</TabsContent>
</Tabs>
</div>
);
}
/** 一行开关控件。 */
function ToggleRow({
label,
description,
checked,
disabled,
onChange,
}: {
label: string;
description: string;
checked: boolean;
disabled: boolean;
onChange: (value: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium">{label}</div>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
<Switch checked={checked} disabled={disabled} onCheckedChange={onChange} />
</div>
);
}
/** Hindsight 记忆检索面板。 */
function MemorySearchPanel() {
const { t } = useI18n();
const m = t.settings.memory;
// 检索范围:default 是内置助手,其余是用户的自定义专家 —— 对应
// Hindsight 的 work bank deerflow-work-{user}-{agent}。
const { agents: allAgents } = useAgents();
const experts = useMemo(
() => allAgents.filter((a) => !a.builtin),
[allAgents],
);
const [searchAgent, setSearchAgent] = useState("default");
const [query, setQuery] = useState("");
const [mode, setMode] = useState<MemorySearchMode>("recall");
const [isSearching, setIsSearching] = useState(false);
const [results, setResults] = useState<string[] | null>(null);
const [reflectText, setReflectText] = useState("");
const [unavailable, setUnavailable] = useState(false);
async function handleSearch() {
const q = query.trim();
if (!q) return;
setIsSearching(true);
try {
const result = await searchMemory(q, mode, searchAgent);
setUnavailable(!result.available);
setResults(result.results);
setReflectText(result.text);
} catch (err) {
toast.error(err instanceof Error ? err.message : String(err));
} finally {
setIsSearching(false);
}
}
return (
<SettingsSection title={m.searchTitle} description={m.searchDesc}>
<div className="space-y-3 rounded-lg border p-4">
<div className="flex flex-wrap items-center gap-2">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch();
}}
placeholder={m.searchPlaceholder}
className="min-w-0 flex-1"
/>
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-sm">
{m.workNotesAgentLabel}
</span>
<Select value={searchAgent} onValueChange={setSearchAgent}>
<SelectTrigger className="w-40" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">
{m.workNotesDefaultAgent}
</SelectItem>
{experts.map((a) => (
<SelectItem key={a.id} value={a.id}>
{desensitize(a.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ToggleGroup
type="single"
value={mode}
onValueChange={(v) => {
if (v) setMode(v as MemorySearchMode);
}}
variant="outline"
>
<ToggleGroupItem value="recall">{m.searchModeRecall}</ToggleGroupItem>
<ToggleGroupItem value="reflect">{m.searchModeReflect}</ToggleGroupItem>
</ToggleGroup>
<Button
onClick={() => void handleSearch()}
disabled={isSearching || !query.trim()}
>
<SearchIcon className="mr-1 h-4 w-4" />
{isSearching ? t.common.loading : m.searchButton}
</Button>
</div>
{unavailable ? (
<div className="text-muted-foreground rounded-md border border-dashed p-3 text-sm">
{m.searchUnavailable}
</div>
) : results !== null ? (
mode === "reflect" ? (
<div className="rounded-md border p-3 text-sm whitespace-pre-wrap [overflow-wrap:anywhere]">
{reflectText.trim() ? desensitize(reflectText.trim()) : m.searchNoResults}
</div>
) : results.length === 0 ? (
<div className="text-muted-foreground rounded-md border border-dashed p-3 text-sm">
{m.searchNoResults}
</div>
) : (
<div className="space-y-2">
<div className="text-muted-foreground text-sm">
{m.searchResultsTitle}
</div>
{results.map((item, index) => (
<div
key={`${index}-${item.slice(0, 16)}`}
className="rounded-md border p-3 text-sm whitespace-pre-wrap [overflow-wrap:anywhere]"
>
{desensitize(item)}
</div>
))}
</div>
)
) : (
<div className="text-muted-foreground text-sm">{m.searchEmpty}</div>
)}
</div>
</SettingsSection>
);
}