From 2a640a901b97861204e9f0903a8a90b3fdadb54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Fri, 21 Aug 2026 23:54:17 +0800 Subject: [PATCH] =?UTF-8?q?M2c=EF=BC=9A=E5=89=8D=E7=AB=AF=E9=95=9C?= =?UTF-8?q?=E6=A0=91=E9=A1=B5=EF=BC=88=E8=B7=AF=E5=BE=84=E9=98=85=E8=AF=BB?= =?UTF-8?q?=E6=B5=81/=E5=80=99=E7=AD=94=E5=88=87=E6=8D=A2/=E9=87=87?= =?UTF-8?q?=E7=94=A8=E5=BD=92=E7=9C=9F/=E5=B1=80=E9=83=A8=E6=A0=91?= =?UTF-8?q?=E5=9B=BE/SSE=20=E8=8A=BD=E4=BD=93=EF=BC=89=E4=B8=8E=E7=81=B5?= =?UTF-8?q?=E8=83=9A=E9=A1=B5=EF=BC=88=E5=88=9B=E5=BB=BA/=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=8D=A1/=E7=85=A7=E5=BF=83=E8=AF=95=E6=BC=94/?= =?UTF-8?q?=E5=90=AF=E9=95=9C=EF=BC=89=EF=BC=8C=E5=A5=91=E7=BA=A6=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=A0=81=E6=AE=B5=E6=8E=A5=E5=85=A5=E5=89=8D=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/operations/capability-matrix.md | 2 +- docs/operations/rewrite-plan.md | 3 +- frontend/src/app/(app)/embryos/page.tsx | 32 ++ .../src/app/(app)/trees/[treeId]/page.tsx | 62 +++ frontend/src/app/(app)/trees/page.tsx | 68 +++ .../src/components/embryos/EmbryoManager.tsx | 354 ++++++++++++++ frontend/src/components/layout/Sidebar.tsx | 7 +- frontend/src/components/trees/AnswerBlock.tsx | 202 ++++++++ frontend/src/components/trees/AskBox.tsx | 86 ++++ .../src/components/trees/BuddingBlock.tsx | 92 ++++ .../src/components/trees/CanonizeDialog.tsx | 136 ++++++ .../src/components/trees/LocalGraphPanel.tsx | 120 +++++ .../src/components/trees/TreeCreateForm.tsx | 68 +++ .../src/components/trees/TreeWorkspace.tsx | 431 ++++++++++++++++++ frontend/src/lib/api/embryos.test.ts | 51 +++ frontend/src/lib/api/embryos.ts | 68 +++ frontend/src/lib/api/trees.test.ts | 101 ++++ frontend/src/lib/api/trees.ts | 102 +++++ frontend/src/lib/constants/labels.ts | 6 + frontend/src/types/domain.ts | 9 +- frontend/src/types/mirror-tree.ts | 130 ++++++ 21 files changed, 2126 insertions(+), 4 deletions(-) create mode 100644 frontend/src/app/(app)/embryos/page.tsx create mode 100644 frontend/src/app/(app)/trees/[treeId]/page.tsx create mode 100644 frontend/src/app/(app)/trees/page.tsx create mode 100644 frontend/src/components/embryos/EmbryoManager.tsx create mode 100644 frontend/src/components/trees/AnswerBlock.tsx create mode 100644 frontend/src/components/trees/AskBox.tsx create mode 100644 frontend/src/components/trees/BuddingBlock.tsx create mode 100644 frontend/src/components/trees/CanonizeDialog.tsx create mode 100644 frontend/src/components/trees/LocalGraphPanel.tsx create mode 100644 frontend/src/components/trees/TreeCreateForm.tsx create mode 100644 frontend/src/components/trees/TreeWorkspace.tsx create mode 100644 frontend/src/lib/api/embryos.test.ts create mode 100644 frontend/src/lib/api/embryos.ts create mode 100644 frontend/src/lib/api/trees.test.ts create mode 100644 frontend/src/lib/api/trees.ts create mode 100644 frontend/src/types/mirror-tree.ts diff --git a/docs/operations/capability-matrix.md b/docs/operations/capability-matrix.md index bf882a7..a9e70cd 100644 --- a/docs/operations/capability-matrix.md +++ b/docs/operations/capability-matrix.md @@ -34,7 +34,7 @@ | 乾坤沙箱 | M5 | 局部实现 | 隔离执行/HMAC/超时取消已有;设备安全与双裁决待扩 | | 博闻 MCP 搜索 | M5 | 局部实现 | 多引擎/密钥池/熔断/缓存可用;待登记入百工 | | 天目 / 元炁 | M4/M7 | 局部实现 | 服务可用;元炁待接入神识 Endpoint,天目待接通感 | -| 镜树前端 / 记忆治理 / 百工页 | M2+ | 未实现 | 旧页面随模块退役,按新信息架构重写 | +| 镜树前端 / 记忆治理 / 百工页 | M2+ | 局部实现 | 镜树页与灵胚页已上线(M2c,2026-08-21):路径阅读流/候答切换/采用归真/局部树图/SSE 芽体/导入卡;type-check + vitest 210 passed;记忆治理页随 M3、百工页随 M5 | | 导入(角色卡 V2/V3 → 灵胚) | M2 | 局部实现 | M2b 已验证(2026-08-21):POST /characters/import-card 产出可审阅灵胚(identity_baseline.import_card 暂存 character_book),启镜时物化世谱书+条目(dict/list 双形态兼容);导出方向保持人物→ST V3 快照 | ## 3. 能力行模板 diff --git a/docs/operations/rewrite-plan.md b/docs/operations/rewrite-plan.md index c83506a..572aad5 100644 --- a/docs/operations/rewrite-plan.md +++ b/docs/operations/rewrite-plan.md @@ -98,7 +98,8 @@ - 证据(M2a):`backend/tests/test_mirror_tree.py` 8 用例覆盖场景 1/2/3/5/6/7/8 与三界作用域;全量回归 940 passed(预存 20 失败不变);`make db-upgrade` 000→002 干净。场景 4(未归真不入忆)随 M3 记忆管线落测试;场景 9(移动端)与前端页面随 M2b。 - 证据(M2b,2026-08-21):`test_import_and_assembly.py` 3 用例(V1/V2 卡→灵胚、启镜物化世谱、符文展开+火候入 IR);旧 chat 体系删除——backend 删 modules/chat、application/chat、knocking、personality_emotion、services/time_sense、纪念日模型及关联仓储/端点/测试,`runs.session_id` 与四张旧表经迁移 003 退役(SQLite batch 双向验证通过);前端删 chat/time 页面与 api 库(type-check + vitest 199 passed);全量后端 846 passed、余 7 个预存失败(缺 `config/models.yaml`)。 - 提前退役说明:knocking/personality_emotion/纪念日原定 M6–M8 退役,因与 chat 线性会话强耦合随 M2b 一并删除;`modules/time_sense` 纯计算核(snapshot/duration/lunar,被符文引擎依赖)保留,辰正 M8 重接。 -- M2c 待办:前端镜树页(当前路径+局部树图+抽屉)、人物页灵胚/启镜流、Kernel 前台串行接入 DB 租约、眉批表(归入 M6 言灵编排)。 +- 证据(M2c,2026-08-21):`/trees` 列表与 `/trees/[treeId]` 工作区(路径阅读流、候答切换 `i/n`、另答/续思/续生/编辑分叉/采用/归真、归真对话框含三界效力说明与 HJ-1102 分歧提示、局部树图可折叠、SSE 芽体流式);`/embryos` 灵胚页(原创/导入卡/照心试演流/启镜强确认/放弃);API 库 `trees.ts`/`embryos.ts` + 11 用例;type-check 与 vitest 210 passed。Next 内嵌 BFF(/api/proxy、Cookie 鉴权)暂为前端通道,收口到照壁列为部署迁移项。 +- M2c 余项:移栽/引文锚 UI、已归真标记在树图回显(随 M3 记忆闭环)、Kernel 前台串行接入 DB 租约、眉批表(归入 M6 言灵编排)。 ### M3 记忆闭环(息壤) diff --git a/frontend/src/app/(app)/embryos/page.tsx b/frontend/src/app/(app)/embryos/page.tsx new file mode 100644 index 0000000..17ae818 --- /dev/null +++ b/frontend/src/app/(app)/embryos/page.tsx @@ -0,0 +1,32 @@ +import { PageContainer } from "@/components/layout/PageContainer"; +import { EmbryoManager } from "@/components/embryos/EmbryoManager"; +import { ErrorState } from "@/components/shared/StateViews"; +import { serverApi } from "@/lib/api/server-client"; +import { describeError } from "@/lib/utils/error"; +import type { EmbryoDraftView } from "@/types/mirror-tree"; + +export const dynamic = "force-dynamic"; + +export default async function EmbryosPage() { + let embryos: EmbryoDraftView[] = []; + let error: string | null = null; + + try { + embryos = await serverApi.get("/embryo-drafts"); + } catch (e) { + error = describeError(e); + } + + return ( + + {error ? ( + + ) : ( + + )} + + ); +} diff --git a/frontend/src/app/(app)/trees/[treeId]/page.tsx b/frontend/src/app/(app)/trees/[treeId]/page.tsx new file mode 100644 index 0000000..410bc45 --- /dev/null +++ b/frontend/src/app/(app)/trees/[treeId]/page.tsx @@ -0,0 +1,62 @@ +import { PageContainer } from "@/components/layout/PageContainer"; +import { ErrorState } from "@/components/shared/StateViews"; +import { TreeWorkspace } from "@/components/trees/TreeWorkspace"; +import { serverApi } from "@/lib/api/server-client"; +import { describeError } from "@/lib/utils/error"; +import type { + GraphResponse, + MirrorTreeView, + PathResponse, +} from "@/types/mirror-tree"; + +export const dynamic = "force-dynamic"; + +export default async function TreeDetailPage({ + params, +}: { + params: Promise<{ treeId: string }>; +}) { + const { treeId } = await params; + + let tree: MirrorTreeView | null = null; + let error: string | null = null; + try { + tree = await serverApi.get(`/trees/${treeId}`); + } catch (e) { + error = describeError(e); + } + if (error || !tree) { + return ( + + + + ); + } + + const [pathData, graphData] = await Promise.all([ + serverApi.get(`/trees/${treeId}/path`).catch(() => null), + serverApi + .get(`/trees/${treeId}/local-graph`, { depth: 4 }) + .catch(() => null), + ]); + if (!pathData || !graphData) { + return ( + + + + ); + } + + return ( + + + + ); +} diff --git a/frontend/src/app/(app)/trees/page.tsx b/frontend/src/app/(app)/trees/page.tsx new file mode 100644 index 0000000..1630035 --- /dev/null +++ b/frontend/src/app/(app)/trees/page.tsx @@ -0,0 +1,68 @@ +import Link from "next/link"; + +import { PageContainer } from "@/components/layout/PageContainer"; +import { ErrorState } from "@/components/shared/StateViews"; +import { serverApi } from "@/lib/api/server-client"; +import { describeError } from "@/lib/utils/error"; +import type { Paginated, Character } from "@/types/api"; +import type { MirrorTreeView } from "@/types/mirror-tree"; + +import { TreeCreateForm } from "@/components/trees/TreeCreateForm"; + +export const dynamic = "force-dynamic"; + +export default async function TreesPage() { + let trees: MirrorTreeView[] = []; + let characterId: string | undefined; + let error: string | null = null; + + try { + const active = await serverApi + .get("/characters/active") + .catch(() => null); + characterId = active?.id; + // 树列表为非分页数组端点;兼容分页包装 + const data = await serverApi.get< + MirrorTreeView[] | Paginated + >("/trees"); + trees = Array.isArray(data) ? data : data.items; + } catch (e) { + error = describeError(e); + } + + return ( + + {error ? ( + + ) : ( +
+ {characterId && } + {trees.length === 0 ? ( +

+ 还没有镜树。创建一棵,从第一个问题开始。 +

+ ) : ( +
    + {trees.map((tree) => ( +
  • + +

    {tree.title}

    +

    + {tree.transplanted_from?.source_tree_id + ? "移栽自另一棵树 · " + : ""} + 创建于 {new Date(tree.created_at).toLocaleString("zh-CN")} +

    + +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/embryos/EmbryoManager.tsx b/frontend/src/components/embryos/EmbryoManager.tsx new file mode 100644 index 0000000..817af53 --- /dev/null +++ b/frontend/src/components/embryos/EmbryoManager.tsx @@ -0,0 +1,354 @@ +"use client"; + +/** + * 灵胚管理:创建(原创/导入卡)、照心编辑、试演(幻境流)、启镜、放弃。 + * 语义(CONTEXT.md):灵胚只有预备身份;放弃灵胚是删草稿,不是死亡; + * 启镜才签发正式人物、魂籍、执镜关系与命台。 + */ + +import { useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; + +import { BuddingBlock } from "@/components/trees/BuddingBlock"; +import { Button } from "@/components/ui/Button"; +import { Modal } from "@/components/ui/Modal"; +import * as embryosApi from "@/lib/api/embryos"; +import { cn } from "@/lib/utils/cn"; +import type { EmbryoDraftView } from "@/types/mirror-tree"; + +interface EmbryoManagerProps { + initialEmbryos: EmbryoDraftView[]; +} + +const SOURCE_LABEL: Record = { + original: "原创", + adopted: "领养", + imported: "导入", + serendipity: "随缘", +}; + +const STATUS_LABEL: Record = { + drafting: "草稿", + previewing: "照心中", + confirming: "待启镜", + activated: "已启镜", +}; + +export function EmbryoManager({ initialEmbryos }: EmbryoManagerProps) { + const router = useRouter(); + const [embryos, setEmbryos] = useState(initialEmbryos); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + + const [rehearseTarget, setRehearseTarget] = useState( + null, + ); + const [rehearsePrompt, setRehearsePrompt] = useState("介绍一下你自己。"); + const [rehearseRunId, setRehearseRunId] = useState(null); + + const reload = useCallback(async () => { + setEmbryos(await embryosApi.listEmbryos()); + }, []); + + const create = async () => { + if (!name.trim() || busy) return; + setBusy(true); + try { + await embryosApi.createEmbryo({ + source: "original", + name: name.trim(), + description: description.trim() || undefined, + }); + setName(""); + setDescription(""); + await reload(); + } catch (e) { + setMessage(e instanceof Error ? e.message : "创建失败"); + } finally { + setBusy(false); + } + }; + + const onImport = async (file: File) => { + setBusy(true); + try { + const result = await embryosApi.importCard(file); + setMessage( + `已导入「${result.name}」为灵胚(世谱条目 ${result.book_entry_count} 条待启镜物化)`, + ); + await reload(); + } catch (e) { + setMessage(e instanceof Error ? e.message : "导入失败"); + } finally { + setBusy(false); + } + }; + + const startRehearse = async () => { + if (!rehearseTarget || !rehearsePrompt.trim()) return; + setBusy(true); + try { + const result = await embryosApi.rehearseEmbryo( + rehearseTarget.id, + rehearsePrompt.trim(), + ); + setRehearseRunId(result.run_id); + } catch (e) { + setMessage(e instanceof Error ? e.message : "试演失败"); + } finally { + setBusy(false); + } + }; + + const activate = async (embryo: EmbryoDraftView) => { + if ( + !window.confirm( + `启镜「${embryo.name}」?将签发正式人物 ID、魂籍与命台,此操作不可撤销。`, + ) + ) { + return; + } + setBusy(true); + try { + await embryosApi.activateEmbryo(embryo.id); + setMessage(`「${embryo.name}」已启镜,人物已就绪`); + await reload(); + } catch (e) { + setMessage(e instanceof Error ? e.message : "启镜失败"); + } finally { + setBusy(false); + } + }; + + const abandon = async (embryo: EmbryoDraftView) => { + if ( + !window.confirm( + `放弃灵胚「${embryo.name}」?这是删除草稿,不是人物死亡。`, + ) + ) + return; + setBusy(true); + try { + await embryosApi.abandonEmbryo(embryo.id); + await reload(); + } catch (e) { + setMessage(e instanceof Error ? e.message : "放弃失败"); + } finally { + setBusy(false); + } + }; + + return ( +
+ {message && ( +

+ {message} + +

+ )} + +
+
{ + e.preventDefault(); + void create(); + }} + > +

原创灵胚

+ setName(e.target.value)} + placeholder="灵胚之名" + aria-label="灵胚之名" + className="w-full rounded-lg border border-rule bg-transparent px-3 py-2 text-sm outline-none focus:border-patina" + /> +