M2c:前端镜树页(路径阅读流/候答切换/采用归真/局部树图/SSE 芽体)与灵胚页(创建/导入卡/照心试演/启镜),契约错误码段接入前端
This commit is contained in:
1 parent
30447fc40f
commit
2a640a901b
21 files changed
+2126
-4
No files matched your search
@@ -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. 能力行模板
|
||||
|
||||
@@ -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 记忆闭环(息壤)
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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),星盘标签与速览共用 */
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user