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/超时取消已有;设备安全与双裁决待扩 |
|
| 乾坤沙箱 | M5 | 局部实现 | 隔离执行/HMAC/超时取消已有;设备安全与双裁决待扩 |
|
||||||
| 博闻 MCP 搜索 | M5 | 局部实现 | 多引擎/密钥池/熔断/缓存可用;待登记入百工 |
|
| 博闻 MCP 搜索 | M5 | 局部实现 | 多引擎/密钥池/熔断/缓存可用;待登记入百工 |
|
||||||
| 天目 / 元炁 | M4/M7 | 局部实现 | 服务可用;元炁待接入神识 Endpoint,天目待接通感 |
|
| 天目 / 元炁 | 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 快照 |
|
| 导入(角色卡 V2/V3 → 灵胚) | M2 | 局部实现 | M2b 已验证(2026-08-21):POST /characters/import-card 产出可审阅灵胚(identity_baseline.import_card 暂存 character_book),启镜时物化世谱书+条目(dict/list 双形态兼容);导出方向保持人物→ST V3 快照 |
|
||||||
|
|
||||||
## 3. 能力行模板
|
## 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。
|
- 证据(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`)。
|
- 证据(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 重接。
|
- 提前退役说明: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 记忆闭环(息壤)
|
### 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 {
|
import {
|
||||||
AdminIcon,
|
AdminIcon,
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
|
ChatIcon,
|
||||||
CharacterIcon,
|
CharacterIcon,
|
||||||
CosmosIcon,
|
CosmosIcon,
|
||||||
DriveIcon,
|
DriveIcon,
|
||||||
@@ -51,11 +52,15 @@ interface NavGroup {
|
|||||||
const NAV_GROUPS: NavGroup[] = [
|
const NAV_GROUPS: NavGroup[] = [
|
||||||
{
|
{
|
||||||
title: "灵台",
|
title: "灵台",
|
||||||
items: [{ href: "/runs", label: "轨迹", icon: <RunIcon /> }],
|
items: [
|
||||||
|
{ href: "/trees", label: "镜树", icon: <ChatIcon /> },
|
||||||
|
{ href: "/runs", label: "轨迹", icon: <RunIcon /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "法身",
|
title: "法身",
|
||||||
items: [
|
items: [
|
||||||
|
{ href: "/embryos", label: "灵胚", icon: <CharacterIcon /> },
|
||||||
{ href: "/cosmos", label: "灵枢", icon: <CosmosIcon /> },
|
{ href: "/cosmos", label: "灵枢", icon: <CosmosIcon /> },
|
||||||
{ href: "/wuxing", label: "五行", icon: <WuxingIcon /> },
|
{ href: "/wuxing", label: "五行", icon: <WuxingIcon /> },
|
||||||
{ href: "/characters", label: "人物", icon: <CharacterIcon /> },
|
{ 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: "被安全策略拒绝",
|
safety_rejected: "被安全策略拒绝",
|
||||||
internal_error: "服务内部错误",
|
internal_error: "服务内部错误",
|
||||||
not_implemented: "该能力尚未支持",
|
not_implemented: "该能力尚未支持",
|
||||||
|
"HJ-1101": "命台正被占用,请求已排队",
|
||||||
|
"HJ-1102": "归真路径与主脉分叉,需先处理分歧",
|
||||||
|
"HJ-1103": "当前状态不允许此操作",
|
||||||
|
"HJ-1301": "禁域裁决拒绝",
|
||||||
|
"HJ-2001": "无可用模型端点",
|
||||||
|
"HJ-9000": "系统不变量校验失败",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 九枢九象名(契约字典 §5.9),星盘标签与速览共用 */
|
/** 九枢九象名(契约字典 §5.9),星盘标签与速览共用 */
|
||||||
|
|||||||
@@ -129,7 +129,14 @@ export type ErrorCode =
|
|||||||
| "tool_error"
|
| "tool_error"
|
||||||
| "safety_rejected"
|
| "safety_rejected"
|
||||||
| "internal_error"
|
| "internal_error"
|
||||||
| "not_implemented";
|
| "not_implemented"
|
||||||
|
/** rewrite/1.0 契约错误码段(契约词典 §15),按需扩充 */
|
||||||
|
| "HJ-1101"
|
||||||
|
| "HJ-1102"
|
||||||
|
| "HJ-1103"
|
||||||
|
| "HJ-1301"
|
||||||
|
| "HJ-2001"
|
||||||
|
| "HJ-9000";
|
||||||
|
|
||||||
/** 九枢模块标识(契约字典 §5.9),取值与九象工程映射一致 */
|
/** 九枢模块标识(契约字典 §5.9),取值与九象工程映射一致 */
|
||||||
export type Pivot =
|
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