751 lines
26 KiB
TypeScript
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">用户画像 & 历史</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>
|
|
);
|
|
}
|