M2c:前端镜树页(路径阅读流/候答切换/采用归真/局部树图/SSE 芽体)与灵胚页(创建/导入卡/照心试演/启镜),契约错误码段接入前端

This commit is contained in:
chenxin committed 2026-08-21 23:54:17 +08:00
1 parent 30447fc40f
commit 2a640a901b
21 files changed
+2126 -4

No files matched your search

+1 -1
View File
@@ -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. 能力行模板
+2 -1
View File
@@ -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 记忆闭环(息壤)
+32
View File
@@ -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<EmbryoDraftView[]>("/embryo-drafts");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="灵胚"
description="启镜前的可审阅草稿:原创、导入或照心共塑;试演不入正式经历。"
>
{error ? (
<ErrorState message={error} />
) : (
<EmbryoManager initialEmbryos={embryos} />
)}
</PageContainer>
);
}
@@ -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<MirrorTreeView>(`/trees/${treeId}`);
} catch (e) {
error = describeError(e);
}
if (error || !tree) {
return (
<PageContainer title="镜树">
<ErrorState message={error ?? "树不存在。"} />
</PageContainer>
);
}
const [pathData, graphData] = await Promise.all([
serverApi.get<PathResponse>(`/trees/${treeId}/path`).catch(() => null),
serverApi
.get<GraphResponse>(`/trees/${treeId}/local-graph`, { depth: 4 })
.catch(() => null),
]);
if (!pathData || !graphData) {
return (
<PageContainer title="镜树">
<ErrorState message="树数据读取失败,请刷新重试。" />
</PageContainer>
);
}
return (
<PageContainer
title={tree.title}
description="当前路径阅读 · 采用/归真分离 · 局部树图定位。"
>
<TreeWorkspace
tree={tree}
initialPath={pathData}
initialGraph={graphData}
/>
</PageContainer>
);
}
+68
View File
@@ -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<Character>("/characters/active")
.catch(() => null);
characterId = active?.id;
// 树列表为非分页数组端点;兼容分页包装
const data = await serverApi.get<
MirrorTreeView[] | Paginated<MirrorTreeView>
>("/trees");
trees = Array.isArray(data) ? data : data.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer title="镜树" description="围绕主题自然生长的树状对话。">
{error ? (
<ErrorState message={error} />
) : (
<div className="space-y-6">
{characterId && <TreeCreateForm characterId={characterId} />}
{trees.length === 0 ? (
<p className="rounded-xl border border-dashed border-rule p-6 text-center text-sm text-muted">
还没有镜树。创建一棵,从第一个问题开始。
</p>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{trees.map((tree) => (
<li key={tree.id}>
<Link
href={`/trees/${tree.id}`}
className="block rounded-xl border border-rule bg-surface p-4 transition-colors hover:border-patina"
>
<h2 className="font-medium">{tree.title}</h2>
<p className="mt-1 text-xs text-muted">
{tree.transplanted_from?.source_tree_id
? "移栽自另一棵树 · "
: ""}
创建于 {new Date(tree.created_at).toLocaleString("zh-CN")}
</p>
</Link>
</li>
))}
</ul>
)}
</div>
)}
</PageContainer>
);
}
@@ -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<string, string> = {
original: "原创",
adopted: "领养",
imported: "导入",
serendipity: "随缘",
};
const STATUS_LABEL: Record<string, string> = {
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<string | null>(null);
const [rehearseTarget, setRehearseTarget] = useState<EmbryoDraftView | null>(
null,
);
const [rehearsePrompt, setRehearsePrompt] = useState("介绍一下你自己。");
const [rehearseRunId, setRehearseRunId] = useState<string | null>(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 (
<div className="space-y-6">
{message && (
<p
role="status"
className="rounded-lg border border-rule bg-surface p-3 text-sm"
>
{message}
<button
type="button"
className="ml-2 text-xs underline"
onClick={() => setMessage(null)}
>
收起
</button>
</p>
)}
<section className="grid gap-3 md:grid-cols-2">
<form
className="space-y-2 rounded-xl border border-rule bg-surface p-4"
onSubmit={(e) => {
e.preventDefault();
void create();
}}
>
<h2 className="text-sm font-medium">原创灵胚</h2>
<input
value={name}
onChange={(e) => 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"
/>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="身份描述(可留待照心共塑)"
aria-label="身份描述"
rows={3}
className="w-full resize-none rounded-lg border border-rule bg-transparent px-3 py-2 text-sm outline-none focus:border-patina"
/>
<Button
type="submit"
variant="primary"
size="sm"
disabled={busy || !name.trim()}
>
造胚
</Button>
</form>
<form
className="space-y-2 rounded-xl border border-dashed border-rule bg-surface/60 p-4"
onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.elements.namedItem(
"card",
) as HTMLInputElement | null;
const file = input?.files?.[0];
if (file) void onImport(file);
}}
>
<h2 className="text-sm font-medium">
导入角色卡(ST V1/V2/V3 → 灵胚)
</h2>
<p className="text-xs text-muted">
卡内容进入道体草稿;character_book
将在启镜时物化为世谱。导入即草稿,启镜前可照心修改。
</p>
<input
type="file"
name="card"
accept=".png,.json,.charx"
aria-label="角色卡文件"
className="w-full text-xs"
/>
<Button type="submit" variant="outline" size="sm" disabled={busy}>
导入为灵胚
</Button>
</form>
</section>
{embryos.length === 0 ? (
<p className="rounded-xl border border-dashed border-rule p-6 text-center text-sm text-muted">
尚无灵胚。原创或导入,都是一场照心的开始。
</p>
) : (
<ul className="space-y-3">
{embryos.map((embryo) => (
<li
key={embryo.id}
className="rounded-xl border border-rule bg-surface p-4"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<h3 className="font-medium">{embryo.name}</h3>
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted">
{SOURCE_LABEL[embryo.source] ?? embryo.source}
</span>
<span
className={cn(
"rounded px-1.5 py-0.5 text-[10px]",
embryo.status === "activated"
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "bg-patina/15 text-patina",
)}
>
{STATUS_LABEL[embryo.status] ?? embryo.status}
</span>
</div>
<div className="flex flex-wrap gap-2">
{embryo.status !== "activated" && (
<>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => {
setRehearseTarget(embryo);
setRehearseRunId(null);
}}
>
照心试演
</Button>
<Button
variant="primary"
size="sm"
disabled={busy}
onClick={() => void activate(embryo)}
>
启镜
</Button>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => void abandon(embryo)}
>
放弃
</Button>
</>
)}
{embryo.status === "activated" && (
<Button
variant="ghost"
size="sm"
onClick={() => router.push("/characters")}
>
前往人物
</Button>
)}
</div>
</div>
{embryo.description && (
<p className="mt-2 line-clamp-2 text-sm text-muted">
{embryo.description}
</p>
)}
{embryo.rehearsals.length > 0 && (
<p className="mt-1 text-xs text-muted">
照心试演 {embryo.rehearsals.length} 次
</p>
)}
</li>
))}
</ul>
)}
<Modal
open={Boolean(rehearseTarget)}
onClose={() => setRehearseTarget(null)}
title={`照心试演 · ${rehearseTarget?.name ?? ""}`}
size="md"
>
<div className="space-y-3 text-sm">
<p className="text-muted">
试演发生在幻境:不产生人物经历,也不入任何正式记录。你在预览「以此相见」之前的它。
</p>
{!rehearseRunId ? (
<>
<textarea
value={rehearsePrompt}
onChange={(e) => setRehearsePrompt(e.target.value)}
rows={3}
aria-label="试演提问"
className="w-full resize-none rounded-lg border border-rule bg-transparent p-2 text-sm outline-none focus:border-patina"
/>
<div className="flex justify-end">
<Button
variant="primary"
size="sm"
disabled={busy || !rehearsePrompt.trim()}
onClick={() => void startRehearse()}
>
开始试演
</Button>
</div>
</>
) : (
<BuddingBlock
runId={rehearseRunId}
onFinished={async () => {
await reload();
}}
/>
)}
</div>
</Modal>
</div>
);
}
+6 -1
View File
@@ -7,6 +7,7 @@ import { LogoButton } from "@/components/easter-egg/LogoButton";
import {
AdminIcon,
ArchiveIcon,
ChatIcon,
CharacterIcon,
CosmosIcon,
DriveIcon,
@@ -51,11 +52,15 @@ interface NavGroup {
const NAV_GROUPS: NavGroup[] = [
{
title: "灵台",
items: [{ href: "/runs", label: "轨迹", icon: <RunIcon /> }],
items: [
{ href: "/trees", label: "镜树", icon: <ChatIcon /> },
{ href: "/runs", label: "轨迹", icon: <RunIcon /> },
],
},
{
title: "法身",
items: [
{ href: "/embryos", label: "灵胚", icon: <CharacterIcon /> },
{ href: "/cosmos", label: "灵枢", icon: <CosmosIcon /> },
{ href: "/wuxing", label: "五行", icon: <WuxingIcon /> },
{ href: "/characters", label: "人物", icon: <CharacterIcon /> },
@@ -0,0 +1,202 @@
"use client";
/**
* 候答区块:同一问节下的候答组——切换、另答、续思/续生、编辑分叉、采用、归真。
* 交互红线(镜树 PRD §5):采用只改工作入口;归真才进入正式经历,两者视觉与语义分离。
*/
import { useState } from "react";
import { Button } from "@/components/ui/Button";
import { cn } from "@/lib/utils/cn";
import type { TreeNodeView } from "@/types/mirror-tree";
interface AnswerBlockProps {
question: TreeNodeView;
candidates: TreeNodeView[];
selectedId: string | null;
workingEntryId: string | null;
canonizedIds: Set<string>;
busy: boolean;
onSelect: (nodeId: string) => void;
onRegenerate: (questionId: string) => void;
onContinue: (nodeId: string) => void;
onResume: (nodeId: string) => void;
onEditFork: (nodeId: string, currentContent: string) => void;
onAdopt: (nodeId: string) => void;
onCanonize: (nodeId: string) => void;
}
const STATUS_LABEL: Record<string, string> = {
budding: "芽体",
sealed_residual: "残答",
solidified: "已固化",
};
export function AnswerBlock({
question,
candidates,
selectedId,
workingEntryId,
canonizedIds,
busy,
onSelect,
onRegenerate,
onContinue,
onResume,
onEditFork,
onAdopt,
onCanonize,
}: AnswerBlockProps) {
const [index, setIndex] = useState(() => {
const i = candidates.findIndex((c) => c.id === selectedId);
return i >= 0 ? i : 0;
});
const current = candidates[index];
if (!current) {
return (
<div className="ml-6 rounded-lg border border-dashed border-rule p-3">
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => onRegenerate(question.id)}
>
生成候答
</Button>
</div>
);
}
const isAdopted = current.id === workingEntryId;
const isCanonized = canonizedIds.has(current.id);
const solidified = current.status === "solidified";
const residual = current.status === "sealed_residual";
const prev = () =>
setIndex((i) => (i - 1 + candidates.length) % candidates.length);
const next = () => setIndex((i) => (i + 1) % candidates.length);
const shownMismatch = selectedId !== current.id;
return (
<div className="ml-6 space-y-2">
<div className="flex items-center gap-2 text-xs text-muted">
{candidates.length > 1 && (
<span className="inline-flex items-center gap-1">
<button
type="button"
onClick={prev}
aria-label="上一候答"
className="rounded px-1.5 py-0.5 hover:bg-overlay-hover"
>
‹
</button>
<span aria-live="polite">
候答 {index + 1}/{candidates.length}
</span>
<button
type="button"
onClick={next}
aria-label="下一候答"
className="rounded px-1.5 py-0.5 hover:bg-overlay-hover"
>
›
</button>
</span>
)}
<span
className={cn(
"rounded px-1.5 py-0.5",
residual && "bg-amber-500/15 text-amber-600 dark:text-amber-400",
!residual && "bg-muted",
)}
>
{STATUS_LABEL[current.status] ?? current.status}
</span>
{isCanonized && (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-emerald-600 dark:text-emerald-400">
已归真
</span>
)}
{isAdopted && !isCanonized && (
<span className="rounded bg-sky-500/15 px-1.5 py-0.5 text-sky-600 dark:text-sky-400">
已采用
</span>
)}
</div>
<div
role="button"
tabIndex={0}
onClick={() => onSelect(current.id)}
onKeyDown={(e) => e.key === "Enter" && onSelect(current.id)}
className={cn(
"cursor-pointer rounded-xl border bg-surface p-4 text-sm leading-relaxed shadow-sm transition-colors",
shownMismatch ? "border-dashed border-rule" : "border-rule",
residual && "opacity-80",
)}
>
{current.content || "(空)"}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => onRegenerate(question.id)}
>
另答
</Button>
{solidified && (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => onContinue(current.id)}
>
续思
</Button>
)}
{residual && (
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => onResume(current.id)}
>
续生
</Button>
)}
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => onEditFork(current.id, current.content)}
>
编辑分叉
</Button>
{solidified && !isAdopted && (
<Button
variant="secondary"
size="sm"
disabled={busy}
onClick={() => onAdopt(current.id)}
>
采用
</Button>
)}
{solidified && !isCanonized && (
<Button
variant="primary"
size="sm"
disabled={busy}
onClick={() => onCanonize(current.id)}
>
归真
</Button>
)}
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
/**
* 发问框:新问节挂当前工作入口(PRD §6.1:继续提问绑定采用的候答,而非最后生成)。
* 界域可选(默认真界),入境有可见标注(§8)。
*/
import { useState } from "react";
import { Button } from "@/components/ui/Button";
import { cn } from "@/lib/utils/cn";
import type { Realm } from "@/types/mirror-tree";
interface AskBoxProps {
busy: boolean;
disabledReason?: string;
onSubmit: (content: string, realm: Realm) => void;
}
const REALMS: { value: Realm; label: string; hint: string }[] = [
{ value: "reality", label: "真界", hint: "正式共同经历" },
{ value: "persistent_world", label: "境界", hint: "世界正史(不入真界)" },
{ value: "simulation", label: "幻境", hint: "试演(不入正式经历)" },
];
export function AskBox({ busy, disabledReason, onSubmit }: AskBoxProps) {
const [content, setContent] = useState("");
const [realm, setRealm] = useState<Realm>("reality");
const submit = () => {
const trimmed = content.trim();
if (!trimmed || busy || disabledReason) return;
onSubmit(trimmed, realm);
setContent("");
};
return (
<div className="space-y-2 rounded-xl border border-rule bg-surface p-3">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) submit();
}}
placeholder={disabledReason ?? "向人物提问……(⌘/Ctrl + Enter 发送)"}
disabled={Boolean(disabledReason) || busy}
rows={3}
className="w-full resize-none rounded-lg border border-rule bg-transparent p-2 text-sm outline-none placeholder:text-muted focus:border-patina"
/>
<div className="flex items-center justify-between gap-2">
<div
className="flex items-center gap-1"
role="radiogroup"
aria-label="界域"
>
{REALMS.map((r) => (
<button
key={r.value}
type="button"
role="radio"
aria-checked={realm === r.value}
title={r.hint}
onClick={() => setRealm(r.value)}
className={cn(
"rounded-full border px-2.5 py-0.5 text-xs transition-colors",
realm === r.value
? "border-patina bg-patina/15 text-patina"
: "border-rule text-muted hover:bg-overlay-hover",
)}
>
{r.label}
</button>
))}
</div>
<Button
variant="primary"
size="sm"
disabled={busy || !content.trim() || Boolean(disabledReason)}
onClick={submit}
>
发问
</Button>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
"use client";
/**
* 芽体块:订阅 Run SSE(llm.delta 流式 / 终态回调),呈现生成中的候答。
* 终态语义(镜树 PRD §9):完成才固化;中断封存为残答,不伪装完成。
*/
import { useEffect, useRef, useState } from "react";
import { createRunStream } from "@/lib/sse-client";
import { cn } from "@/lib/utils/cn";
interface BuddingBlockProps {
runId: string;
/** 终态回调(completed / interrupted / failed / cancelled) */
onFinished: (status: string) => void;
}
export function BuddingBlock({ runId, onFinished }: BuddingBlockProps) {
const [text, setText] = useState("");
const [phase, setPhase] = useState<"streaming" | "sealed" | "done">(
"streaming",
);
const finishedRef = useRef(false);
useEffect(() => {
const controller = createRunStream(runId, {
onEvent: (event) => {
if (event.type === "llm.delta") {
const delta =
(event.payload as { delta?: string } | undefined)?.delta ?? "";
if (delta) setText((t) => t + delta);
return;
}
if (
event.type === "run.finished" ||
event.type === "run.interrupted" ||
event.type === "run.cancelled" ||
event.type === "run.failed" ||
event.type === "run.completed"
) {
if (finishedRef.current) return;
finishedRef.current = true;
const status =
event.type === "run.finished" || event.type === "run.completed"
? "completed"
: event.type === "run.cancelled"
? "cancelled"
: "interrupted";
setPhase(status === "completed" ? "done" : "sealed");
onFinished(status);
}
},
});
return () => controller.close();
}, [runId, onFinished]);
return (
<div className="ml-6 space-y-1">
<span
className={cn(
"inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs",
phase === "streaming" && "bg-patina/15 text-patina",
phase === "sealed" &&
"bg-amber-500/15 text-amber-600 dark:text-amber-400",
phase === "done" &&
"bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
)}
aria-live="polite"
>
<span
className={cn(
"inline-block size-1.5 rounded-full",
phase === "streaming" ? "animate-pulse bg-patina" : "bg-current",
)}
aria-hidden
/>
{phase === "streaming"
? "芽体生成中"
: phase === "sealed"
? "已封存为残答"
: "已固化"}
</span>
<div className="min-h-16 rounded-xl border border-dashed border-rule bg-surface/60 p-4 text-sm leading-relaxed">
{text || <span className="text-muted">等待第一缕回声……</span>}
{phase === "streaming" && (
<span className="ml-0.5 animate-pulse">▍</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,136 @@
"use client";
/**
* 归真对话框:展示作用域及其效力边界(镜树 PRD §5.3)。
* 境界归真须 world_id;与主脉分叉冲突(HJ-1102)时呈现当前主脉端点,不静默替换。
*/
import { useState } from "react";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { cn } from "@/lib/utils/cn";
import type { Realm, TreeNodeView } from "@/types/mirror-tree";
interface CanonizeDialogProps {
open: boolean;
node: TreeNodeView | null;
busy: boolean;
conflictHead?: string | null;
onClose: () => void;
onConfirm: (scope: Realm, worldId?: string) => void;
}
const SCOPES: {
value: Realm;
label: string;
effect: string;
needsWorld: boolean;
}[] = [
{
value: "reality",
label: "真界",
effect: "进入正式共同经历主脉;重忆、缘契、人格改变仍各自确认。",
needsWorld: false,
},
{
value: "persistent_world",
label: "境界",
effect: "进入指定世界正史;不进入真界经历与本相。",
needsWorld: true,
},
{
value: "simulation",
label: "幻境",
effect: "仅记录「正式发生过这次试演」;内容不当作真实发生。",
needsWorld: false,
},
];
export function CanonizeDialog({
open,
node,
busy,
conflictHead,
onClose,
onConfirm,
}: CanonizeDialogProps) {
const [scope, setScope] = useState<Realm>("reality");
const [worldId, setWorldId] = useState("");
const current = SCOPES.find((s) => s.value === scope)!;
return (
<Modal open={open} onClose={busy ? () => undefined : onClose} title="归真">
<div className="space-y-4 text-sm">
<p className="text-muted">
将把截至此候答的路径纳入正式经历。归真是高影响动作,
<strong>切换候答不会回滚已归真的经历</strong>。
</p>
{node && (
<blockquote className="rounded-lg border border-rule bg-surface p-3 text-xs leading-relaxed text-muted">
{node.content.slice(0, 120) || "(空)"}
</blockquote>
)}
<div className="space-y-2" role="radiogroup" aria-label="归真作用域">
{SCOPES.map((s) => (
<button
key={s.value}
type="button"
role="radio"
aria-checked={scope === s.value}
onClick={() => setScope(s.value)}
className={cn(
"w-full rounded-lg border p-3 text-left transition-colors",
scope === s.value
? "border-patina bg-patina/10"
: "border-rule hover:bg-overlay-hover",
)}
>
<span className="font-medium">{s.label}</span>
<span className="mt-1 block text-xs text-muted">{s.effect}</span>
</button>
))}
</div>
{current.needsWorld && (
<label className="block space-y-1">
<span className="text-xs text-muted">世界 ID(境界归真必填)</span>
<input
value={worldId}
onChange={(e) => setWorldId(e.target.value)}
placeholder="world_……"
className="w-full rounded-lg border border-rule bg-transparent px-2 py-1.5 text-sm outline-none focus:border-patina"
/>
</label>
)}
{conflictHead && (
<p
role="alert"
className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-3 text-xs text-amber-700 dark:text-amber-400"
>
归真路径与当前主脉分叉(HJ-1102)。已归真经历不会被静默替换——请先回主脉端点
继续生长,或明确放弃本次归真。
</p>
)}
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={onClose} disabled={busy}>
取消
</Button>
<Button
variant="primary"
size="sm"
disabled={busy || (current.needsWorld && !worldId.trim())}
onClick={() =>
onConfirm(scope, current.needsWorld ? worldId.trim() : undefined)
}
>
确认归真
</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,120 @@
"use client";
/**
* 局部树图面板:服务定位而非阅读(镜树 PRD §10.1)。
* 祖先链实线、兄弟与子枝缩进展示;点击节点切换阅读焦点。
*/
import { useMemo } from "react";
import { cn } from "@/lib/utils/cn";
import type { GraphNodeView } from "@/types/mirror-tree";
interface LocalGraphPanelProps {
nodes: GraphNodeView[];
focusNodeId: string | null;
pathNodeIds: Set<string>;
onSelect: (nodeId: string) => void;
collapsed: boolean;
onToggle: () => void;
}
const TYPE_LABEL: Record<string, string> = {
question: "问",
answer: "答",
continuation: "续",
};
export function LocalGraphPanel({
nodes,
focusNodeId,
pathNodeIds,
onSelect,
collapsed,
onToggle,
}: LocalGraphPanelProps) {
const roots = useMemo(
() => nodes.filter((n) => n.parent_node_id === null),
[nodes],
);
const byParent = useMemo(() => {
const map = new Map<string, GraphNodeView[]>();
for (const n of nodes) {
if (!n.parent_node_id) continue;
const list = map.get(n.parent_node_id) ?? [];
list.push(n);
map.set(n.parent_node_id, list);
}
return map;
}, [nodes]);
if (collapsed) {
return (
<button
type="button"
onClick={onToggle}
className="rounded border border-rule px-2 py-1 text-xs text-muted hover:bg-overlay-hover"
aria-label="展开局部树图"
>
局部树图 ›
</button>
);
}
const renderNode = (node: GraphNodeView, depth: number) => {
const children = byParent.get(node.id) ?? [];
const inPath = pathNodeIds.has(node.id);
return (
<div key={node.id} style={{ marginLeft: depth ? 12 : 0 }}>
<button
type="button"
onClick={() => onSelect(node.id)}
className={cn(
"flex w-full items-center gap-1.5 rounded px-1.5 py-1 text-left text-xs hover:bg-overlay-hover",
node.id === focusNodeId && "bg-patina/15 text-patina",
)}
title={node.content_preview}
>
<span
className={cn(
"inline-flex size-4 shrink-0 items-center justify-center rounded border text-[10px]",
node.node_type === "question" && "border-rule text-muted",
node.node_type !== "question" && inPath
? "border-patina/60 text-patina"
: "border-rule text-muted",
node.status === "sealed_residual" &&
"border-amber-500/60 text-amber-600",
)}
aria-hidden
>
{TYPE_LABEL[node.node_type] ?? "?"}
</span>
<span className="truncate">{node.content_preview || "(空)"}</span>
</button>
{children.map((child) => renderNode(child, depth + 1))}
</div>
);
};
return (
<aside className="w-60 shrink-0 space-y-2 rounded-xl border border-rule bg-surface p-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-medium tracking-wide text-muted">
局部树图
</h3>
<button
type="button"
onClick={onToggle}
className="rounded px-1.5 text-xs text-muted hover:bg-overlay-hover"
aria-label="收起局部树图"
>
‹
</button>
</div>
<div className="space-y-0.5 overflow-y-auto">
{roots.length === 0 && <p className="text-xs text-muted">尚无节点</p>}
{roots.map((root) => renderNode(root, 0))}
</div>
</aside>
);
}
@@ -0,0 +1,68 @@
"use client";
/** 新建镜树表单:主题必填,默认界域真界。 */
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/Button";
interface TreeCreateFormProps {
characterId: string;
}
export function TreeCreateForm({ characterId }: TreeCreateFormProps) {
const router = useRouter();
const [title, setTitle] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async () => {
const trimmed = title.trim();
if (!trimmed || busy) return;
setBusy(true);
setError(null);
try {
const { createTree } = await import("@/lib/api/trees");
const tree = await createTree({
character_id: characterId,
title: trimmed,
});
router.push(`/trees/${tree.id}`);
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败");
setBusy(false);
}
};
return (
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
void submit();
}}
>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="新树主题,如「关于守护的边界」"
aria-label="新树主题"
className="flex-1 rounded-lg border border-rule bg-surface px-3 py-2 text-sm outline-none placeholder:text-muted focus:border-patina"
/>
<Button
type="submit"
variant="primary"
size="sm"
disabled={busy || !title.trim()}
>
建树
</Button>
{error && (
<span role="alert" className="text-xs text-red-500">
{error}
</span>
)}
</form>
);
}
@@ -0,0 +1,431 @@
"use client";
/**
* 镜树工作区:当前路径阅读流 + 候答交互 + 局部树图 + 发问。
*
* 数据流:path(祖先链阅读)与 local-graph(兄弟候答/子枝)双源;
* 生成中的候答以芽体块订阅 Run SSE,终态后统一刷新两源。
*/
import { useCallback, useMemo, useState } from "react";
import { AnswerBlock } from "@/components/trees/AnswerBlock";
import { AskBox } from "@/components/trees/AskBox";
import { BuddingBlock } from "@/components/trees/BuddingBlock";
import { CanonizeDialog } from "@/components/trees/CanonizeDialog";
import { LocalGraphPanel } from "@/components/trees/LocalGraphPanel";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { ApiError } from "@/lib/api/client";
import * as treesApi from "@/lib/api/trees";
import { cn } from "@/lib/utils/cn";
import type {
GraphResponse,
MirrorTreeView,
PathResponse,
Realm,
TreeNodeView,
} from "@/types/mirror-tree";
interface TreeWorkspaceProps {
tree: MirrorTreeView;
initialPath: PathResponse;
initialGraph: GraphResponse;
}
interface BuddingTarget {
runId: string;
parentNodeId: string;
hint: string;
}
const REALM_LABEL: Record<string, string> = {
reality: "真界",
persistent_world: "境界",
simulation: "幻境",
};
export function TreeWorkspace({
tree,
initialPath,
initialGraph,
}: TreeWorkspaceProps) {
const [path, setPath] = useState(initialPath);
const [graph, setGraph] = useState(initialGraph);
const [workingEntry, setWorkingEntry] = useState(tree.working_entry_node_id);
const [busy, setBusy] = useState(false);
const [budding, setBudding] = useState<BuddingTarget | null>(null);
const [canonizeNode, setCanonizeNode] = useState<TreeNodeView | null>(null);
const [conflictHead, setConflictHead] = useState<string | null>(null);
const [editTarget, setEditTarget] = useState<{
nodeId: string;
content: string;
} | null>(null);
const [editValue, setEditValue] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [graphCollapsed, setGraphCollapsed] = useState(false);
const refresh = useCallback(
async (entry?: string | null) => {
const focus =
entry === undefined ? (path.entry_node_id ?? workingEntry) : entry;
const [nextPath, nextGraph] = await Promise.all([
treesApi.treePath(tree.id, focus),
treesApi.localGraph(tree.id, focus),
]);
setPath(nextPath);
setGraph(nextGraph);
},
[tree.id, path.entry_node_id, workingEntry],
);
/** 每个问节的兄弟候答(来自图数据:同问节下的 answer 节点) */
const candidatesByQuestion = useMemo(() => {
const map = new Map<string, TreeNodeView[]>();
const pathNodes = new Map(path.nodes.map((n) => [n.id, n]));
for (const gn of graph.nodes) {
if (gn.node_type !== "answer" && gn.node_type !== "continuation")
continue;
const parent = gn.parent_node_id;
if (!parent) continue;
const list = map.get(parent) ?? [];
// 阅读链上的候答有完整数据;旁枝以 path 节点或图摘要构造
const full =
pathNodes.get(gn.id) ??
({
id: gn.id,
tree_id: tree.id,
parent_node_id: parent,
node_type: gn.node_type,
status: gn.status,
author: "character",
content: gn.content_preview,
realm: gn.realm,
generation_meta: {},
supersedes_node_id: null,
citations: [],
created_at: "",
} satisfies TreeNodeView);
list.push(full);
}
for (const list of map.values()) {
list.sort((a, b) =>
a.created_at && b.created_at
? a.created_at.localeCompare(b.created_at)
: 0,
);
}
return map;
}, [graph.nodes, path.nodes, tree.id]);
const chainAnswerByQuestion = useMemo(() => {
const map = new Map<string, TreeNodeView>();
for (const node of path.nodes) {
if (node.parent_node_id) map.set(node.parent_node_id, node);
}
return map;
}, [path.nodes]);
const runGeneration = useCallback(
async (
spawn: Promise<{ node_id: string; run_id: string }>,
parentNodeId: string,
hint: string,
) => {
setBusy(true);
try {
const result = await spawn;
setBudding({ runId: result.run_id, parentNodeId, hint });
} catch (e) {
setMessage(e instanceof ApiError ? e.message : "发起生成失败");
} finally {
setBusy(false);
}
},
[],
);
const onAsk = useCallback(
async (content: string, realm: Realm) => {
setBusy(true);
try {
const question = await treesApi.createQuestion(tree.id, {
content,
realm,
});
await runGeneration(
treesApi.spawnAnswer(question.id),
question.id,
"首次作答",
);
await refresh(question.id);
} catch (e) {
setMessage(e instanceof ApiError ? e.message : "发问失败");
setBusy(false);
}
},
[tree.id, refresh, runGeneration],
);
const onBuddingFinished = useCallback(async () => {
const entry = path.entry_node_id;
setBudding(null);
await refresh(entry);
}, [path.entry_node_id, refresh]);
const onAdopt = useCallback(
async (nodeId: string) => {
setBusy(true);
try {
const result = await treesApi.adoptNode(tree.id, nodeId);
setWorkingEntry(result.working_entry_node_id);
await refresh(nodeId);
} catch (e) {
setMessage(e instanceof ApiError ? e.message : "采用失败");
} finally {
setBusy(false);
}
},
[tree.id, refresh],
);
const onCanonizeConfirm = useCallback(
async (scope: Realm, worldId?: string) => {
if (!canonizeNode) return;
setBusy(true);
setConflictHead(null);
try {
await treesApi.canonize(tree.id, {
endpoint_node_id: canonizeNode.id,
canonical_scope: scope,
world_id: worldId || undefined,
});
setCanonizeNode(null);
await refresh();
setMessage("已归真");
} catch (e) {
if (e instanceof ApiError && e.code === "HJ-1102") {
setConflictHead(String(e.details?.current_trunk_head ?? ""));
} else {
setMessage(e instanceof ApiError ? e.message : "归真失败");
}
} finally {
setBusy(false);
}
},
[canonizeNode, tree.id, refresh],
);
const onEditForkSubmit = useCallback(async () => {
if (!editTarget || !editValue.trim()) return;
setBusy(true);
try {
await treesApi.editFork(editTarget.nodeId, editValue.trim());
setEditTarget(null);
setEditValue("");
await refresh();
} catch (e) {
setMessage(e instanceof ApiError ? e.message : "编辑分叉失败");
} finally {
setBusy(false);
}
}, [editTarget, editValue, refresh]);
const pathNodeIds = useMemo(
() => new Set(path.nodes.map((n) => n.id)),
[path.nodes],
);
const askDisabledReason = budding
? "生成中,请候芽体固化"
: !workingEntry && path.nodes.length > 0
? undefined
: undefined;
return (
<div className="space-y-4">
<header className="flex items-baseline justify-between gap-4">
<div>
<h1 className="text-lg font-semibold">{tree.title}</h1>
<p className="text-xs text-muted">
工作入口:
{workingEntry
? (path.nodes
.find((n) => n.id === workingEntry)
?.content.slice(0, 24) ?? "…")
: "尚未采用候答"}
</p>
</div>
{message && (
<p role="status" className="text-xs text-muted">
{message}
<button
type="button"
className="ml-2 underline"
onClick={() => setMessage(null)}
>
收起
</button>
</p>
)}
</header>
<div className="flex gap-4">
<LocalGraphPanel
nodes={graph.nodes}
focusNodeId={path.entry_node_id}
pathNodeIds={pathNodeIds}
collapsed={graphCollapsed}
onToggle={() => setGraphCollapsed((v) => !v)}
onSelect={async (nodeId) => {
setBusy(true);
try {
await refresh(nodeId);
} finally {
setBusy(false);
}
}}
/>
<div className="min-w-0 flex-1 space-y-6">
{path.nodes.length === 0 && (
<p className="rounded-xl border border-dashed border-rule p-6 text-center text-sm text-muted">
这棵树还是空的——发出第一个问题,让它开始生长。
</p>
)}
{path.nodes.map((node) =>
node.node_type === "question" ? (
<div key={node.id} className="space-y-2">
<div className="flex items-start justify-end gap-2">
<div className="max-w-[80%] rounded-xl border border-rule bg-surface px-4 py-3 text-sm leading-relaxed">
{node.content}
</div>
<span className="mt-1 shrink-0 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted">
{REALM_LABEL[node.realm] ?? node.realm}
</span>
</div>
<AnswerBlock
question={node}
candidates={candidatesByQuestion.get(node.id) ?? []}
selectedId={chainAnswerByQuestion.get(node.id)?.id ?? null}
workingEntryId={workingEntry}
canonizedIds={new Set()} // 归真标记随 M3 记忆闭环接入回显
busy={busy}
onSelect={async (id) => {
setBusy(true);
try {
await refresh(id);
} finally {
setBusy(false);
}
}}
onRegenerate={(qid) =>
runGeneration(treesApi.spawnAnswer(qid), qid, "另答")
}
onContinue={(id) =>
runGeneration(treesApi.continueNode(id), id, "续思")
}
onResume={(id) =>
runGeneration(treesApi.resumeNode(id), id, "续生")
}
onEditFork={(id, content) => {
setEditTarget({ nodeId: id, content });
setEditValue(content);
}}
onAdopt={onAdopt}
onCanonize={(id) => {
setConflictHead(null);
const full = path.nodes.find((n) => n.id === id) ?? null;
setCanonizeNode(full);
}}
/>
{budding?.parentNodeId === node.id && (
<BuddingBlock
runId={budding.runId}
onFinished={onBuddingFinished}
/>
)}
</div>
) : (
// 续思延展节点(链上非候答直接呈现)
<div key={node.id} className="ml-6">
{budding?.parentNodeId === node.id && (
<BuddingBlock
runId={budding.runId}
onFinished={onBuddingFinished}
/>
)}
{node.status === "solidified" && (
<div className="rounded-xl border border-rule bg-surface/60 p-4 text-sm leading-relaxed">
<span className="mr-2 rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted">
续
</span>
{node.content}
</div>
)}
</div>
),
)}
<AskBox
busy={busy || Boolean(budding)}
disabledReason={askDisabledReason ?? undefined}
onSubmit={onAsk}
/>
</div>
</div>
<CanonizeDialog
open={Boolean(canonizeNode)}
node={canonizeNode}
busy={busy}
conflictHead={conflictHead}
onClose={() => {
setCanonizeNode(null);
setConflictHead(null);
}}
onConfirm={onCanonizeConfirm}
/>
<Modal
open={Boolean(editTarget)}
onClose={() => setEditTarget(null)}
title="编辑分叉"
size="md"
>
<div className="space-y-3 text-sm">
<p className="text-muted">
将创建新版本并从这里分叉;原内容与后代保留可访问。
</p>
<textarea
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
rows={6}
className="w-full resize-none rounded-lg border border-rule bg-transparent p-2 text-sm outline-none focus:border-patina"
/>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setEditTarget(null)}
>
取消
</Button>
<Button
variant="primary"
size="sm"
disabled={busy || !editValue.trim()}
onClick={onEditForkSubmit}
>
创建分叉
</Button>
</div>
</div>
</Modal>
<p className={cn("sr-only")} aria-live="polite">
{busy ? "处理中" : ""}
</p>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiRequest: vi.fn(),
apiGet: vi.fn(),
apiPatch: vi.fn(),
apiPost: vi.fn(),
}));
import { apiRequest, apiGet, apiPost } from "./client";
import {
activateEmbryo,
importCard,
listEmbryos,
rehearseEmbryo,
} from "./embryos";
describe("embryos api", () => {
it("listEmbryos GET /embryo-drafts", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await listEmbryos();
expect(apiGet).toHaveBeenCalledWith("/embryo-drafts");
});
it("rehearseEmbryo POST /embryo-drafts/:id/rehearse", async () => {
vi.mocked(apiPost).mockResolvedValue({
run_id: "run_1",
kind: "rehearsal",
});
await rehearseEmbryo("embryo_1", "你是谁");
expect(apiPost).toHaveBeenCalledWith("/embryo-drafts/embryo_1/rehearse", {
prompt: "你是谁",
});
});
it("activateEmbryo POST /embryo-drafts/:id/activate", async () => {
vi.mocked(apiPost).mockResolvedValue({ character_id: "character_1" });
await activateEmbryo("embryo_1");
expect(apiPost).toHaveBeenCalledWith("/embryo-drafts/embryo_1/activate");
});
it("importCard 以 FormData POST /characters/import-card", async () => {
vi.mocked(apiRequest).mockResolvedValue({ embryo_id: "embryo_2" });
const file = new File(["{}"], "card.json", { type: "application/json" });
await importCard(file);
expect(apiRequest).toHaveBeenCalledWith(
"/characters/import-card",
expect.objectContaining({ method: "POST", body: expect.any(FormData) }),
);
});
});
+68
View File
@@ -0,0 +1,68 @@
/** 灵胚 API Client(api-contracts §3.2)。 */
import { apiRequest } from "./client";
import { apiGet, apiPatch, apiPost } from "./client";
import type { EmbryoDraftView, ImportCardResult } from "@/types/mirror-tree";
export function listEmbryos() {
return apiGet<EmbryoDraftView[]>("/embryo-drafts");
}
export function getEmbryo(embryoId: string) {
return apiGet<EmbryoDraftView>(`/embryo-drafts/${embryoId}`);
}
export function createEmbryo(body: {
source: string;
name: string;
description?: string;
identity_baseline?: Record<string, unknown>;
}) {
return apiPost<EmbryoDraftView>("/embryo-drafts", body);
}
export function patchEmbryo(
embryoId: string,
body: {
name?: string;
description?: string;
identity_baseline?: Record<string, unknown>;
},
) {
return apiPatch<EmbryoDraftView>(`/embryo-drafts/${embryoId}`, body);
}
export function rehearseEmbryo(embryoId: string, prompt: string) {
return apiPost<{ run_id: string; kind: string }>(
`/embryo-drafts/${embryoId}/rehearse`,
{
prompt,
},
);
}
export function activateEmbryo(embryoId: string) {
return apiPost<{
character_id: string;
name: string;
status: string;
source: string;
identity_record_version: number;
}>(`/embryo-drafts/${embryoId}/activate`);
}
export function abandonEmbryo(embryoId: string) {
return apiRequest<void>(`/embryo-drafts/${embryoId}/abandon`, {
method: "POST",
});
}
/** 导入 ST 角色卡(.png/.json/.charx)→ 产出可审阅灵胚。 */
export function importCard(file: File) {
const form = new FormData();
form.append("file", file);
return apiRequest<ImportCardResult>("/characters/import-card", {
method: "POST",
body: form,
});
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPatch: vi.fn(),
apiPost: vi.fn(),
}));
import { apiGet, apiPatch, apiPost } from "./client";
import {
adoptNode,
archiveTree,
canonize,
createQuestion,
localGraph,
spawnAnswer,
treePath,
} from "./trees";
describe("trees api", () => {
it("treePath GET /trees/:id/path(无 entry 时省略查询)", async () => {
vi.mocked(apiGet).mockResolvedValue({
tree_id: "tree_1",
entry_node_id: null,
nodes: [],
});
await treePath("tree_1");
expect(apiGet).toHaveBeenCalledWith("/trees/tree_1/path", undefined);
await treePath("tree_1", "node_9");
expect(apiGet).toHaveBeenCalledWith("/trees/tree_1/path", {
entry: "node_9",
});
});
it("localGraph GET /trees/:id/local-graph 携带深度", async () => {
vi.mocked(apiGet).mockResolvedValue({
tree_id: "tree_1",
focus_node_id: null,
nodes: [],
});
await localGraph("tree_1", "node_1");
expect(apiGet).toHaveBeenCalledWith("/trees/tree_1/local-graph", {
node: "node_1",
depth: 3,
});
});
it("createQuestion POST /trees/:id/nodes", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "node_q" });
await createQuestion("tree_1", { content: "问", realm: "simulation" });
expect(apiPost).toHaveBeenCalledWith("/trees/tree_1/nodes", {
content: "问",
realm: "simulation",
});
});
it("spawnAnswer POST /nodes/:id/answers", async () => {
vi.mocked(apiPost).mockResolvedValue({
node_id: "node_a",
run_id: "run_1",
});
await spawnAnswer("node_q", "指引");
expect(apiPost).toHaveBeenCalledWith("/nodes/node_q/answers", {
instructions: "指引",
});
});
it("adoptNode POST /trees/:id/adopt", async () => {
vi.mocked(apiPost).mockResolvedValue({
tree_id: "tree_1",
working_entry_node_id: "node_a",
});
await adoptNode("tree_1", "node_a");
expect(apiPost).toHaveBeenCalledWith("/trees/tree_1/adopt", {
node_id: "node_a",
});
});
it("canonize POST /trees/:id/canonize(境界带 world_id)", async () => {
vi.mocked(apiPost).mockResolvedValue({ canonization_id: "canon_1" });
await canonize("tree_1", {
endpoint_node_id: "node_a",
canonical_scope: "persistent_world",
world_id: "world_1",
});
expect(apiPost).toHaveBeenCalledWith("/trees/tree_1/canonize", {
endpoint_node_id: "node_a",
canonical_scope: "persistent_world",
world_id: "world_1",
});
});
it("archiveTree PATCH /trees/:id status=archived", async () => {
vi.mocked(apiPatch).mockResolvedValue({ id: "tree_1", status: "archived" });
await archiveTree("tree_1");
expect(apiPatch).toHaveBeenCalledWith("/trees/tree_1", {
status: "archived",
});
});
});
+102
View File
@@ -0,0 +1,102 @@
/** 镜树 API Client(api-contracts §3.3)。 */
import { apiGet, apiPatch, apiPost } from "./client";
import type {
CanonizeRequest,
CanonizeResult,
GraphResponse,
MirrorTreeView,
PathResponse,
SpawnResult,
TransplantResult,
TreeNodeView,
} from "@/types/mirror-tree";
export function listTrees(characterId?: string) {
return apiGet<MirrorTreeView[]>(
"/trees",
characterId ? { character_id: characterId } : undefined,
);
}
export function getTree(treeId: string) {
return apiGet<MirrorTreeView>(`/trees/${treeId}`);
}
export function createTree(body: {
character_id: string;
title: string;
default_realm?: string;
}) {
return apiPost<MirrorTreeView>("/trees", body);
}
export function archiveTree(treeId: string) {
return apiPatch<MirrorTreeView>(`/trees/${treeId}`, { status: "archived" });
}
export function treePath(treeId: string, entryNodeId?: string | null) {
return apiGet<PathResponse>(
`/trees/${treeId}/path`,
entryNodeId ? { entry: entryNodeId } : undefined,
);
}
export function localGraph(treeId: string, nodeId?: string | null, depth = 3) {
return apiGet<GraphResponse>(`/trees/${treeId}/local-graph`, {
node: nodeId ?? undefined,
depth,
});
}
export function createQuestion(
treeId: string,
body: { content: string; realm?: string; parent_answer_id?: string },
) {
return apiPost<TreeNodeView>(`/trees/${treeId}/nodes`, body);
}
export function spawnAnswer(nodeId: string, instructions?: string) {
return apiPost<SpawnResult>(`/nodes/${nodeId}/answers`, { instructions });
}
export function continueNode(nodeId: string, instructions?: string) {
return apiPost<SpawnResult>(`/nodes/${nodeId}/continue`, { instructions });
}
export function resumeNode(nodeId: string, instructions?: string) {
return apiPost<SpawnResult>(`/nodes/${nodeId}/resume`, { instructions });
}
export function editFork(nodeId: string, newContent: string) {
return apiPost<{ node_id: string; supersedes_node_id: string }>(
`/nodes/${nodeId}/edit-fork`,
{
new_content: newContent,
},
);
}
export function adoptNode(treeId: string, nodeId: string) {
return apiPost<{ tree_id: string; working_entry_node_id: string }>(
`/trees/${treeId}/adopt`,
{
node_id: nodeId,
},
);
}
export function canonize(treeId: string, body: CanonizeRequest) {
return apiPost<CanonizeResult>(`/trees/${treeId}/canonize`, body);
}
export function transplantBranch(
treeId: string,
nodeId: string,
newTitle: string,
) {
return apiPost<TransplantResult>(`/trees/${treeId}/transplant`, {
node_id: nodeId,
new_title: newTitle,
});
}
+6
View File
@@ -121,6 +121,12 @@ export const ERROR_CODE_LABEL: Record<ErrorCode, string> = {
safety_rejected: "被安全策略拒绝",
internal_error: "服务内部错误",
not_implemented: "该能力尚未支持",
"HJ-1101": "命台正被占用,请求已排队",
"HJ-1102": "归真路径与主脉分叉,需先处理分歧",
"HJ-1103": "当前状态不允许此操作",
"HJ-1301": "禁域裁决拒绝",
"HJ-2001": "无可用模型端点",
"HJ-9000": "系统不变量校验失败",
};
/** 九枢九象名(契约字典 §5.9),星盘标签与速览共用 */
+8 -1
View File
@@ -129,7 +129,14 @@ export type ErrorCode =
| "tool_error"
| "safety_rejected"
| "internal_error"
| "not_implemented";
| "not_implemented"
/** rewrite/1.0 契约错误码段(契约词典 §15),按需扩充 */
| "HJ-1101"
| "HJ-1102"
| "HJ-1103"
| "HJ-1301"
| "HJ-2001"
| "HJ-9000";
/** 九枢模块标识(契约字典 §5.9),取值与九象工程映射一致 */
export type Pivot =
+130
View File
@@ -0,0 +1,130 @@
/** 镜树域类型(契约词典 §9 / api-contracts §3.3,snake_case 为 API 边界)。 */
export type NodeType = "question" | "answer" | "continuation";
export type NodeStatus = "budding" | "sealed_residual" | "solidified";
export type Realm = "reality" | "persistent_world" | "simulation";
export type TreeStatus = "active" | "archived";
export interface CitationBrief {
id: string;
target_type: string;
snapshot_digest: string;
source_id: string;
source_version: number;
}
export interface TreeNodeView {
id: string;
tree_id: string;
parent_node_id: string | null;
node_type: NodeType;
status: NodeStatus;
author: "user" | "character";
content: string;
realm: Realm;
generation_meta: { run_id?: string; [key: string]: unknown };
supersedes_node_id: string | null;
citations: CitationBrief[];
created_at: string;
}
export interface MirrorTreeView {
id: string;
character_id: string;
title: string;
status: TreeStatus;
default_realm: Realm;
working_entry_node_id: string | null;
transplanted_from: { source_tree_id?: string; source_node_id?: string };
created_at: string;
}
export interface PathResponse {
tree_id: string;
entry_node_id: string | null;
nodes: TreeNodeView[];
}
export interface GraphNodeView {
id: string;
parent_node_id: string | null;
node_type: NodeType;
status: NodeStatus;
realm: Realm;
branch_mark: boolean;
content_preview: string;
}
export interface GraphResponse {
tree_id: string;
focus_node_id: string | null;
nodes: GraphNodeView[];
}
export interface CanonizeRequest {
endpoint_node_id: string;
canonical_scope: Realm;
world_id?: string;
}
export interface CanonizeResult {
canonization_id: string;
scope: Realm;
endpoint_node_id: string;
path_length: number;
}
export interface SpawnResult {
node_id: string;
run_id: string;
}
export interface TransplantResult {
new_tree_id: string;
working_entry_node_id: string;
}
export interface CitationDetail extends CitationBrief {
node_id: string;
locator: Record<string, unknown>;
snapshot: string;
newer_version_available: boolean;
}
/** 灵胚域类型(契约词典 §7 / api-contracts §3.2)。 */
export type EmbryoStatus =
| "drafting"
| "previewing"
| "confirming"
| "activated"
| "abandoned";
export type EmbryoSource = "original" | "adopted" | "imported" | "serendipity";
export interface EmbryoRehearsal {
run_id: string;
prompt: string;
performed_at: string;
}
export interface EmbryoDraftView {
id: string;
status: EmbryoStatus;
source: EmbryoSource;
name: string;
description: string | null;
identity_baseline: Record<string, unknown>;
rehearsals: EmbryoRehearsal[];
created_at: string;
updated_at: string;
}
export interface ImportCardResult {
embryo_id: string;
status: EmbryoStatus;
name: string;
source: EmbryoSource;
book_entry_count: number;
next: string;
}