From a1010df389f33059a66a49c734bfb268745bdadc Mon Sep 17 00:00:00 2001 From: Bosheng0422 Date: Mon, 7 Sep 2026 14:43:28 +0800 Subject: [PATCH] feat(memory): add atom create and correction UI --- CHANGELOG.md | 8 + dashboard/src/api/modules/memoryDashboard.ts | 44 ++- dashboard/src/locales/en.json | 39 +++ dashboard/src/locales/zh.json | 39 +++ .../src/pages/Agent/Memory/AtomsList.test.tsx | 50 +++ .../src/pages/Agent/Memory/AtomsList.tsx | 292 +++++++++++------- .../src/pages/Agent/Memory/JournalList.tsx | 6 + .../pages/Agent/Memory/MemoryTree.test.tsx | 94 ++++++ .../src/pages/Agent/Memory/MemoryTree.tsx | 163 ++++++++-- .../Agent/Memory/shared/LineageStrip.tsx | 116 +++++-- .../pages/Agent/Memory/shared/createAtom.tsx | 217 +++++++++++++ .../pages/Agent/Memory/shared/editAtom.tsx | 64 ++++ src/octop/api/routers/memory.py | 78 +++++ tests/integration/test_memory_api.py | 58 ++++ 14 files changed, 1109 insertions(+), 159 deletions(-) create mode 100644 dashboard/src/pages/Agent/Memory/MemoryTree.test.tsx create mode 100644 dashboard/src/pages/Agent/Memory/shared/createAtom.tsx create mode 100644 dashboard/src/pages/Agent/Memory/shared/editAtom.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a8dcb2e..87ab3dee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ ## [Unreleased] +### 新增 + +- 记忆树支持手动新建主题/记忆,以及编辑已有记忆(replace/supersede,保留变更历史)。 + +### 修复 + +- 记忆树详情接入 `user_edit` lineage,人工修正后显示“人工修正的记忆”、修正前内容和原始来源上下文。 + ## [0.9.32] - 2026-09-06 ### 新增 diff --git a/dashboard/src/api/modules/memoryDashboard.ts b/dashboard/src/api/modules/memoryDashboard.ts index eba79d8c..ef93ddad 100644 --- a/dashboard/src/api/modules/memoryDashboard.ts +++ b/dashboard/src/api/modules/memoryDashboard.ts @@ -175,10 +175,11 @@ export interface JournalItem { target_atom_id?: string | null; target_candidate_id?: string | null; note?: string | null; + before?: Record | null; /** Short target memory/topic text enriched by the backend for specific action display. */ target_summary?: string | null; /** Present on ``extract_run`` rows: structured stats for this extraction pass. */ - after?: ExtractRunStats | null; + after?: ExtractRunStats | Record | null; } export interface ListJournalResponse { @@ -237,6 +238,19 @@ export interface RejectCandidateResponse { status: "rejected"; } +export interface CreateAtomResponse { + atom: AtomItem; + entity: EntityItem; + created_entity: boolean; + status: "created"; +} + +export interface ReplaceAtomResponse { + old_atom_id: string; + atom: AtomItem; + status: "replaced" | "unchanged"; +} + export interface LastExtractRun { timestamp?: string; session_id?: string | null; @@ -450,6 +464,34 @@ export const memoryDashboardApi = { { method: "POST", body: JSON.stringify(body ?? {}) }, ), + createAtom: ( + aid: string, + body: { + assertion: string; + entity_id?: string; + entity_name?: string; + entity_type?: string; + kind?: AtomKind; + importance?: Importance; + confidence?: Confidence; + reason?: string; + }, + ) => + request(`${base(aid)}/atoms`, { + method: "POST", + body: JSON.stringify(body), + }), + + replaceAtom: ( + aid: string, + atomId: string, + body: { assertion: string; reason?: string }, + ) => + request( + `${base(aid)}/atoms/${encodeURIComponent(atomId)}:replace`, + { method: "POST", body: JSON.stringify(body) }, + ), + // terminal aggregator (5 cards) terminalAboutMe: (aid: string, limit = 5) => request( diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 3aafe548..c1bd14b1 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -3493,6 +3493,45 @@ "deprecate": "Deprecate this memory", "actions": "Actions" }, + "create": { + "title": "New memory", + "titleInTopic": "Add a memory under this topic", + "ok": "Save", + "success": "Memory added", + "failed": "Could not add memory: {{message}}", + "topic": "Topic", + "existingTopic": "Existing topic", + "newTopic": "New topic", + "pickTopic": "Choose a topic", + "topicRequired": "Please choose a topic", + "topicName": "Topic name", + "topicNameRequired": "Please enter a topic name", + "topicNamePlaceholder": "e.g. drink preferences", + "topicType": "Topic type", + "kind": "Memory type", + "assertion": "Memory", + "assertionRequired": "Please enter the memory", + "assertionPlaceholder": "e.g. prefers americano", + "addToTopicTip": "Add a memory under this topic" + }, + "edit": { + "title": "Edit this memory", + "action": "Edit this memory", + "tooltip": "Edit this memory", + "description": "After saving, Octop will use the new text. The previous version stays in the change history.", + "ok": "Save", + "success": "Memory updated", + "failed": "Could not update memory: {{message}}", + "empty": "Memory text cannot be empty" + }, + "lineage": { + "manualCorrection": "Manually corrected memory", + "beforeCorrection": "Before correction: {{assertion}}", + "originalContext": "Original source context:", + "manualEntry": "Manually added memory:", + "conversationSource": "Source conversation excerpt:", + "noOriginalContext": "No original source is available" + }, "deprecate": { "title": "Deprecate this memory?", "description": "Octop will no longer use this memory once it is deprecated. If it was recorded incorrectly, describe the correct content as the deprecation reason.", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 6f0cebf9..681f26c7 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -3484,6 +3484,45 @@ "deprecate": "弃用这条记忆", "actions": "操作" }, + "create": { + "title": "新建记忆", + "titleInTopic": "在此主题下添加记忆", + "ok": "保存", + "success": "记忆已添加", + "failed": "添加失败:{{message}}", + "topic": "主题", + "existingTopic": "已有主题", + "newTopic": "新建主题", + "pickTopic": "选择主题", + "topicRequired": "请选择主题", + "topicName": "主题名称", + "topicNameRequired": "请填写主题名称", + "topicNamePlaceholder": "例如:饮品偏好", + "topicType": "主题类型", + "kind": "记忆类型", + "assertion": "记忆内容", + "assertionRequired": "请填写记忆内容", + "assertionPlaceholder": "例如:喜欢喝美式咖啡", + "addToTopicTip": "在此主题下添加记忆" + }, + "edit": { + "title": "编辑这条记忆", + "action": "编辑这条记忆", + "tooltip": "编辑这条记忆", + "description": "保存后 Octop 会使用新内容;原来的记录会留在变更历史里。", + "ok": "保存", + "success": "记忆已更新", + "failed": "更新失败:{{message}}", + "empty": "记忆内容不能为空" + }, + "lineage": { + "manualCorrection": "人工修正的记忆", + "beforeCorrection": "修正前:{{assertion}}", + "originalContext": "原始来源上下文:", + "manualEntry": "人工添加的记忆:", + "conversationSource": "来源对话片段:", + "noOriginalContext": "没有可展示的原始来源" + }, "deprecate": { "title": "弃用这条记忆?", "description": "弃用后 Octop 将不再使用这条记忆。如果是记录有误,建议先描述正确内容作为弃用原因。", diff --git a/dashboard/src/pages/Agent/Memory/AtomsList.test.tsx b/dashboard/src/pages/Agent/Memory/AtomsList.test.tsx index 1198cdc7..24106025 100644 --- a/dashboard/src/pages/Agent/Memory/AtomsList.test.tsx +++ b/dashboard/src/pages/Agent/Memory/AtomsList.test.tsx @@ -18,6 +18,12 @@ import { makeAtom, listAtomsResp } from "../../../test/memoryFixtures"; vi.mock("../../../api/modules/memoryDashboard", () => ({ memoryDashboardApi: { listAtoms: vi.fn(), + listEntities: vi + .fn() + .mockResolvedValue({ items: [], total: 0, has_more: false }), + listJournal: vi + .fn() + .mockResolvedValue({ items: [], total: 0, has_more: false }), }, isAtomDeprecated: (a: { deprecated_at?: string | null }) => a.deprecated_at != null, @@ -128,4 +134,48 @@ describe("", () => { ).toBeGreaterThanOrEqual(1); }); }); + + it("shows the audit record instead of presenting old evidence as the corrected assertion", async () => { + api.listAtoms.mockResolvedValue( + listAtomsResp([ + makeAtom({ + id: "atom-corrected", + assertion: "用户喜欢喝拿铁。", + verbatim_quote: "我每天早上都喝美式咖啡", + }), + ]), + ); + api.listJournal.mockResolvedValue({ + items: [ + { + id: "journal-edit", + timestamp: "2026-09-07T08:00:00Z", + action: "user_edit", + actor: "user", + target_atom_id: "atom-corrected", + before: { assertion: "用户喜欢喝美式咖啡。", atom_id: "atom-old" }, + after: { assertion: "用户喜欢喝拿铁。", atom_id: "atom-corrected" }, + }, + ], + total: 1, + has_more: false, + }); + + const user = userEvent.setup(); + render(); + await user.click(await screen.findByText("用户喜欢喝拿铁。")); + + await waitFor(() => { + expect(api.listJournal).toHaveBeenCalledWith("ZYWZTD", { + action: "user_edit", + target_atom_id: "atom-corrected", + limit: 1, + }); + }); + expect(await screen.findByText(/人工修正的记忆/)).toBeInTheDocument(); + expect( + screen.getByText("修正前:用户喜欢喝美式咖啡。"), + ).toBeInTheDocument(); + expect(screen.getByText(/原始来源上下文:/)).toBeInTheDocument(); + }); }); diff --git a/dashboard/src/pages/Agent/Memory/AtomsList.tsx b/dashboard/src/pages/Agent/Memory/AtomsList.tsx index 42bb80b2..bf65ba8d 100644 --- a/dashboard/src/pages/Agent/Memory/AtomsList.tsx +++ b/dashboard/src/pages/Agent/Memory/AtomsList.tsx @@ -15,7 +15,7 @@ import { Tooltip, Typography, } from "antd"; -import { Trash2 } from "lucide-react"; +import { Pencil, Plus, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { @@ -23,6 +23,7 @@ import { isAtomDeprecated, type AtomItem, type AtomKind, + type EntityItem, type Importance, type ListAtomsBody, } from "../../../api/modules/memoryDashboard"; @@ -30,6 +31,8 @@ import MemoryLayerView from "./shared/MemoryLayerView"; import LineageStrip from "./shared/LineageStrip"; import MemoryPipelineEmpty from "./shared/MemoryPipelineEmpty"; import { confirmDeprecateAtom } from "./shared/deprecateAtom"; +import { confirmEditAtom } from "./shared/editAtom"; +import CreateAtomModal from "./shared/createAtom"; const PAGE_SIZE = 20; @@ -64,6 +67,8 @@ export default function AtomsList({ agentId }: Props) { const [importance, setImportance] = useState(""); const [selected, setSelected] = useState(null); const [hoveredId, setHoveredId] = useState(null); + const [entities, setEntities] = useState([]); + const [createOpen, setCreateOpen] = useState(false); const load = useCallback(async () => { setLoading(true); @@ -85,6 +90,14 @@ export default function AtomsList({ agentId }: Props) { useEffect(() => { if (!agentId) return; void load(); + void memoryDashboardApi + .listEntities(agentId, { + limit: 200, + order_by: "atom_count", + order: "desc", + }) + .then((r) => setEntities(r.items)) + .catch(() => setEntities([])); }, [agentId, load]); const handleDeprecate = (atom: AtomItem) => { @@ -98,6 +111,17 @@ export default function AtomsList({ agentId }: Props) { }); }; + const handleEdit = (atom: AtomItem) => { + confirmEditAtom({ + agentId, + atom, + onSuccess: (next) => { + setSelected(next); + void load(); + }, + }); + }; + const toolbar = ( <> {t("memory.list.kind", "类型")}: @@ -122,6 +146,13 @@ export default function AtomsList({ agentId }: Props) { }} options={IMPORTANCE_OPTIONS} /> + ); @@ -130,128 +161,175 @@ export default function AtomsList({ agentId }: Props) { const noFilterActive = !kind && !importance; return ( - - toolbar={toolbar} - items={items} - total={total} - page={page} - pageSize={PAGE_SIZE} - onPageChange={setPage} - loading={loading} - emptyContent={ - noFilterActive ? : undefined - } - keyOf={(a) => a.id} - selected={selected} - onItemClick={setSelected} - onCloseDrawer={() => setSelected(null)} - drawerTitle={t("memory.atomDetail", "记忆详情")} - drawerWidth={560} - renderItem={(a) => ( -
setHoveredId(a.id)} - onMouseLeave={() => setHoveredId(null)} - > - - - {isAtomDeprecated(a) ? 已忘记 : null} - -
{a.assertion}
-
- {formatRelativeTime(a.created_at)} - {a.kind ? ` · ${kindLabel(a.kind)}` : ""} -
- {!isAtomDeprecated(a) && hoveredId === a.id ? ( - + <> + + toolbar={toolbar} + items={items} + total={total} + page={page} + pageSize={PAGE_SIZE} + onPageChange={setPage} + loading={loading} + emptyContent={ + noFilterActive ? : undefined + } + keyOf={(a) => a.id} + selected={selected} + onItemClick={setSelected} + onCloseDrawer={() => setSelected(null)} + drawerTitle={t("memory.atomDetail", "记忆详情")} + drawerWidth={560} + renderItem={(a) => ( +
setHoveredId(a.id)} + onMouseLeave={() => setHoveredId(null)} + > + + + {isAtomDeprecated(a) ? 已忘记 : null} + +
{a.assertion}
+
+ {formatRelativeTime(a.created_at)} + {a.kind ? ` · ${kindLabel(a.kind)}` : ""} +
+ {!isAtomDeprecated(a) && hoveredId === a.id ? ( { - e.stopPropagation(); - handleDeprecate(a); - }} style={{ position: "absolute", right: 0, top: "50%", transform: "translateY(-50%)", - color: "#ff4d4f", - cursor: "pointer", - fontSize: 15, - padding: "2px 4px", - borderRadius: 4, - lineHeight: 1, + display: "inline-flex", + gap: 4, }} > - + + { + e.stopPropagation(); + handleEdit(a); + }} + style={{ + color: "#1677ff", + cursor: "pointer", + padding: "2px 4px", + borderRadius: 4, + lineHeight: 1, + }} + > + + + + + { + e.stopPropagation(); + handleDeprecate(a); + }} + style={{ + color: "#ff4d4f", + cursor: "pointer", + padding: "2px 4px", + borderRadius: 4, + lineHeight: 1, + }} + > + + + - - ) : null} -
- )} - renderDrawer={(atom) => ( -
- - {atom.kind ? {kindLabel(atom.kind)} : null} - - - {isAtomDeprecated(atom) ? "已忘记" : "在用"} - - + ) : null} +
+ )} + renderDrawer={(atom) => ( +
+ + {atom.kind ? {kindLabel(atom.kind)} : null} + + + {isAtomDeprecated(atom) ? "已忘记" : "在用"} + + - {/* Source, shown only as user-facing lineage after view consolidation */} - + - 记忆内容 - {atom.assertion} + 记忆内容 + {atom.assertion} - 原话依据 - - {atom.verbatim_quote} - + 原话依据 + + {atom.verbatim_quote} + - 可信度 - + 可信度 + - {(atom.search_terms ?? []).length > 0 ? ( - <> - - 关联关键词 - - - {(atom.search_terms ?? []).map((s) => ( - {s} - ))} - - - ) : null} + {(atom.search_terms ?? []).length > 0 ? ( + <> + + 关联关键词 + + + {(atom.search_terms ?? []).map((s) => ( + {s} + ))} + + + ) : null} - - 首次记录于 {formatRelativeTime(atom.created_at)} - {atom.occurred_at - ? ` · 发生于 ${formatRelativeTime(atom.occurred_at)}` - : ""} - + + 首次记录于 {formatRelativeTime(atom.created_at)} + {atom.occurred_at + ? ` · 发生于 ${formatRelativeTime(atom.occurred_at)}` + : ""} + - {/* Actions, shown only for active atoms */} - {!isAtomDeprecated(atom) ? ( - <> - - {t("memory.tree.actions", "操作")} - - - - ) : null} -
- )} - /> + {!isAtomDeprecated(atom) ? ( + <> + + {t("memory.tree.actions", "操作")} + + + + + + + ) : null} +
+ )} + /> + setCreateOpen(false)} + onSuccess={() => { + void load(); + void memoryDashboardApi + .listEntities(agentId, { + limit: 200, + order_by: "atom_count", + order: "desc", + }) + .then((r) => setEntities(r.items)) + .catch(() => undefined); + }} + /> + ); } diff --git a/dashboard/src/pages/Agent/Memory/JournalList.tsx b/dashboard/src/pages/Agent/Memory/JournalList.tsx index d01851a7..80b53518 100644 --- a/dashboard/src/pages/Agent/Memory/JournalList.tsx +++ b/dashboard/src/pages/Agent/Memory/JournalList.tsx @@ -31,6 +31,8 @@ const ACTION_OPTIONS = [ { value: "promote", label: "采纳" }, { value: "reject", label: "忽略" }, { value: "deprecate", label: "弃用" }, + { value: "create", label: "新建" }, + { value: "user_edit", label: "编辑" }, { value: "page_regen", label: "刷新主题" }, ]; @@ -44,6 +46,7 @@ const ACTION_COLOR: Record = { page_regen: "geekblue", create: "green", update: "blue", + user_edit: "blue", merge: "gold", }; @@ -57,6 +60,7 @@ const ACTION_HEX: Record = { page_regen: "#2f54eb", create: "#52c41a", update: "#1677ff", + user_edit: "#1677ff", merge: "#faad14", }; @@ -601,6 +605,7 @@ function singleEventStory(item: JournalItem): { icon: string } { case "create": return { icon: "🆕" }; case "update": + case "user_edit": return { icon: "✏️" }; case "merge": return { icon: "🔗" }; @@ -637,6 +642,7 @@ function actionLabel(action: string): string { case "create": return "创建"; case "update": + case "user_edit": return "更新"; case "merge": return "合并"; diff --git a/dashboard/src/pages/Agent/Memory/MemoryTree.test.tsx b/dashboard/src/pages/Agent/Memory/MemoryTree.test.tsx new file mode 100644 index 00000000..4eb921ae --- /dev/null +++ b/dashboard/src/pages/Agent/Memory/MemoryTree.test.tsx @@ -0,0 +1,94 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + listAtomsResp, + listEntitiesResp, + makeAtom, + makeEntity, +} from "../../../test/memoryFixtures"; + +vi.mock("../../../api/modules/memoryDashboard", () => ({ + memoryDashboardApi: { + listEntities: vi.fn(), + listAtoms: vi.fn(), + listJournal: vi.fn(), + }, + isAtomDeprecated: (atom: { deprecated_at?: string | null }) => + atom.deprecated_at != null, +})); + +import { memoryDashboardApi } from "../../../api/modules/memoryDashboard"; +import MemoryTree from "./MemoryTree"; + +const api = vi.mocked(memoryDashboardApi, true); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("", () => { + it("shows correction audit in the tree detail drawer", async () => { + api.listEntities.mockResolvedValue( + listEntitiesResp([ + makeEntity({ + id: "entity-project", + canonical_name: "拼豆工作台", + atom_count: 1, + }), + ]), + ); + api.listAtoms.mockResolvedValue( + listAtomsResp([ + makeAtom({ + id: "atom-corrected", + entity_id: "entity-project", + candidate_id: "", + assertion: "企鹅图像已经调整。", + verbatim_quote: "企鹅不像,需要重新调整。", + }), + ]), + ); + api.listJournal.mockResolvedValue({ + items: [ + { + id: "journal-edit", + timestamp: "2026-09-07T08:00:00Z", + action: "user_edit", + actor: "user", + target_atom_id: "atom-corrected", + before: { + assertion: "企鹅不像,需要重新调整。", + atom_id: "atom-old", + }, + after: { + assertion: "企鹅图像已经调整。", + atom_id: "atom-corrected", + }, + }, + ], + total: 1, + has_more: false, + }); + + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByText("拼豆工作台")); + await user.click(await screen.findByText("企鹅图像已经调整。")); + + await waitFor(() => { + expect(api.listJournal).toHaveBeenCalledWith("main", { + action: "user_edit", + target_atom_id: "atom-corrected", + limit: 1, + }); + }); + expect(await screen.findByText(/人工修正的记忆/)).toBeInTheDocument(); + expect( + screen.getByText("修正前:企鹅不像,需要重新调整。"), + ).toBeInTheDocument(); + expect(screen.getByText(/原始来源上下文:/)).toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/pages/Agent/Memory/MemoryTree.tsx b/dashboard/src/pages/Agent/Memory/MemoryTree.tsx index 8da35529..6736e031 100644 --- a/dashboard/src/pages/Agent/Memory/MemoryTree.tsx +++ b/dashboard/src/pages/Agent/Memory/MemoryTree.tsx @@ -39,6 +39,8 @@ import { BookOpen, ChevronDown, ChevronRight, + Pencil, + Plus, RefreshCw, Trash2, } from "lucide-react"; @@ -56,6 +58,9 @@ import { import Markdown from "../../../components/Markdown/LazyMarkdown"; import MemoryPipelineEmpty from "./shared/MemoryPipelineEmpty"; import { confirmDeprecateAtom } from "./shared/deprecateAtom"; +import { confirmEditAtom } from "./shared/editAtom"; +import CreateAtomModal from "./shared/createAtom"; +import LineageStrip from "./shared/LineageStrip"; interface Props { agentId: string; @@ -78,6 +83,8 @@ export default function MemoryTree({ agentId, initialExpandEntityId }: Props) { ); const [atomsByEntity, setAtomsByEntity] = useState(new Map()); const [selectedAtom, setSelectedAtom] = useState(null); + const [createOpen, setCreateOpen] = useState(false); + const [createEntityId, setCreateEntityId] = useState(); // Entity whose long-form summary page is being viewed in the drawer. const [summaryEntity, setSummaryEntity] = useState(null); // Used for scroll positioning. @@ -190,6 +197,21 @@ export default function MemoryTree({ agentId, initialExpandEntityId }: Props) { void fetchAtoms(atom.entity_id); }; + const handleReplaced = (next: AtomItem) => { + setSelectedAtom(next); + void fetchAtoms(next.entity_id); + }; + + const handleCreated = (atom: AtomItem) => { + setExpanded((prev) => { + const next = new Set(prev); + next.add(atom.entity_id); + return next; + }); + void loadEntities(); + void fetchAtoms(atom.entity_id); + }; + const totalAtoms = useMemo( () => entities.reduce((sum, e) => sum + e.atom_count, 0), [entities], @@ -221,9 +243,24 @@ export default function MemoryTree({ agentId, initialExpandEntityId }: Props) { {t("memory.tree.atomCount", "{{n}} 条记忆", { n: totalAtoms })} - - {t("common.refresh", "刷新")} - + + + + {t("common.refresh", "刷新")} + +
@@ -262,11 +299,22 @@ export default function MemoryTree({ agentId, initialExpandEntityId }: Props) { expanded={isExpanded} onToggle={() => handleToggleEntity(entity.id)} onViewSummary={() => setSummaryEntity(entity)} + onAdd={() => { + setCreateEntityId(entity.id); + setCreateOpen(true); + }} /> {isExpanded ? ( + confirmEditAtom({ + agentId, + atom, + onSuccess: handleReplaced, + }) + } onDeprecate={(atom) => confirmDeprecateAtom({ agentId, @@ -289,6 +337,7 @@ export default function MemoryTree({ agentId, initialExpandEntityId }: Props) { agentId={agentId} onClose={() => setSelectedAtom(null)} onDeprecated={handleDeprecated} + onReplaced={handleReplaced} /> setSummaryEntity(null)} /> + + { + setCreateOpen(false); + setCreateEntityId(undefined); + }} + onSuccess={(atom) => handleCreated(atom)} + /> ); } @@ -343,11 +404,13 @@ function EntityRow({ expanded, onToggle, onViewSummary, + onAdd, }: { entity: EntityItem; expanded: boolean; onToggle: () => void; onViewSummary: () => void; + onAdd: () => void; }) { const [hovered, setHovered] = useState(false); const { t } = useTranslation(); @@ -413,6 +476,21 @@ function EntityRow({ 待刷新 ) : null} + + + + + + ) : null}
diff --git a/dashboard/src/pages/Agent/Memory/shared/LineageStrip.tsx b/dashboard/src/pages/Agent/Memory/shared/LineageStrip.tsx index 6f2a56a0..347b5cc3 100644 --- a/dashboard/src/pages/Agent/Memory/shared/LineageStrip.tsx +++ b/dashboard/src/pages/Agent/Memory/shared/LineageStrip.tsx @@ -7,17 +7,20 @@ import { useEffect, useState } from "react"; import { Skeleton, Typography } from "antd"; +import { useTranslation } from "react-i18next"; import { memoryDashboardApi, type AtomItem, type CandidateItem, + type JournalItem, } from "../../../../api/modules/memoryDashboard"; interface RawEventShape { id?: string; content?: string; text?: string; + event_type?: string; [k: string]: unknown; } @@ -27,22 +30,45 @@ interface Props { } export default function LineageStrip({ agentId, atom }: Props) { + const { t } = useTranslation(); const [rawEvent, setRawEvent] = useState(null); + const [correction, setCorrection] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { let cancelled = false; setLoading(true); setRawEvent(null); + setCorrection(null); (async () => { try { - if (!atom.candidate_id) return; - if (typeof memoryDashboardApi.getCandidate !== "function") return; + const correctionRequest = + typeof memoryDashboardApi.listJournal === "function" + ? memoryDashboardApi + .listJournal(agentId, { + action: "user_edit", + target_atom_id: atom.id, + limit: 1, + }) + .catch(() => null) + : Promise.resolve(null); + + if ( + !atom.candidate_id || + typeof memoryDashboardApi.getCandidate !== "function" + ) { + const correctionResult = await correctionRequest; + if (!cancelled) setCorrection(correctionResult?.items[0] ?? null); + return; + } const cand = (await memoryDashboardApi .getCandidate(agentId, atom.candidate_id) .catch(() => null)) as CandidateItem | null; if (cancelled) return; + const correctionResult = await correctionRequest; + if (cancelled) return; + setCorrection(correctionResult?.items[0] ?? null); const eventId = cand?.quote_event_id; if (eventId && typeof memoryDashboardApi.getRawEvent === "function") { const ev = (await memoryDashboardApi @@ -70,41 +96,73 @@ export default function LineageStrip({ agentId, atom }: Props) { } const fromQuote = rawEvent?.content || rawEvent?.text || atom.verbatim_quote; - if (!fromQuote) { - return ( + const beforeAssertion = correction?.before?.assertion; + const correctionText = + typeof beforeAssertion === "string" && beforeAssertion.trim() + ? beforeAssertion + : null; + const sourceLabel = correction + ? t("memory.lineage.originalContext", "原始来源上下文:") + : rawEvent?.event_type === "manual" + ? t("memory.lineage.manualEntry", "人工添加的记忆:") + : t("memory.lineage.conversationSource", "来源对话片段:"); + + return ( +
+ {correction ? ( +
+ + ✏️ {t("memory.lineage.manualCorrection", "人工修正的记忆")} + + {correctionText ? ( +
+ {t("memory.lineage.beforeCorrection", "修正前:{{assertion}}", { + assertion: correctionText, + })} +
+ ) : null} +
+ ) : null}
- 🧬 由 Octop 从对话中提取 + 🧬 {sourceLabel} -
- ); - } - return ( -
- - 🧬 来源对话片段: - -
- {truncate(String(fromQuote), 200)} + {fromQuote ? ( +
+ {truncate(String(fromQuote), 200)} +
+ ) : ( +
+ {t("memory.lineage.noOriginalContext", "没有可展示的原始来源")} +
+ )}
); } +const correctionBox: React.CSSProperties = { + border: "1px solid #91caff", + background: "#e6f4ff", + borderRadius: 6, + padding: "10px 12px", + marginBottom: 8, +}; + const lineageBox: React.CSSProperties = { border: "1px solid #f0f0f0", background: "#fcfcfc", diff --git a/dashboard/src/pages/Agent/Memory/shared/createAtom.tsx b/dashboard/src/pages/Agent/Memory/shared/createAtom.tsx new file mode 100644 index 00000000..533f8754 --- /dev/null +++ b/dashboard/src/pages/Agent/Memory/shared/createAtom.tsx @@ -0,0 +1,217 @@ +/** + * createAtom — modal for manually adding an Entity + AtomCard. + */ +import { useEffect } from "react"; +import { Form, Input, Modal, Radio, Select } from "antd"; +import { message } from "@/utils/antdMessage"; +import { useTranslation } from "react-i18next"; + +import { + memoryDashboardApi, + type AtomItem, + type AtomKind, + type EntityItem, +} from "../../../../api/modules/memoryDashboard"; + +const KIND_OPTIONS: { value: AtomKind; label: string }[] = [ + { value: "Fact", label: "事实" }, + { value: "Preference", label: "偏好" }, + { value: "Decision", label: "决定" }, + { value: "Task", label: "任务" }, +]; + +const ENTITY_TYPE_OPTIONS = [ + { value: "Fact", label: "事实" }, + { value: "Person", label: "人物" }, + { value: "User", label: "用户" }, + { value: "Project", label: "项目" }, + { value: "Decision", label: "决定" }, + { value: "Task", label: "任务" }, +]; + +interface Props { + open: boolean; + agentId: string; + entities: EntityItem[]; + /** When set, lock the form to this existing topic. */ + presetEntityId?: string; + onClose: () => void; + onSuccess?: ( + atom: AtomItem, + entity: EntityItem, + createdEntity: boolean, + ) => void; +} + +export default function CreateAtomModal({ + open, + agentId, + entities, + presetEntityId, + onClose, + onSuccess, +}: Props) { + const { t } = useTranslation(); + const [form] = Form.useForm(); + const topicMode = Form.useWatch("topicMode", form) as + | "existing" + | "new" + | undefined; + + useEffect(() => { + if (!open) return; + form.setFieldsValue({ + topicMode: presetEntityId || entities.length > 0 ? "existing" : "new", + entity_id: presetEntityId, + entity_name: undefined, + entity_type: "Fact", + kind: "Fact", + assertion: "", + }); + }, [open, presetEntityId, entities.length, form]); + + const lockedToEntity = Boolean(presetEntityId); + + return ( + { + const values = await form.validateFields(); + const assertion = String(values.assertion || "").trim(); + try { + const body = + values.topicMode === "new" + ? { + assertion, + entity_name: String(values.entity_name || "").trim(), + entity_type: values.entity_type as string, + kind: values.kind as AtomKind, + } + : { + assertion, + entity_id: values.entity_id as string, + kind: values.kind as AtomKind, + }; + const r = await memoryDashboardApi.createAtom(agentId, body); + message.success(t("memory.create.success", "记忆已添加")); + onSuccess?.(r.atom, r.entity, r.created_entity); + onClose(); + } catch (e) { + message.error( + t("memory.create.failed", { + message: (e as Error).message ?? e, + defaultValue: "添加失败:{{message}}", + }), + ); + throw e; + } + }} + > +
+ {!lockedToEntity ? ( + + + + {t("memory.create.existingTopic", "已有主题")} + + + {t("memory.create.newTopic", "新建主题")} + + + + ) : null} + + {lockedToEntity || topicMode === "existing" ? ( + + + + + + + +
+
+ ); +} diff --git a/dashboard/src/pages/Agent/Memory/shared/editAtom.tsx b/dashboard/src/pages/Agent/Memory/shared/editAtom.tsx new file mode 100644 index 00000000..730098c6 --- /dev/null +++ b/dashboard/src/pages/Agent/Memory/shared/editAtom.tsx @@ -0,0 +1,64 @@ +/** + * editAtom — shared confirmation flow for replacing one memory's assertion. + */ +import { Input, Modal, Typography } from "antd"; +import { message } from "@/utils/antdMessage"; +import i18n from "@/i18n"; + +import { + memoryDashboardApi, + type AtomItem, +} from "../../../../api/modules/memoryDashboard"; + +export function confirmEditAtom({ + agentId, + atom, + onSuccess, +}: { + agentId: string; + atom: AtomItem; + onSuccess?: (next: AtomItem) => void; +}) { + let assertion = atom.assertion; + Modal.confirm({ + title: i18n.t("memory.edit.title"), + width: 520, + content: ( +
+ + {i18n.t("memory.edit.description")} + + { + assertion = e.target.value; + }} + /> +
+ ), + okText: i18n.t("memory.edit.ok"), + cancelText: i18n.t("common.cancel"), + onOk: async () => { + const text = assertion.trim(); + if (!text) { + message.error(i18n.t("memory.edit.empty")); + throw new Error("empty assertion"); + } + try { + const r = await memoryDashboardApi.replaceAtom(agentId, atom.id, { + assertion: text, + }); + message.success(i18n.t("memory.edit.success")); + onSuccess?.(r.atom); + } catch (e) { + message.error( + i18n.t("memory.edit.failed", { + message: (e as Error).message ?? e, + }), + ); + throw e; + } + }, + }); +} diff --git a/src/octop/api/routers/memory.py b/src/octop/api/routers/memory.py index 87929041..fc359967 100644 --- a/src/octop/api/routers/memory.py +++ b/src/octop/api/routers/memory.py @@ -9,6 +9,8 @@ * ``POST .../atoms/list`` → ``list_atoms`` * ``GET .../atoms/{atom_id}`` → ``memory_get`` (path projection) +* ``POST .../atoms`` → ``create_atom`` +* ``POST .../atoms/{atom_id}:replace`` → ``replace_atom`` * ``POST .../atoms/{atom_id}:deprecate`` → ``deprecate_atom`` * ``POST .../entities/list`` → ``list_entities`` * ``GET .../entities/{entity_id}`` → ``memory_get`` (page projection, may be empty) @@ -201,6 +203,24 @@ class _DeprecateAtomBody(BaseModel): actor: str | None = None +class _CreateAtomBody(BaseModel): + assertion: str + entity_id: str | None = None + entity_name: str | None = None + entity_type: str | None = Field( + default=None, description="User / Person / Project / Decision / Task / Fact" + ) + kind: str | None = Field(default=None, description="Fact / Decision / Task / Preference") + importance: str | None = None + confidence: str | None = None + reason: str | None = None + + +class _ReplaceAtomBody(BaseModel): + assertion: str + reason: str | None = None + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -616,6 +636,64 @@ async def deprecate_atom( ) +@router.post( + "/agents/{agent_id}/memory/atoms", + summary="Create a long-term memory", + description=( + "Creates a canonical memory under an existing or new entity. The request is recorded as actor=user; clients " + "cannot override the audit actor." + ), +) +async def create_atom( + agent_id: str, + body: _CreateAtomBody, + as_user: int | None = None, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + return cast( + dict[str, Any], + call_memory_rpc( + agent_id=agent_id, + method="create_atom", + params=_strip_none(body.model_dump()), + user=user, + as_user=as_user, + server=server, + ), + ) + + +@router.post( + "/agents/{agent_id}/memory/atoms/{atom_id}:replace", + summary="Correct a long-term memory", + description=( + "Creates a successor Atom and supersedes the active Atom without creating a conversation event or extraction " + "candidate. The request is recorded as actor=user; clients cannot override the audit actor." + ), +) +async def replace_atom( + agent_id: str, + atom_id: str, + body: _ReplaceAtomBody, + as_user: int | None = None, + user: Any = Depends(current_user), + server: Any = Depends(get_server), +) -> dict[str, Any]: + params = _strip_none({"atom_id": atom_id, **body.model_dump()}) + return cast( + dict[str, Any], + call_memory_rpc( + agent_id=agent_id, + method="replace_atom", + params=params, + user=user, + as_user=as_user, + server=server, + ), + ) + + # --------------------------------------------------------------------------- # Terminal aggregator endpoints # --------------------------------------------------------------------------- diff --git a/tests/integration/test_memory_api.py b/tests/integration/test_memory_api.py index 14f3a71a..56530c30 100644 --- a/tests/integration/test_memory_api.py +++ b/tests/integration/test_memory_api.py @@ -271,6 +271,64 @@ async def test_deprecate_atom_round_trip(env_with_main_agent) -> None: assert r2.status_code == 404 +@pytest.mark.asyncio +async def test_create_and_replace_atom_round_trip(env_with_main_agent) -> None: + client, srv, auth, aid = env_with_main_agent + _seed_memory(srv, aid) + + created = await client.post( + f"/api/agents/{aid}/memory/atoms", + headers=auth, + json={ + "assertion": "喜欢早起跑步", + "entity_name": "作息", + "entity_type": "Fact", + "kind": "Preference", + "actor": "rule", + }, + ) + assert created.status_code == 200, created.text + body = created.json() + assert body["status"] == "created" + assert body["created_entity"] is True + atom_id = body["atom"]["id"] + assert body["atom"]["assertion"] == "喜欢早起跑步" + + create_journal = await client.post( + f"/api/agents/{aid}/memory/journal/list", + headers=auth, + json={"action": "create", "target_atom_id": atom_id}, + ) + assert create_journal.status_code == 200 + assert create_journal.json()["items"][0]["actor"] == "user" + + replaced = await client.post( + f"/api/agents/{aid}/memory/atoms/{atom_id}:replace", + headers=auth, + json={"assertion": "改成晚上跑步", "actor": "auto"}, + ) + assert replaced.status_code == 200, replaced.text + assert replaced.json()["status"] == "replaced" + assert replaced.json()["atom"]["assertion"] == "改成晚上跑步" + assert replaced.json()["old_atom_id"] == atom_id + + replacement_id = replaced.json()["atom"]["id"] + edit_journal = await client.post( + f"/api/agents/{aid}/memory/journal/list", + headers=auth, + json={"action": "user_edit", "target_atom_id": replacement_id}, + ) + assert edit_journal.status_code == 200 + assert edit_journal.json()["items"][0]["actor"] == "user" + + again = await client.post( + f"/api/agents/{aid}/memory/atoms/{atom_id}:replace", + headers=auth, + json={"assertion": "这条已经弃用了"}, + ) + assert again.status_code == 404 + + @pytest.mark.asyncio async def test_list_raw_events(env_with_main_agent) -> None: client, srv, auth, aid = env_with_main_agent