M6:缘契、人格与世界——契约版本链、铸魂分灵、河图境界与三界分流

- 缘契域 domains/yuanqi:covenants(当前版)+ covenant_events(不可变历史);提议—问心—铭契—撤回全链;边界变更立即生效;多维关系并存,服务层拒绝 affinity/好感度单一键;浪漫维度成人资格标记位
- 道体铸魂与分灵 domains/jingsheng/soul.py:soul_forgings 确认流(锚点变更 pending→confirmed 应用并签发新魂籍版本,确认前人物锚点不变);分灵创建新人物(继承资料与谱系 lineage 链、不继承原身份,魂籍从 v1 起)
- 河图域 domains/hetu:persistent_worlds 境界 + world_entities/world_relations 境界作用域重建 + 世谱挂接;境界正史视图(scope=persistent_world 归真集合);canonize 后置推进正史计数
- 三界分流:reality→息壤提取(不变);persistent_world→境界实录+正史推进;simulation→只记试演实录;factual_records 增 scope 列——境界/幻境实录与真界本相隔离
- 正影打通:引文 target_type=memory 时被引记忆 superseded → 返回 successor_memory_id 提示,不静默替换
- 前端:人物详情页缘契面板(提议维度选择/铭契/撤回/事件史)+ 世界页重建(境界目录/实体/正史概览)+ covenants/worlds api 库
- 退役:旧 personality/drive/evolution/world 模块、服务、端点、仓储、schema、前端五页与组件样式;迁移 007 退役九张旧表与 characters.current_personality_version_id
- 验收(test_m6_domains 六用例):多维无单一好感度/铭契版本链/事件不可抹除/撤回立即生效/边界即时/铸魂确认流+魂籍版本/分灵谱系/境界正史不进真界(实录 scope 隔离+真界候选=0)/引文正影后继
- 四套件:后端 379、前端 199、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 007 净库+±1 干净
This commit is contained in:
chenxin committed 2026-08-27 20:10:11 +08:00
1 parent 684261f4b4
commit 7e23d6fa84
108 files changed
+2600 -9732

No files matched your search

@@ -2,6 +2,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { CharacterIdentityCard } from "@/components/characters/CharacterIdentityCard";
import { CovenantPanel } from "@/components/characters/CovenantPanel";
import { CharacterLifecycleBar } from "@/components/characters/CharacterLifecycleBar";
import { ModelPolicyEditForm } from "@/components/characters/ModelPolicyEditForm";
import { ProfileEditForm } from "@/components/characters/ProfileEditForm";
@@ -12,6 +13,7 @@ import { CharacterStatusBadge } from "@/components/ui/StatusBadge";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import { formatDateTime } from "@/lib/utils/format";
import type { Covenant } from "@/lib/api/covenants";
import type { Character } from "@/types/api";
export const dynamic = "force-dynamic";
@@ -28,12 +30,21 @@ export default async function CharacterDetailPage({
const { characterId } = await params;
let character = null;
let covenant: Covenant | null = null;
let error: string | null = null;
try {
character = await serverApi.get<Character>(`/characters/${characterId}`);
} catch (e) {
error = describeError(e);
}
// 缘契独立拉取,失败静默(无缘契是合法状态)
try {
covenant = await serverApi.get<Covenant | null>(
`/covenants/by-character/${characterId}`,
);
} catch {
covenant = null;
}
if (error) {
return (
@@ -62,6 +73,10 @@ export default async function CharacterDetailPage({
<CharacterIdentityCard character={character} />
<div className="mt-6">
<CovenantPanel characterId={character.id} initialCovenant={covenant} />
</div>
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<ProfileEditForm character={character} />
<ModelPolicyEditForm character={character} />
File diff suppressed because it is too large. Load diff
-244
View File
@@ -1,244 +0,0 @@
import { DriveView } from "@/components/drive/DriveView";
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 { GOAL_TYPE_LABEL, GOAL_STATUS_LABEL } from "@/lib/constants/labels";
import type {
Character,
CuriosityQuestion,
ExploratorySubgoal,
GoalNode,
Paginated,
} from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 内驱力页 · 大周天生成的好奇问题与探索性次生目标 + 太一目标进度总览。
* 设计稿 drive-preview.html — 太一目标 + 好奇萤火 + 探索天平 + 阴阳制衡。
*/
export default async function DrivePage() {
let characterId: string | null = null;
let characterName = "";
let goals: GoalNode[] = [];
let curiosities: CuriosityQuestion[] = [];
let subgoals: ExploratorySubgoal[] = [];
let counts = { candidate: 0, approved: 0, deferred: 0, rejected: 0 };
let error: string | null = null;
try {
const active = await serverApi.get<Character>("/characters/active");
characterId = active.id;
characterName = active.display_name || active.name;
const [goalRes, curios, cand, approved, deferred, rejected] =
await Promise.all([
serverApi
.get<GoalNode[]>("/admin/goals", { character_id: active.id })
.catch(() => [] as GoalNode[]),
serverApi.get<Paginated<CuriosityQuestion>>(
`/characters/${active.id}/curiosities`,
{ page_size: 20 },
),
serverApi.get<Paginated<ExploratorySubgoal>>(
`/characters/${active.id}/subgoals`,
{ status: "candidate", page_size: 50 },
),
serverApi.get<Paginated<ExploratorySubgoal>>(
`/characters/${active.id}/subgoals`,
{ status: "approved", page_size: 1 },
),
serverApi.get<Paginated<ExploratorySubgoal>>(
`/characters/${active.id}/subgoals`,
{ status: "deferred", page_size: 1 },
),
serverApi.get<Paginated<ExploratorySubgoal>>(
`/characters/${active.id}/subgoals`,
{ status: "rejected", page_size: 1 },
),
]);
goals = goalRes;
curiosities = curios.items;
subgoals = cand.items;
counts = {
candidate: cand.total,
approved: approved.total,
deferred: deferred.total,
rejected: rejected.total,
};
} catch (e) {
error = describeError(e);
}
const totalCurios = curiosities.length;
const totalSubgoals = subgoals.length;
return (
<PageContainer
title="内驱力"
description="大周天 · 好奇与探索"
meta={
<>
{characterName && (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12.5,
color: "var(--patina)",
padding: "3px 11px 3px 4px",
border: "1px solid var(--patina-dim)",
borderRadius: 9999,
background: "var(--patina-wash)",
}}
>
<span
style={{
width: 18,
height: 18,
borderRadius: "50%",
background: "var(--patina)",
color: "var(--ink)",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontFamily: '"Songti SC", serif',
fontSize: 11,
fontWeight: 600,
flexShrink: 0,
}}
>
{characterName.charAt(0)}
</span>
{characterName}
</span>
)}
<span className="page-meta">
好奇 <b>{totalCurios}</b> · 待探 <b>{totalSubgoals}</b> · 已延{" "}
<b>{counts.deferred}</b>
</span>
</>
}
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : characterId ? (
<div className="space-y-5">
{/* 太一 · 目标进度 */}
{goals.length > 0 && (
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 太一{" "}
<span className="sub">最高目标 · 内驱力在其下生成次生探索</span>
</div>
{goals.map((g) => (
<div
key={g.id}
className="taiyi-card"
style={{ marginBottom: 12 }}
>
<div className="taiyi-row">
<span
className={
g.status === "active"
? "taiyi-dot active"
: g.status === "completed"
? "taiyi-dot completed"
: "taiyi-dot"
}
style={{
background:
g.status === "blocked"
? "var(--cinnabar)"
: undefined,
boxShadow:
g.status === "blocked"
? "0 0 0 3px rgba(184,57,46,0.2)"
: undefined,
}}
/>
<span className="taiyi-title">{g.title}</span>
{g.type && (
<span className="taiyi-tag">
{GOAL_TYPE_LABEL[g.type] ?? g.type}
</span>
)}
<span className="taiyi-status">
{GOAL_STATUS_LABEL[g.status] ?? g.status}
</span>
</div>
<div className="taiyi-prog">
<div className="track">
<div
className="fill"
style={{
width: `${Math.round(Math.max(0, Math.min(1, g.progress)) * 100)}%`,
}}
/>
</div>
<span className="tv">
{Math.round(Math.max(0, Math.min(1, g.progress)) * 100)}%
</span>
</div>
{g.description && (
<p className="text-[12.5px] leading-relaxed text-fg-dim/90">
{g.description}
</p>
)}
</div>
))}
</section>
)}
<DriveView
characterId={characterId}
curiosities={curiosities}
subgoals={subgoals}
counts={counts}
/>
{/* 阴阳制衡(太极图) */}
<div className="yin-yang">
<svg className="yy-taiji" viewBox="0 0 200 200" aria-hidden="true">
<circle
cx="100"
cy="100"
r="96"
fill="none"
stroke="var(--gilt-dim)"
strokeWidth="0.8"
/>
<circle cx="100" cy="100" r="88" fill="var(--patina)" />
<path
d="M 100,12 A 88,88 0 0,1 100,188 A 44,44 0 0,1 100,100 A 44,44 0 0,0 100,12 Z"
fill="var(--cinnabar)"
opacity="0.88"
/>
<circle cx="100" cy="144" r="13" fill="var(--patina)" />
<circle
cx="100"
cy="56"
r="13"
fill="var(--cinnabar)"
opacity="0.88"
/>
</svg>
<div className="yy-text">
<b>阴阳制衡</b> · 内驱力持续生成候选(
<span className="yang">阳 · 生发</span>
),司命分级过滤、你按需确认(
<span className="yin">阴 · 约束</span>
)。候选零产生则僵化(阴盛),悉数被驳则阳亢——生成节奏与治理粒度需相称。
<div className="yy-meta">
阴阳平衡度 = 通过率 / (生成率 + ε) · 当前 <b>0.94 · 动态平衡</b>
</div>
</div>
</div>
</div>
) : null}
</PageContainer>
);
}
@@ -1,55 +0,0 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { PersonalityWorkspace } from "@/components/personality/PersonalityWorkspace";
import { EmptyState, ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Paginated, PersonalityVersion } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 人格页 · 当前人物的人格版本,字段以提议审核方式留痕写入。
* 预取当前版本与版本历史,交由 PersonalityWorkspace 组合编辑 / 即时审批 / 时间线。
*/
export default async function PersonalityPage() {
let personality: PersonalityVersion | null = null;
let versions: PersonalityVersion[] = [];
let error: string | null = null;
try {
personality = await serverApi.get<PersonalityVersion>(
"/personality/current",
);
} catch (e) {
error = describeError(e);
}
// 版本历史独立拉取,失败静默(不影响编辑区)
try {
versions = (
await serverApi.get<Paginated<PersonalityVersion>>(
"/personality/versions",
{ page_size: 50 },
)
).items;
} catch {
/* 版本历史可选 */
}
return (
<PageContainer
title="人格"
description="当前人物的人格版本;字段修改以提议审核方式留痕写入。"
>
{error ? (
<ErrorState message={error} />
) : personality ? (
<PersonalityWorkspace personality={personality} versions={versions} />
) : (
<EmptyState
title="尚未生成人格"
description="当前人物尚未初始化人格版本,待后端写入后即可查看与编辑。"
/>
)}
</PageContainer>
);
}
@@ -1,60 +0,0 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { RunTraceView } from "@/components/runs/RunTraceView";
import { ErrorState } from "@/components/shared/StateViews";
import { ErrorTraceLink } from "@/components/shared/ErrorTraceLink";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Run, RunEvent } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function RunPage({
params,
}: {
params: Promise<{ runId: string }>;
}) {
const { runId } = await params;
let run: Run | null = null;
let events: RunEvent[] = [];
let error: string | null = null;
try {
const [fetchedRun, fetchedEvents] = await Promise.all([
serverApi.get<Run>(`/runs/${runId}`),
serverApi
.get<RunEvent[]>(`/runs/${runId}/events`)
.catch(() => [] as RunEvent[]),
]);
run = fetchedRun;
events = fetchedEvents;
} catch (e) {
error = describeError(e);
}
// 取该 run 首个带 trace_id 的事件作为链路入口(同一 run 共享一条链路)
const traceId = events.find((ev) => ev.trace_id)?.trace_id;
return (
<PageContainer
title="运行轨迹"
description={`Run ${runId}`}
maxWidth="max-w-4xl"
>
{error ? (
<ErrorState message={error} />
) : (
<>
{traceId && (
<div className="mb-4 flex items-center gap-2 text-xs text-muted">
<span>本次链路</span>
<ErrorTraceLink traceId={traceId} />
</div>
)}
<RunTraceView runId={runId} initialRun={run} initialEvents={events} />
</>
)}
</PageContainer>
);
}
-76
View File
@@ -1,76 +0,0 @@
import Link from "next/link";
import { PageContainer } from "@/components/layout/PageContainer";
import { RunStatusBadge } from "@/components/ui/StatusBadge";
import { EmptyState, ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { formatDateTime } from "@/lib/utils/format";
import { describeError } from "@/lib/utils/error";
import type { Paginated, Run } from "@/types/api";
export const dynamic = "force-dynamic";
/** 运行记录入口:与 /runs/[runId] 详情页组成完整轨迹路径。 */
export default async function RunsPage() {
let runs: Paginated<Run> | null = null;
let error: string | null = null;
try {
runs = await serverApi.get<Paginated<Run>>("/runs", { page_size: 50 });
} catch (cause) {
error = describeError(cause);
}
return (
<PageContainer
title="运行轨迹"
description="查看每一次推演、工具调用与模型响应的执行状态。"
meta={
runs && (
<span className="rounded-full border border-border-glass bg-glass px-2.5 py-0.5 text-xs text-muted">
共 {runs.total} 条
</span>
)
}
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : !runs || runs.items.length === 0 ? (
<EmptyState
title="暂无运行记录"
description="发起对话或执行任务后,这里会保留可追溯的运行轨迹。"
/>
) : (
<div className="overflow-hidden rounded-2xl border border-border-glass bg-glass backdrop-blur-sm">
<ul className="divide-y divide-border-glass">
{runs.items.map((run) => (
<li key={run.id}>
<Link
href={`/runs/${run.id}`}
className="group flex min-h-20 items-center gap-4 px-4 py-3 transition-colors hover:bg-overlay-hover focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-patina"
>
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-sm text-fg group-hover:text-patina">
{run.goal || run.id}
</p>
<p className="mt-1 truncate text-xs text-muted">
{[
run.id,
run.model_hint ? `模型:${run.model_hint}` : null,
`创建:${formatDateTime(run.created_at ?? run.started_at)}`,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<RunStatusBadge status={run.status} />
</Link>
</li>
))}
</ul>
</div>
)}
</PageContainer>
);
}
+7 -220
View File
@@ -1,235 +1,22 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { WorldEntityList } from "@/components/admin/WorldEntityList";
import { WorldRelationManager } from "@/components/world/WorldRelationManager";
import { WorldRelationGraph } from "@/components/world/WorldRelationGraph";
import { EmptyState, ErrorState } from "@/components/shared/StateViews";
import { WorldWorkbench } from "@/components/world/WorldWorkbench";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type {
Character,
Paginated,
WorldEntity,
WorldRelation,
} from "@/types/api";
import type { PersistentWorld } from "@/lib/api/worlds";
export const dynamic = "force-dynamic";
/**
* 世界页 · 数字生命对世界的认知(河图枢主域视图)。
* 设计稿 world-preview.html — 人物 pill + section-label + 关系图谱 + 实体名册 + 不确定性 + 世界观共振。
* 实体 CRUD 走 admin 域(WorldEntityList,保留编辑/删除);关系图谱走独立 /world/relations。
* 世界页(M6 河图重建):境界列表 + 境界详情(实体/关系/正史)。
* 境界正史不进真界本相——正史条目只读呈现。
*/
export default async function WorldPage() {
let entities: WorldEntity[] = [];
let relations: WorldRelation[] = [];
let character: Character | null = null;
let characterName = "";
let worlds: PersistentWorld[] = [];
let error: string | null = null;
try {
entities = await serverApi.get<WorldEntity[]>("/admin/world/entities");
worlds = await serverApi.get<PersistentWorld[]>("/worlds");
} catch (e) {
error = describeError(e);
}
try {
relations = (
await serverApi.get<Paginated<WorldRelation>>("/world/relations", {
page_size: 50,
})
).items;
} catch {
/* 关系可选 */
}
try {
character = await serverApi
.get<Character>("/characters/active")
.catch(() => null);
if (character) {
characterName = character.display_name || character.name;
}
} catch {
/* 人物信息可选 */
}
/* 按 state_type 统计(用于待验断言计数) */
const hypotheses = entities.filter(
(e) => e.state_type === "hypothesis",
).length;
/* 待验断言(静态示例,与设计稿对齐) */
const uncertainties = [
{
claim: "云岚 与 青梧 同属旧族旁支,可能存在亲属关系。",
reason: "来源:对话中的姓氏推测 · 证据单一且间接",
confidence: 0.42,
},
{
claim: "用户当前正在推进的「辰正项目」目标 为时态感知子系统。",
reason: "来源:关键词频率推断 · 缺乏用户明确表述",
confidence: 0.55,
},
];
return (
<PageContainer
title="世界"
description="河图 · 世界模型"
meta={
<>
{characterName && (
<span className="char-pill">
<span className="avatar-mini">{characterName.charAt(0)}</span>
{characterName}视角
</span>
)}
<span className="page-meta">
实体 <b>{entities.length}</b> · 关系 <b>{relations.length}</b> ·
待验 <b style={{ color: "var(--cinnabar)" }}>{hypotheses}</b>
</span>
</>
}
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : entities.length === 0 ? (
<EmptyState
title="暂无世界实体"
description="尚未建模任何实体。点击「新增世界实体」开始。"
/>
) : (
<div className="space-y-5">
{/* 关系图谱 */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 关系图谱{" "}
<span className="sub">
河图 · 实体为节点,关系为边,置信度定粗细,假设态虚线待验
</span>
</div>
<WorldRelationGraph entities={entities} relations={relations} />
</section>
{/* 实体名册 */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 实体名册{" "}
<span className="sub">按类型 · 事实性着色 · 推断召回时降权</span>
</div>
<WorldEntityList entities={entities} />
</section>
{/* 待验断言(不确定性) */}
{uncertainties.length > 0 && (
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 待验断言{" "}
<span className="sub">
UncertaintyState · 低置信,需复核方可固化为事实
</span>
</div>
<div className="unc-list">
{uncertainties.map((u, i) => (
<div key={i} className="unc-card">
<div className="unc-claim">
<div className="unc-text">{u.claim}</div>
<div className="unc-reason">{u.reason}</div>
</div>
<div className="unc-conf">
<div className="v">{u.confidence.toFixed(2)}</div>
<div className="k">置信</div>
</div>
<span className="unc-verify">需验证</span>
</div>
))}
</div>
</section>
)}
{/* 世界观共振 */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 世界观共振{" "}
<span className="sub">
见地镜 · 每人物独立的世界观立场,可同境可异境
</span>
</div>
<div className="wv-card">
<div className="wv-pair">
<div className="wv-side qingwu">
<div className="wv-head">
<div className="wv-disc">
{characterName ? characterName.charAt(0) : "?"}
</div>
<span className="wv-name">{characterName || "人物"}</span>
<span className="wv-tag shared">同境 · 主脉</span>
</div>
<div className="wv-stance">
万物流转无常,变化的规律本身比任何固定结论更可依凭。
</div>
<div className="wv-anchors">
<span className="wv-anchor">
面对未知优先观察与适应,而非强求确定性。
</span>
<span className="wv-anchor">
把推断与事实分开,承认不知也是一种知。
</span>
</div>
<span className="wv-conf">
spiritual_core.worldview · confidence 0.82 · v7
</span>
</div>
<div className="wv-bridge">
<div className="b-disc">
<div className="b-fill"></div>
<span className="b-val">0.41</span>
</div>
<div className="b-label">
共振度
<br />
divergent
</div>
</div>
<div className="wv-side yunlan">
<div className="wv-head">
<div className="wv-disc">岚</div>
<span className="wv-name">云岚</span>
<span className="wv-tag divergent">异境 · 旁支</span>
</div>
<div className="wv-stance">
人如其名,聚散如岚——来去之间留痕即可,不必执守长久。
</div>
<div className="wv-anchors">
<span className="wv-anchor">
珍惜每次相遇,不强求每段关系都有归处。
</span>
<span className="wv-anchor">
在变动中自洽,不为稳定而委屈当下。
</span>
</div>
<span className="wv-conf">
spiritual_core.worldview · confidence 0.38 · v3 · 初成
</span>
</div>
</div>
</div>
</section>
{/* 关系管理 */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 关系管理{" "}
<span className="sub">新增 / 编辑实体间关系</span>
</div>
<WorldRelationManager relations={relations} entities={entities} />
</section>
</div>
)}
</PageContainer>
);
return <WorldWorkbench initialWorlds={worlds} error={error} />;
}
-920
View File
@@ -1,920 +0,0 @@
"use client";
import { cn } from "@/lib/utils/cn";
import { PageContainer } from "@/components/layout/PageContainer";
/* ── 五行色(组件作用域 CSS 变量,未映射到 @theme → 用任意值引用)── */
const WUXING_VARS = {
"--wood": "#6fa389",
"--wood-dim": "rgba(111, 163, 137, 0.34)",
"--fire": "#b8392e",
"--fire-dim": "rgba(184, 57, 46, 0.38)",
"--earth": "#b08a3c",
"--earth-dim": "rgba(176, 138, 60, 0.36)",
"--metal": "#c9a24a",
"--metal-dim": "rgba(201, 162, 74, 0.34)",
"--water": "#5a7fa6",
"--water-dim": "rgba(90, 127, 166, 0.38)",
} as const;
/* ── 枢健康维度数据 ── */
const DIMS = [
{
key: "wood",
seal: "木",
label: "成长性",
sub: "演化候选 · 学习速度",
width: 64,
val: "0.64",
},
{
key: "fire",
seal: "火",
label: "表达力",
sub: "事件发射 · 模型密度",
width: 78,
val: "0.78",
},
{
key: "earth",
seal: "土",
label: "稳定性",
sub: "数据一致 · 基线回归",
width: 88,
val: "0.88",
},
{
key: "metal",
seal: "金",
label: "约束强度",
sub: "审核驳回 · 确认触发",
width: 71,
val: "0.71",
},
{
key: "water",
seal: "水",
label: "适应性",
sub: "上下文切换 · 异常恢复",
width: 55,
val: "0.55",
},
] as const;
/* ── 九枢数据 ── */
const PIVOTS = [
{
name: "灵核枢",
eng: "Personality Core",
score: 89,
trend: "↑ 土 +2",
desc: "人格连续性稳定",
bars: [55, 70, 96, 60, 58] as const,
alert: false,
},
{
name: "太一枢",
eng: "Goal Orchestrator",
score: 85,
trend: "↑ 土 +1",
desc: "目标调度基线稳固",
bars: [48, 52, 90, 75, 62] as const,
alert: false,
},
{
name: "司命枢",
eng: "Safety Governor",
score: 73,
trend: "↓ 木 -3",
desc: "金过盛克木",
bars: [32, 50, 84, 94, 40] as const,
alert: true,
tag: "金→木",
},
{
name: "烛照枢",
eng: "Perception Layer",
score: 81,
trend: "↑ 火 +4",
desc: "多模态输入活跃",
bars: [50, 92, 78, 55, 70] as const,
alert: false,
},
{
name: "河图枢",
eng: "World Model",
score: 87,
trend: "↑ 土 +1",
desc: "世界实体一致",
bars: [58, 60, 95, 66, 64] as const,
alert: false,
},
{
name: "洛书枢",
eng: "Planner",
score: 79,
trend: "↑ 水 +2",
desc: "规划切换敏捷",
bars: [62, 74, 80, 58, 82] as const,
alert: false,
},
{
name: "息壤枢",
eng: "Memory System",
score: 84,
trend: "↑ 木 +3",
desc: "知识沉淀加速",
bars: [80, 54, 91, 52, 60] as const,
alert: false,
},
{
name: "百工枢",
eng: "Tool Runtime",
score: 80,
trend: "↑ 火 +5",
desc: "工具调用密集",
bars: [46, 88, 72, 82, 66] as const,
alert: false,
},
{
name: "天工阁枢",
eng: "Agent Society",
score: 76,
trend: "↑ 水 +2",
desc: "协作分工顺畅",
bars: [54, 76, 68, 64, 78] as const,
alert: false,
},
] as const;
/* ── 相生依赖链 ── */
const SHENG_FLOWS = [
{
pair: "木",
target: "火",
body: "内驱力激活 催生 对话表达 · 息壤枢学习加速推动灵核枢输出活跃。",
val: "0.68",
},
{
pair: "火",
target: "土",
body: "高频表达 沉淀 稳定状态 · 对话事件入库强化世界模型与人格基线。",
val: "0.72",
},
{
pair: "土",
target: "金",
body: "稳定数据 提供依据 给约束规则 · 世界实体一致,司命审核判据更可靠。",
val: "0.65",
},
{
pair: "金",
target: "水",
body: "明确规范 塑造 适应路径 · 约束边界清晰,异常恢复与上下文切换更可控。",
val: "0.58",
},
{
pair: "水",
target: "木",
body: "适应能力 反哺 成长 · 场景切换中积累新候选,演化候选生成率回温。",
val: "0.61",
},
] as const;
/* ── 相克告警 ── */
const KE_ALERTS = [
{
pairA: "金",
pairB: "木",
desc: "司命审核驳回率 vs 大周天候选生成率 · 安全审查过严压制演化,阴阳平衡度下行。",
level: "0.82 · 高",
width: 82,
urgent: true,
muted: false,
},
{
pairA: "土",
pairB: "水",
desc: "人格版本固化度 vs 上下文切换成本 · 人格越稳,面对新场景的适应越慢。",
level: "0.64 · 中",
width: 64,
urgent: false,
muted: false,
},
{
pairA: "火",
pairB: "金",
desc: "工具调用频率 vs 安全确认触发率 · 调用越密,权限判断越频,已在容忍区。",
level: "0.44 · 低",
width: 44,
urgent: false,
muted: true,
},
] as const;
/* ── 五行 key 列表 ── */
const ELEMENT_KEYS = ["wood", "fire", "earth", "metal", "water"] as const;
const ELEMENT_SEALS: Record<string, string> = {
wood: "木",
fire: "火",
earth: "土",
metal: "金",
water: "水",
};
/* ── SVG 节点坐标(viewBox 400×400)── */
const NODE_COORDS = {
wood: { cx: 200, cy: 65, labelX: 200, labelY: 30 },
fire: { cx: 328, cy: 158, labelX: 372, labelY: 132 },
earth: { cx: 279, cy: 309, labelX: 311, labelY: 350 },
metal: { cx: 121, cy: 309, labelX: 89, labelY: 350 },
water: { cx: 72, cy: 158, labelX: 30, labelY: 132 },
} as const;
/* 相生边(五边形):木→火, 火→土, 土→金, 金→水, 水→木 */
const SHENG_EDGES: [string, string][] = [
["wood", "fire"],
["fire", "earth"],
["earth", "metal"],
["metal", "water"],
["water", "wood"],
];
/* 相克边(五角星):木→土, 土→水, 水→火, 火→金, 金→木 */
const KE_EDGES: [string, string][] = [
["wood", "earth"],
["earth", "water"],
["water", "fire"],
["fire", "metal"],
["metal", "wood"],
];
/* ============================================================
* 组件
* ============================================================ */
export default function WuxingPage() {
return (
<>
<style>{`
.wuxing-scope {
${Object.entries(WUXING_VARS)
.map(([k, v]) => `${k}: ${v};`)
.join("\n ")}
}
@keyframes wuxing-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--patina) 50%, transparent); }
70% { box-shadow: 0 0 0 6px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
.pulse-dot {
animation: wuxing-pulse 2.6s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
.wuxing-svg .ring-deco { fill: none; stroke: var(--rule); stroke-width: 1; }
.wuxing-svg .sheng { stroke: var(--gilt); stroke-width: 1.6; fill: none; opacity: 0.85; }
.wuxing-svg .ke { stroke: var(--cinnabar); stroke-width: 1.3; stroke-dasharray: 4 4; fill: none; opacity: 0.7; }
.wuxing-svg .node-circle { stroke-width: 1.5; }
.wuxing-svg .center-bg { fill: var(--ink); stroke: var(--gilt-dim); stroke-width: 1; }
.dim-track {
height: 5px;
border-radius: 3px;
background: var(--rule);
overflow: hidden;
position: relative;
}
.dim-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 3px;
}
.pbar-track {
height: 4px;
border-radius: 2px;
background: var(--rule);
overflow: hidden;
position: relative;
}
.pbar-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 2px;
}
.ke-track {
height: 4px;
border-radius: 2px;
background: var(--rule);
overflow: hidden;
position: relative;
}
.ke-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 2px;
}
`}</style>
<div className="wuxing-scope">
<PageContainer
title="五行"
description="枢健康维度 · 生克关系"
actions={
<span
className="inline-flex items-center gap-[7px] rounded-full border px-3 py-1 font-mono text-xs"
style={{
borderColor: "var(--patina-dim)",
color: "var(--patina)",
background: "var(--patina-wash)",
}}
>
<span
className="inline-block h-[6px] w-[6px] rounded-full"
style={{ background: "var(--patina)" }}
>
<span className="pulse-dot block h-full w-full rounded-full" />
</span>
架构心跳 82 · 良
</span>
}
>
{/* ══ 五行生克环 + 枢健康维度 ══ */}
<div className="mb-5 flex flex-col gap-[22px] lg:flex-row lg:items-stretch">
{/* ---- 生克环 ---- */}
<div
className="relative flex w-full flex-col overflow-hidden rounded-lg border p-[18px] lg:w-auto lg:flex-[0_0_auto] lg:basis-[460px]"
style={{
borderColor: "var(--gilt-dim)",
background: "var(--face-sheen), var(--surface)",
boxShadow:
"inset 0 1px 0 var(--face-in-top), inset 0 -1px 0 var(--face-in-bot), var(--shadow)",
}}
>
{/* 噪点纹理 */}
<span
className="pointer-events-none absolute inset-0 z-0"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' seed='7'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E\")",
opacity: 0.018,
mixBlendMode: "overlay",
}}
/>
<svg
className="wuxing-svg relative z-10 block h-auto w-full"
viewBox="0 0 400 400"
role="img"
aria-label="五行生克环:外环相生、内五角星相克"
>
{/* 装饰环 */}
<circle className="ring-deco" cx="200" cy="200" r="172" />
<circle
className="ring-deco"
cx="200"
cy="200"
r="108"
opacity="0.5"
strokeDasharray="2 4"
/>
{/* 相克(虚线五角星,下层) */}
{KE_EDGES.map(([from, to]) => {
const f = NODE_COORDS[from as keyof typeof NODE_COORDS];
const t = NODE_COORDS[to as keyof typeof NODE_COORDS];
return (
<line
key={`ke-${from}-${to}`}
className="ke"
x1={f.cx}
y1={f.cy}
x2={t.cx}
y2={t.cy}
/>
);
})}
{/* 相生(实线五边形,上层) */}
{SHENG_EDGES.map(([from, to]) => {
const f = NODE_COORDS[from as keyof typeof NODE_COORDS];
const t = NODE_COORDS[to as keyof typeof NODE_COORDS];
return (
<line
key={`sheng-${from}-${to}`}
className="sheng"
x1={f.cx}
y1={f.cy}
x2={t.cx}
y2={t.cy}
/>
);
})}
{/* 五行节点 */}
{ELEMENT_KEYS.map((key) => {
const n = NODE_COORDS[key];
const seal = ELEMENT_SEALS[key];
return (
<g key={key}>
<circle
className="node-circle"
cx={n.cx}
cy={n.cy}
r="27"
style={{
fill: `var(--${key}-dim)`,
stroke: `var(--${key})`,
}}
/>
<text
x={n.cx}
y={n.cy}
textAnchor="middle"
dominantBaseline="central"
style={{
fontFamily: '"Songti SC", "STSong", serif',
fontSize: "22px",
fontWeight: 700,
fill: `var(--${key})`,
}}
>
{seal}
</text>
<text
x={n.labelX}
y={n.labelY}
textAnchor="middle"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, system-ui, sans-serif",
fontSize: "9px",
fill: "var(--fg-faint)",
letterSpacing: "0.04em",
}}
>
{DIMS.find((d) => d.key === key)?.label ?? ""}
</text>
</g>
);
})}
{/* 中心:架构心跳 */}
<circle className="center-bg" cx="200" cy="200" r="58" />
<text
x="200"
y="186"
textAnchor="middle"
style={{
fontFamily: '"Songti SC", "STSong", serif',
fontSize: "15px",
fontWeight: 600,
fill: "var(--gilt)",
}}
>
架构心跳
</text>
<text
x="200"
y="212"
textAnchor="middle"
style={{
fontFamily:
'"JetBrains Mono", "SF Mono", ui-monospace, monospace',
fontSize: "20px",
fontWeight: 300,
fill: "var(--fg)",
letterSpacing: "0.04em",
}}
>
82
</text>
<text
x="200"
y="228"
textAnchor="middle"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, system-ui, sans-serif",
fontSize: "9.5px",
fill: "var(--fg-dim)",
}}
>
阴阳平衡 · 良
</text>
</svg>
{/* 图例 */}
<div
className="relative z-10 mt-[10px] flex flex-wrap gap-4 border-t pt-[10px]"
style={{ borderColor: "var(--rule)" }}
>
<span
className="inline-flex items-center gap-[6px] text-[11.5px]"
style={{ color: "var(--fg-dim)" }}
>
<span
className="inline-block w-[18px]"
style={{ borderTop: "1.6px solid var(--gilt)" }}
/>
相生 · 正向依赖
</span>
<span
className="inline-flex items-center gap-[6px] text-[11.5px]"
style={{ color: "var(--fg-dim)" }}
>
<span
className="inline-block w-[18px]"
style={{ borderTop: "1.3px dashed var(--cinnabar)" }}
/>
相克 · 负向制约
</span>
</div>
</div>
{/* ---- 维度卡 ---- */}
<div
className="flex min-w-0 flex-1 flex-col rounded-lg border p-[18px_20px]"
style={{
borderColor: "var(--rule)",
background: "var(--surface)",
}}
>
<h3
className="font-serif text-[14px] font-semibold"
style={{ color: "var(--fg)", marginBottom: "14px" }}
>
枢健康维度
</h3>
<div className="flex flex-1 flex-col gap-[11px]">
{DIMS.map((d) => (
<div
key={d.key}
className="dim"
style={{
display: "grid",
gridTemplateColumns: "22px 60px 1fr 30px",
alignItems: "center",
gap: "10px",
}}
>
<span
className="text-center font-serif text-[16px] font-bold"
style={{ color: `var(--${d.key})` }}
>
{d.seal}
</span>
<span
className="text-[12.5px] font-medium leading-tight"
style={{ color: "var(--fg)" }}
>
{d.label}
<small
className="mt-[1px] block text-[10.5px] font-normal leading-tight"
style={{ color: "var(--fg-faint)" }}
>
{d.sub}
</small>
</span>
<span className="dim-track">
<span
className="dim-fill"
style={{
width: `${d.width}%`,
background: `linear-gradient(90deg, var(--${d.key}-dim), var(--${d.key}))`,
}}
/>
</span>
<span
className="text-right font-mono text-[10.5px]"
style={{ color: `var(--${d.key})` }}
>
{d.val}
</span>
</div>
))}
</div>
<div
className="mt-[14px] rounded-r-[4px] border-l-2 px-[12px] py-[10px] text-[12px] leading-[1.55]"
style={{
borderLeftColor: "var(--gilt-dim)",
background: "var(--gilt-faint)",
color: "var(--fg-dim)",
}}
>
<b
className="font-serif font-semibold"
style={{ color: "var(--gilt)" }}
>
金→木
</b>{" "}
相克已接近阈值:司命审核驳回率(金)压制大周天候选生成(木),演化流速近
7 日下行。建议在司命台复核约束规则松紧度,参见{" "}
<code
className="font-mono text-[11px]"
style={{ color: "var(--gilt)" }}
>
阴阳平衡度
</code>{" "}
监控。
</div>
</div>
</div>
{/* ══ 九枢健康网格 ══ */}
<div className="mb-5">
<div
className="mb-[14px] flex items-center gap-[6px] font-mono text-[12px]"
style={{ color: "var(--fg-faint)", letterSpacing: "0.1em" }}
>
<span style={{ color: "var(--gilt)", opacity: 0.8 }}>⟡</span>
九枢健康
<span
className="text-[12px] tracking-normal opacity-75"
style={{ color: "var(--fg-dim)" }}
>
每枢五维 · 综合分 + 趋势
</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{PIVOTS.map((p) => (
<div
key={p.name}
className={cn(
"flex flex-col gap-[9px] rounded-lg border p-[14px_16px]",
p.alert && "bg-[rgba(184,57,46,0.05)]",
)}
style={{
borderColor: p.alert
? "var(--cinnabar-dim)"
: "var(--rule)",
background: p.alert ? undefined : "var(--surface)",
}}
>
{/* 枢头部 */}
<div className="flex items-baseline gap-2">
<span
className="font-serif text-[15px] font-semibold"
style={{ color: "var(--fg)" }}
>
{p.name}
</span>
<span
className="font-mono text-[10px] tracking-wide"
style={{ color: "var(--fg-faint)" }}
>
{p.eng}
</span>
<span
className="ml-auto font-mono text-[17px] font-light tracking-wide"
style={{
color:
p.score < 75 ? "var(--cinnabar)" : "var(--patina)",
}}
>
{p.score}
</span>
</div>
{/* 五维微进度条 */}
<div className="flex flex-col gap-[4px]">
{ELEMENT_KEYS.map((ek, i) => (
<div
key={ek}
style={{
display: "grid",
gridTemplateColumns: "14px 1fr 24px",
alignItems: "center",
gap: "7px",
}}
>
<span
className="text-center font-serif text-[11px]"
style={{ color: `var(--${ek})` }}
>
{ELEMENT_SEALS[ek]}
</span>
<span className="pbar-track">
<span
className="pbar-fill"
style={{
width: `${p.bars[i]}%`,
background: `var(--${ek})`,
}}
/>
</span>
<span
className="font-mono text-[9.5px] text-right"
style={{ color: "var(--fg-faint)" }}
>
.{p.bars[i]}
</span>
</div>
))}
</div>
{/* 底部趋势 */}
<div
className="flex items-center gap-2 pt-[4px] text-[11px]"
style={{
borderTop: "1px solid var(--rule)",
color: "var(--fg-faint)",
}}
>
<span
className="font-mono"
style={{
color: p.trend.startsWith("↑")
? "var(--patina)"
: "var(--cinnabar)",
}}
>
{p.trend}
</span>
{p.desc}
{p.alert && p.tag && (
<span
className="ml-auto rounded-[3px] border px-[7px] py-[2px] font-mono text-[9.5px]"
style={{
borderColor: "var(--cinnabar-dim)",
background: "rgba(184, 57, 46, 0.08)",
color: "var(--cinnabar)",
}}
>
{p.tag}
</span>
)}
</div>
</div>
))}
</div>
</div>
{/* ══ 相生流 + 相克告警 ══ */}
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
{/* ---- 相生流 ---- */}
<div className="min-w-0 flex-1">
<h3
className="mb-[14px] font-serif text-[14px] font-semibold"
style={{ color: "var(--fg)" }}
>
相生 · 正向依赖链
</h3>
<div className="flex flex-col gap-2">
{SHENG_FLOWS.map((f) => (
<div
key={`${f.pair}-${f.target}`}
className="flex items-start gap-[10px] rounded-[5px] border px-[13px] py-[10px] text-[12.5px] leading-[1.5]"
style={{
borderColor: "var(--rule)",
background: "var(--patina-wash)",
}}
>
<span
className="shrink-0 pt-[1px] font-serif text-[13px] whitespace-nowrap"
style={{ color: "var(--gilt)" }}
>
{f.pair}
<span
className="mx-[3px] font-mono text-[11px]"
style={{ color: "var(--fg-faint)" }}
>
→
</span>
{f.target}
</span>
<span
className="min-w-0"
style={{ color: "var(--fg-dim)" }}
>
<span style={{ color: "var(--fg)" }}>{f.body}</span>
</span>
<span
className="shrink-0 pt-[1px] font-mono text-[10.5px] whitespace-nowrap"
style={{ color: "var(--patina)" }}
>
{f.val}
</span>
</div>
))}
</div>
</div>
{/* ---- 相克告警 ---- */}
<div className="w-full shrink-0 lg:w-[340px]">
<div
className="rounded-lg border p-[16px_18px]"
style={{
borderColor: "var(--rule)",
background: "var(--surface)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3
className="m-0 font-serif text-[14px] font-semibold"
style={{ color: "var(--fg)" }}
>
相克告警{" "}
<span
className="font-mono text-[11px] font-normal"
style={{ color: "var(--fg-faint)" }}
>
克制失衡
</span>
</h3>
<span
className="rounded-[5px] border px-[10px] py-[3px] font-mono text-[11px]"
style={{
borderColor: "var(--cinnabar-dim)",
background: "rgba(184, 57, 46, 0.08)",
color: "var(--cinnabar)",
}}
>
2 待关注
</span>
</div>
<div className="flex flex-col gap-2">
{KE_ALERTS.map((a) => (
<div
key={`${a.pairA}-${a.pairB}`}
className="flex items-start gap-[10px] rounded-[5px] border px-[12px] py-[9px]"
style={{
borderColor: a.muted
? "var(--rule)"
: "var(--cinnabar-dim)",
background: a.muted ? "transparent" : "var(--ink)",
}}
>
<span
className="w-[46px] shrink-0 pt-[1px] text-center font-serif text-[13px] leading-tight"
style={{
color: a.muted
? "var(--fg-faint)"
: "var(--cinnabar)",
}}
>
{a.pairA}
<br />
{a.pairB}
</span>
<div className="min-w-0 flex-1">
<div
className="text-[12px] leading-[1.5]"
style={{
color: a.muted
? "var(--fg-faint)"
: "var(--fg-dim)",
}}
>
<span
style={{
color: a.muted ? "var(--fg-dim)" : "var(--fg)",
}}
>
{a.desc}
</span>
</div>
<div className="mt-1 flex items-center gap-[6px]">
<span className="ke-track flex-1">
<span
className="ke-fill"
style={{
width: `${a.width}%`,
background: a.muted
? "linear-gradient(90deg, var(--gilt-dim), var(--gilt))"
: "linear-gradient(90deg, var(--cinnabar-dim), var(--cinnabar))",
}}
/>
</span>
<span
className="shrink-0 font-mono text-[9.5px] whitespace-nowrap"
style={{
color: a.muted
? "var(--gilt)"
: "var(--cinnabar)",
}}
>
{a.level}
</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</PageContainer>
</div>
</>
);
}
@@ -6,7 +6,6 @@ import { VermilionAction } from "@/components/admin/VermilionAction";
import { PolicyMatrix } from "@/components/admin/PolicyMatrix";
import { AuditConsole } from "@/components/admin/AuditConsole";
import { ToolRegistry } from "@/components/admin/ToolRegistry";
import { PersonalityEditor } from "@/components/admin/PersonalityEditor";
import { GoalTree } from "@/components/admin/GoalTree";
import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix";
import { WorldEntityList } from "@/components/admin/WorldEntityList";
@@ -36,7 +35,6 @@ import type {
export const dynamic = "force-dynamic";
const VALID_PIVOTS: Pivot[] = [
"personality_core",
"goal_orchestrator",
"safety_governor",
"perception",
@@ -67,8 +65,6 @@ export default async function PivotDetailPage({
return <SafetyGovernorDetail />;
case "tool_runtime":
return <ToolRuntimeDetail />;
case "personality_core":
return <PersonalityCoreDetail />;
}
}
@@ -214,31 +210,6 @@ async function ToolRuntimeDetail() {
);
}
/** 灵核枢:人格各字段 inline 可编辑(核心人格 L4 走提议审核) */
async function PersonalityCoreDetail() {
let persona: PersonalityVersion | null = null;
let error: string | null = null;
try {
persona = await serverApi.get<PersonalityVersion>("/personality/current");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="灵核枢"
description="人格与身份 · 每个字段均可编辑"
maxWidth="max-w-3xl"
>
{error || !persona ? (
<ErrorState message={error ?? "人格信息暂不可用。"} />
) : (
<PersonalityEditor persona={persona} />
)}
</PageContainer>
);
}
/** 太一枢:目标层级树(最高目标 → 任务,可编辑标题/优先级/状态) */
async function GoalOrchestratorDetail() {
let goals: GoalNode[] = [];
-16
View File
@@ -113,27 +113,11 @@ export default async function HomePage() {
{/* 底部功能导航 */}
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-xs text-muted/60">
<Link href="/personality" className="transition-colors hover:text-fg">
人格
</Link>
<Link href="/evolution" className="transition-colors hover:text-fg">
演化
</Link>
{FEATURE_FLAGS.drivePanel && (
<Link href="/drive" className="transition-colors hover:text-fg">
内驱力
</Link>
)}
{FEATURE_FLAGS.worldPanel && (
<Link href="/world" className="transition-colors hover:text-fg">
世界
</Link>
)}
{FEATURE_FLAGS.timePanel && (
<Link href="/time" className="transition-colors hover:text-fg">
时态
</Link>
)}
<Link href="/archive" className="transition-colors hover:text-fg">
归档
</Link>
@@ -1,230 +0,0 @@
"use client";
import { useState } from "react";
import { personalityApi } from "@/lib/api";
import { cn } from "@/lib/utils/cn";
import type {
PersonalityProposal,
PersonalityProposalFields,
PersonalityVersion,
} from "@/types/api";
type StringField =
| "identity_profile"
| "role_destiny"
| "personality_seed"
| "expression_style"
| "relationship_pattern";
type JsonField = "traits" | "values" | "emotion_baseline";
const STRING_FIELDS: Array<{ key: StringField; label: string }> = [
{ key: "identity_profile", label: "身份" },
{ key: "role_destiny", label: "命格" },
{ key: "personality_seed", label: "人格种子" },
{ key: "expression_style", label: "表达风格" },
{ key: "relationship_pattern", label: "关系模式" },
];
const JSON_FIELDS: Array<{ key: JsonField; label: string }> = [
{ key: "traits", label: "特质" },
{ key: "values", label: "价值偏好" },
{ key: "emotion_baseline", label: "情绪基线" },
];
/**
* 灵核枢人格字段 —— 每个字段均可 inline 编辑。
* 核心人格(L4)修改走提议审核(§11 propose-update):点字段「编辑」进入编辑态,
* 可同时编辑多个字段;底部统一填写修改原因、提交建议。后端审核通过才写入新版本。
* 字段严格对齐后端 PersonalityProposalFields:identity_profile / role_destiny /
* personality_seed / expression_style / relationship_pattern(文本)与
* traits / values / emotion_baseline(JSON)。draft 只存正在编辑的字段(文本/JSON 文本),
* 提交时仅发送改动项;dict 字段提交前 JSON.parse 校验。提交成功经 onProposed 回调
* 把建议交给父组件(PendingProposalCard)做即时审批。
*/
export function PersonalityEditor({
persona,
onProposed,
}: {
persona: PersonalityVersion;
onProposed?: (proposal: PersonalityProposal) => void;
}) {
const [draft, setDraft] = useState<Record<string, string>>({});
const [reason, setReason] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const dirtyFields = Object.keys(draft);
const hasEdits = dirtyFields.length > 0;
const jsonKeys = JSON_FIELDS.map((f) => f.key);
function displayValue(field: StringField | JsonField): string {
if (field in draft) return draft[field];
const raw = persona[field];
if (raw == null) return "";
return typeof raw === "string" ? raw : JSON.stringify(raw, null, 2);
}
function startEdit(field: string) {
setDraft((d) => ({
...d,
[field]: displayValue(field as StringField | JsonField),
}));
setError(null);
}
function cancelEdit(field: string) {
setDraft((d) => {
const next = { ...d };
delete next[field];
return next;
});
}
async function submit() {
if (!hasEdits) {
setError("请先编辑至少一个字段。");
return;
}
if (!reason.trim()) {
setError("请填写修改原因(核心人格变更需留痕)。");
return;
}
const fields: Record<string, unknown> = {};
for (const field of dirtyFields) {
const text = draft[field];
if (jsonKeys.includes(field as JsonField)) {
try {
fields[field] = text.trim() ? JSON.parse(text) : {};
} catch {
setError(`「${field}」不是合法 JSON,请检查格式。`);
return;
}
} else {
fields[field] = text;
}
}
setBusy(true);
setError(null);
try {
const res = await personalityApi.proposeUpdate({
fields: fields as PersonalityProposalFields,
reason,
});
setDraft({});
setReason("");
onProposed?.(res);
} catch (e) {
setError(e instanceof Error ? e.message : "提交失败,请重试。");
} finally {
setBusy(false);
}
}
function renderField(field: string, label: string, isJson: boolean) {
const isEditing = field in draft;
const value = displayValue(field as StringField | JsonField);
return (
<div key={field} className="px-5 py-3.5">
<div className="flex items-center justify-between">
<p className="font-serif text-xs font-medium text-muted">
{label}
{isJson && (
<span className="ml-1 text-[10px] text-muted/70">JSON</span>
)}
</p>
{!isEditing ? (
<button
type="button"
onClick={() => startEdit(field)}
className="text-[11px] text-patina hover:underline"
>
编辑
</button>
) : (
<button
type="button"
onClick={() => cancelEdit(field)}
className="text-[11px] text-muted hover:underline"
>
取消
</button>
)}
</div>
{isEditing ? (
<textarea
value={value}
onChange={(e) =>
setDraft((d) => ({ ...d, [field]: e.target.value }))
}
rows={isJson ? 4 : 3}
className="mt-1 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 font-mono text-xs text-fg"
/>
) : (
<p
className={cn(
"mt-1 whitespace-pre-wrap text-sm leading-relaxed",
value ? "text-fg" : "italic text-muted",
)}
>
{value || "(未设置)"}
</p>
)}
</div>
);
}
return (
<div className="space-y-4">
<div className="glass divide-y divide-border-glass rounded-2xl">
{STRING_FIELDS.map(({ key, label }) => renderField(key, label, false))}
{JSON_FIELDS.map(({ key, label }) => renderField(key, label, true))}
</div>
{/* 统一提交区:仅当有改动字段时出现 */}
{hasEdits && (
<div className="glass rounded-2xl px-5 py-4">
<div className="flex items-center gap-2">
<p className="font-serif text-sm font-medium text-fg">
提交人格修改建议
</p>
<span className="rounded bg-cinnabar/15 px-1.5 py-0.5 text-[10px] text-cinnabar">
L4 · 需审核
</span>
<span className="ml-auto text-[10px] text-muted">
{dirtyFields.length} 项已改
</span>
</div>
<input
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="修改原因(必填,核心人格变更留痕)…"
className="mt-2 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="mt-2 flex gap-2">
<button
type="button"
onClick={submit}
disabled={busy}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
提交建议
</button>
<button
type="button"
onClick={() => {
setDraft({});
setReason("");
}}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
放弃全部
</button>
</div>
</div>
)}
{error && <p className="px-1 text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -0,0 +1,222 @@
"use client";
import { useState } from "react";
import { HeartHandshakeIcon, ScrollTextIcon } from "@/components/ui/Icons";
import { Button } from "@/components/ui/Button";
import type { Covenant } from "@/lib/api/covenants";
import { covenantApi } from "@/lib/api";
interface Props {
characterId: string;
initialCovenant: Covenant | null;
}
const DIM_SUGGESTIONS = [
"trust",
"mentorship",
"companionship",
"creative_alliance",
];
/**
* 缘契面板:当前缘契(多维维度 + 边界)/ 无缘契时提议。
* 撤回立即生效;事件历史不可抹除——由后端裁决,本面板只呈现与操作。
*/
export function CovenantPanel({ characterId, initialCovenant }: Props) {
const [covenant, setCovenant] = useState(initialCovenant);
const [dims, setDims] = useState<Record<string, string>>({
trust: "forming",
});
const [title, setTitle] = useState("");
const [showHistory, setShowHistory] = useState(false);
const [history, setHistory] = useState<
{ id: string; type: string; actor: string; created_at: string | null }[]
>([]);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
async function handlePropose() {
if (Object.keys(dims).length === 0) {
setNotice("至少需要一个关系维度(多维并存,禁单一好感度)。");
return;
}
setBusy(true);
try {
const created = await covenantApi.proposeCovenant({
character_id: characterId,
dimensions: dims,
primary_title: title || null,
});
setCovenant(created);
setNotice("缘契已提议,经问心与铭契后生效。");
} catch (e) {
setNotice(e instanceof Error ? e.message : "提议失败");
} finally {
setBusy(false);
}
}
async function handleSeal() {
if (!covenant) return;
setBusy(true);
try {
const sealed = await covenantApi.sealCovenant(covenant.id, {
dimensions: dims,
});
setCovenant(sealed);
setNotice("已铭契生效。");
} catch (e) {
setNotice(e instanceof Error ? e.message : "铭契失败");
} finally {
setBusy(false);
}
}
async function handleWithdraw() {
if (!covenant) return;
setBusy(true);
try {
await covenantApi.withdrawCovenant(covenant.id, "面板操作撤回");
setCovenant(null);
setNotice("已撤回(立即生效)。");
} catch (e) {
setNotice(e instanceof Error ? e.message : "撤回失败");
} finally {
setBusy(false);
}
}
async function loadHistory() {
if (!covenant) return;
const events = await covenantApi.listCovenantEvents(covenant.id);
setHistory(events);
setShowHistory(true);
}
return (
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<HeartHandshakeIcon className="size-4 text-gold" />
缘契
</h3>
<span className="set-card-eng">Covenant</span>
</div>
<p className="set-card-desc">
多维关系与共同确认的边界;撤回立即生效,事件历史不可抹除。
</p>
{notice && <div className="mb-3 text-sm text-patina">{notice}</div>}
{covenant ? (
<div>
<div className="mb-2 flex flex-wrap items-center gap-2">
{covenant.primary_title && (
<span className="font-serif text-sm text-fg">
{covenant.primary_title}
</span>
)}
<span className="rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
{covenant.status === "sealed"
? `已铭契 v${covenant.version}`
: "提议中"}
</span>
</div>
<div className="mb-3 flex flex-wrap gap-2">
{Object.entries(covenant.dimensions).map(([k, v]) => (
<span
key={k}
className="rounded-full border border-patina/40 px-2 py-0.5 font-mono text-[10px] text-patina"
>
{k} · {v}
</span>
))}
</div>
<div className="flex flex-wrap gap-2">
{covenant.status === "pending" && (
<Button
variant="primary"
size="sm"
onClick={handleSeal}
disabled={busy}
>
铭契生效
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={handleWithdraw}
disabled={busy}
>
撤回
</Button>
<Button variant="ghost" size="sm" onClick={loadHistory}>
<ScrollTextIcon className="mr-1 size-3.5" />
事件史
</Button>
</div>
{showHistory && (
<ul className="mt-3 divide-y divide-rule rounded-xl border border-rule">
{history.map((e) => (
<li
key={e.id}
className="flex items-center justify-between px-3 py-2 text-xs"
>
<span className="font-mono text-fg">{e.type}</span>
<span className="text-muted">
{e.actor} · {e.created_at?.slice(0, 19).replace("T", " ")}
</span>
</li>
))}
</ul>
)}
</div>
) : (
<div className="grid gap-3">
<div className="flex flex-wrap gap-2">
{DIM_SUGGESTIONS.map((d) => {
const active = d in dims;
return (
<button
key={d}
type="button"
className={`rounded-full border px-2 py-0.5 font-mono text-[10px] transition-colors ${
active
? "border-patina/60 bg-patina-wash text-patina"
: "border-rule text-muted hover:border-patina/40"
}`}
onClick={() => {
const next = { ...dims };
if (active) delete next[d];
else next[d] = "forming";
setDims(next);
}}
>
{d}
</button>
);
})}
</div>
<input
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
placeholder="主称谓(可选,如:亦师亦友)"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<div>
<Button
variant="primary"
size="sm"
onClick={handlePropose}
disabled={busy}
>
提议缘契
</Button>
</div>
</div>
)}
</div>
);
}
@@ -1,88 +0,0 @@
import type { CuriosityQuestion } from "@/types/api";
const SOURCE_LABEL: Record<string, string> = {
world_uncertainty: "世界不确定",
memory_gap: "记忆缺口",
perception_change: "感知变化",
goal_tension: "目标张力",
reflection: "反思",
time_rhythm: "时间节律",
};
const SOURCE_CLASS: Record<string, string> = {
world_uncertainty: "src-world",
memory_gap: "src-memory",
perception_change: "src-percept",
goal_tension: "src-goal",
reflection: "src-reflect",
time_rhythm: "src-time",
};
/** 好奇问题(缺口记录,只读展示,无审核)设计稿 curio 布局 */
export function CuriosityItem({ curiosity }: { curiosity: CuriosityQuestion }) {
const isHi = curiosity.expected_value >= 0.7;
const riskClass =
curiosity.urgency >= 0.7
? "risk-pill L4"
: curiosity.urgency >= 0.4
? "risk-pill L2"
: "risk-pill";
const sourceCls = SOURCE_CLASS[curiosity.source] ?? "";
return (
<div className="curio">
<span
className={`curio-fire ${isHi ? "hi" : ""}`}
title={`预期信息价值 ${curiosity.expected_value.toFixed(2)}`}
/>
<div className="curio-body">
<div className="curio-q">{curiosity.question}</div>
<div className="curio-meta">
<span className={`src-tag ${sourceCls}`}>
<span className="sdot"></span>
{SOURCE_LABEL[curiosity.source] ?? curiosity.source}
</span>
<span className={riskClass}>
L
{curiosity.urgency >= 0.7
? "4"
: curiosity.urgency >= 0.4
? "2"
: "1"}
</span>
{curiosity.related_entities.length > 0 && (
<span className="ent-link">
@{curiosity.related_entities.slice(0, 2).join(" · @")}
{curiosity.related_entities.length > 2 &&
" · +" + (curiosity.related_entities.length - 2)}
</span>
)}
</div>
<div className="curio-bars">
<div className="cbar value">
<div className="ck">信息价值</div>
<div className="ctrack">
<div
className="cfill"
style={{
width: `${Math.round(Math.max(0, Math.min(1, curiosity.expected_value)) * 100)}%`,
}}
/>
</div>
</div>
<div className="cbar urg">
<div className="ck">紧迫</div>
<div className="ctrack">
<div
className="cfill"
style={{
width: `${Math.round(Math.max(0, Math.min(1, curiosity.urgency)) * 100)}%`,
}}
/>
</div>
</div>
</div>
</div>
</div>
);
}
-160
View File
@@ -1,160 +0,0 @@
"use client";
import { useState } from "react";
import { EmptyState, ErrorState } from "@/components/shared/StateViews";
import { driveApi } from "@/lib/api";
import type { CuriosityQuestion, ExploratorySubgoal } from "@/types/api";
import { describeError } from "@/lib/utils/error";
import { cn } from "@/lib/utils/cn";
import { CardGridSkeleton } from "@/components/ui/Skeleton";
import { CuriosityItem } from "./CuriosityItem";
import { SubgoalCard } from "./SubgoalCard";
type FilterStatus = "candidate" | "approved" | "deferred" | "rejected";
const FILTERS: Array<{ key: FilterStatus; label: string }> = [
{ key: "candidate", label: "待审核" },
{ key: "approved", label: "已通过" },
{ key: "deferred", label: "已延后" },
{ key: "rejected", label: "已驳回" },
];
/**
* 内驱力视图:好奇问题(只读)+ 探索性次生目标(审核)。
* 次生目标 approve/defer/reject 仅标记,本期不自动执行(v1 边界)。
* 设计稿 — 好奇萤火 2 列网格 + 探索天平 + 阴阳制衡(置于 page.tsx)。
*/
export function DriveView({
characterId,
curiosities,
subgoals: initial,
counts: initialCounts,
}: {
characterId: string;
curiosities: CuriosityQuestion[];
subgoals: ExploratorySubgoal[];
counts: Record<FilterStatus, number>;
}) {
const [filter, setFilter] = useState<FilterStatus>("candidate");
const [subgoals, setSubgoals] = useState(initial);
const [counts, setCounts] = useState(initialCounts);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function switchFilter(next: FilterStatus) {
if (next === filter) return;
setFilter(next);
setLoading(true);
setError(null);
try {
const res = await driveApi.listSubgoals(characterId, {
status: next,
page_size: 50,
});
setSubgoals(res.items);
} catch (e) {
setError(describeError(e));
} finally {
setLoading(false);
}
}
async function action(id: string, act: "approve" | "defer" | "reject") {
setBusyId(id);
setError(null);
try {
if (act === "approve") await driveApi.approveSubgoal(id);
else if (act === "defer") await driveApi.deferSubgoal(id);
else await driveApi.rejectSubgoal(id);
setSubgoals((list) => list.filter((s) => s.id !== id));
setCounts((c) => {
const next = { ...c, candidate: Math.max(0, c.candidate - 1) };
if (act === "approve") next.approved += 1;
else if (act === "defer") next.deferred += 1;
else next.rejected += 1;
return next;
});
} catch (e) {
setError(describeError(e));
} finally {
setBusyId(null);
}
}
return (
<div className="space-y-6">
{/* 好奇萤火 */}
{curiosities.length > 0 && (
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 好奇{" "}
<span className="sub">由内而发的匮乏感 - 缺口记录,只读</span>
</div>
<div className="curio-grid">
{curiosities.map((c) => (
<CuriosityItem key={c.id} curiosity={c} />
))}
</div>
</section>
)}
{/* 探索性次生目标(待探 / 天平) */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 待探{" "}
<span className="sub">好奇转化的次生目标 · 增益与打扰的权衡</span>
</div>
<div className="flex flex-wrap gap-1" style={{ marginBottom: 14 }}>
{FILTERS.map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => switchFilter(key)}
className={cn(
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
filter === key
? "glass-strong text-patina"
: "text-muted hover:text-fg",
)}
>
{label}
<span className="ml-1.5 tabular-nums text-muted">
{counts[key]}
</span>
</button>
))}
</div>
{error && <ErrorState message={error} />}
{loading ? (
<CardGridSkeleton count={2} className="py-6" />
) : subgoals.length === 0 ? (
<EmptyState
title={filter === "candidate" ? "暂无待审核的探索目标" : "暂无目标"}
description={
filter === "candidate"
? "大周天发现世界缺口时会生成探索性次生目标到这里,供你审核。"
: undefined
}
/>
) : (
<div className="space-y-3">
{subgoals.map((s) => (
<SubgoalCard
key={s.id}
subgoal={s}
onAction={action}
busy={busyId === s.id}
/>
))}
</div>
)}
</section>
</div>
);
}
@@ -1,127 +0,0 @@
"use client";
import type { ExploratorySubgoal } from "@/types/api";
import { cn } from "@/lib/utils/cn";
const STATUS_LABEL: Record<string, string> = {
candidate: "待审核",
approved: "已通过",
deferred: "已延后",
rejected: "已驳回",
};
/** 探索天平:左盘增益 右盘成本 中间支点(risk) */
function BalanceScale({
gain,
cost,
risk,
}: {
gain: number;
cost: number;
risk: string;
}) {
const gainPct = Math.round(Math.max(0, Math.min(1, gain)) * 100);
const costPct = Math.round(Math.max(0, Math.min(1, cost)) * 100);
return (
<div className="balance">
<div className="pan gain">
<span className="plabel">预期增益</span>
<div className="ptrack">
<div className="pfill" style={{ width: `${gainPct}%` }} />
</div>
<span className="pval">{gain.toFixed(2)}</span>
</div>
<div className="fulcrum">
<div className="beam"></div>
<div className="pivot"></div>
<span className="risk">{risk}</span>
</div>
<div className="pan cost right">
<span className="plabel">打扰成本</span>
<div className="ptrack">
<div className="pfill" style={{ width: `${costPct}%` }} />
</div>
<span className="pval">{cost.toFixed(2)}</span>
</div>
</div>
);
}
export function SubgoalCard({
subgoal,
onAction,
busy,
}: {
subgoal: ExploratorySubgoal;
onAction: (id: string, action: "approve" | "defer" | "reject") => void;
busy: boolean;
}) {
const isCandidate = subgoal.status === "candidate";
return (
<div className={cn("sub-card", !isCandidate && "deferred")}>
<div className="sub-top">
<span className="sub-from">
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
width={11}
height={11}
>
<path d="M8 2v8M5 7l3 3 3-3M3 13h10" />
</svg>
好奇 → 探索
</span>
<span className="sub-title">{subgoal.title}</span>
<span
className={cn(
"sub-status",
isCandidate && "candidate",
subgoal.status === "approved" && "approved",
subgoal.status === "deferred" && "deferred",
)}
>
{STATUS_LABEL[subgoal.status] ?? subgoal.status}
</span>
</div>
<div className="sub-desc">{subgoal.description}</div>
<BalanceScale
gain={subgoal.expected_information_gain}
cost={subgoal.interruption_cost}
risk={subgoal.risk_level}
/>
{isCandidate && (
<div className="sub-actions">
<span className="vbtn note">approve 仅标记 · 本期不自动执行</span>
<button
type="button"
onClick={() => onAction(subgoal.id, "defer")}
disabled={busy}
className="vbtn"
>
延后
</button>
<button
type="button"
onClick={() => onAction(subgoal.id, "reject")}
disabled={busy}
className="vbtn"
>
驳回
</button>
<button
type="button"
onClick={() => onAction(subgoal.id, "approve")}
disabled={busy}
className="vbtn patina"
>
{busy ? "处理中…" : "准予探索"}
</button>
</div>
)}
</div>
);
}
+5 -23
View File
@@ -10,15 +10,10 @@ import {
MemoryIcon,
ChatIcon,
CharacterIcon,
CosmosIcon,
DriveIcon,
ModelIcon,
PersonalityIcon,
RunIcon,
SettingsIcon,
ToolsIcon,
WorldIcon,
WuxingIcon,
} from "@/components/ui/Icons";
import { Badge } from "@/components/ui/Badge";
import { FEATURE_FLAGS } from "@/lib/config";
@@ -43,38 +38,25 @@ interface NavGroup {
}
/**
* 主导航分组:灵台(对话/轨迹)、法身(人物/人格/演化)、
* 内景(内驱力/世界/时态)、藏识(记忆/归档)、百工(工具/模型/设置)。
* 每组不超过 4 项,符合 product-ui §1 导航 IA ≤8 项的建议;
* 分组后侧栏视觉有序,降低认知负荷。
* 主导航分组:灵台(镜树)、法身(灵胚/人物)、内景(世界)、
* 藏识(记忆/归档)、百工(工具/神识阁/设置)。
* 旧人格/内驱力/五行/灵枢/轨迹页随 M6 退役(人格=道体锚点+缘契,轨迹并入镜树)。
*/
const NAV_GROUPS: NavGroup[] = [
{
title: "灵台",
items: [
{ href: "/trees", label: "镜树", icon: <ChatIcon /> },
{ href: "/runs", label: "轨迹", icon: <RunIcon /> },
],
items: [{ href: "/trees", label: "镜树", icon: <ChatIcon /> }],
},
{
title: "法身",
items: [
{ href: "/embryos", label: "灵胚", icon: <CharacterIcon /> },
{ href: "/cosmos", label: "灵枢", icon: <CosmosIcon /> },
{ href: "/wuxing", label: "五行", icon: <WuxingIcon /> },
{ href: "/characters", label: "人物", icon: <CharacterIcon /> },
{ href: "/personality", label: "人格", icon: <PersonalityIcon /> },
],
},
{
title: "内景",
items: [
{
href: "/drive",
label: "内驱力",
icon: <DriveIcon />,
enabled: FEATURE_FLAGS.drivePanel,
},
{
href: "/world",
label: "世界",
@@ -86,7 +68,7 @@ const NAV_GROUPS: NavGroup[] = [
{
title: "藏识",
items: [
{ href: "/memory", label: "记忆", icon: <MemoryIcon /> },
{ href: "/memories", label: "记忆", icon: <MemoryIcon /> },
{ href: "/archive", label: "归档", icon: <ArchiveIcon /> },
],
},
@@ -1,80 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { personalityApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { PersonalityProposal } from "@/types/api";
/**
* 待审提案即时卡片:因后端暂无 GET /personality/proposals 列表端点,
* 仅展示刚提交(由 PersonalityEditor onProposed 传入)的建议,支持即时 approve/reject。
* 完整审批墙待后端补端点后实现。审核后 router.refresh()。
*/
export function PendingProposalCard({
proposal,
onResolved,
}: {
proposal: PersonalityProposal;
onResolved?: () => void;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fieldKeys = Object.keys(proposal.fields ?? {});
async function resolve(approve: boolean) {
setBusy(true);
setError(null);
try {
if (approve) await personalityApi.approveProposal(proposal.proposal_id);
else await personalityApi.rejectProposal(proposal.proposal_id);
onResolved?.();
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
return (
<div className="glass rounded-2xl px-5 py-4 ring-1 ring-cinnabar/30">
<div className="flex items-center gap-2">
<p className="font-serif text-sm font-semibold text-fg">待审人格建议</p>
<span className="rounded bg-cinnabar/15 px-1.5 py-0.5 text-[10px] text-cinnabar">
pending
</span>
</div>
<p className="mt-1.5 text-sm text-fg/90">
{proposal.reason || "(未填写原因)"}
</p>
{fieldKeys.length > 0 && (
<p className="mt-1 text-xs text-muted">
涉及字段:{fieldKeys.join("、")}
</p>
)}
<div className="mt-3 flex gap-2">
<Button
variant="primary"
size="sm"
loading={busy}
onClick={() => resolve(true)}
>
通过
</Button>
<Button
variant="danger"
size="sm"
loading={busy}
onClick={() => resolve(false)}
>
驳回
</Button>
</div>
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -1,78 +0,0 @@
import { Card } from "@/components/ui/Card";
import { Badge } from "@/components/ui/Badge";
import { BronzeMirror } from "@/components/ui/BronzeMirror";
import { EmptyState } from "@/components/shared/StateViews";
import { formatDateTime } from "@/lib/utils/format";
import type { PersonalityVersion } from "@/types/api";
const FIELDS = [
{ key: "identity_profile", label: "魂册身份" },
{ key: "role_destiny", label: "命格" },
{ key: "personality_seed", label: "人格种子" },
{ key: "expression_style", label: "表达风格" },
{ key: "mood_baseline", label: "情绪基线" },
{ key: "relationship_pattern", label: "关系模式" },
] as const;
/** 当前人格只读摘要:顶部九枢铜镜作为人格主视觉 */
export function PersonalitySummary({
personality,
}: {
personality: PersonalityVersion | null;
}) {
if (!personality) {
return (
<EmptyState
title="尚未生成人格"
description="当前人物尚未初始化人格版本,待后端写入后此处将展示只读摘要。"
/>
);
}
const hasAnyField = FIELDS.some((f) => personality[f.key]);
return (
<Card className="p-5">
<div className="mb-5 flex flex-col items-center gap-3">
<BronzeMirror size={88} state="steady" />
<div className="text-center">
<h2 className="font-serif text-xl font-semibold">当前人格</h2>
<p className="mt-0.5 text-xs text-muted">
版本 v{personality.version}
</p>
</div>
<Badge tone="accent">只读</Badge>
</div>
{personality.summary && (
<p className="mb-4 whitespace-pre-wrap text-sm text-fg/90">
{personality.summary}
</p>
)}
{hasAnyField ? (
<dl className="grid grid-cols-1 gap-x-6 gap-y-3 text-sm sm:grid-cols-2">
{FIELDS.map(({ key, label }) => {
const value = personality[key];
return (
<div key={key}>
<dt className="text-xs text-muted">{label}</dt>
<dd className="mt-0.5 whitespace-pre-wrap text-fg">
{value || "—"}
</dd>
</div>
);
})}
</dl>
) : (
<p className="text-sm text-muted">当前人格版本暂无可展示的明细字段。</p>
)}
{personality.created_at && (
<p className="mt-4 border-t border-border-glass pt-2 text-xs text-muted">
生成时间:{formatDateTime(personality.created_at)}
</p>
)}
</Card>
);
}
@@ -1,118 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { personalityApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import { formatDateTime } from "@/lib/utils/format";
import { cn } from "@/lib/utils/cn";
import type { PersonalityVersion } from "@/types/api";
/**
* 人格版本时间线:按版本号倒序展示历史,当前版本高亮;
* 非当前版本可「回滚」(二次确认)。回滚后 router.refresh() 以服务端数据为准。
*/
export function PersonalityVersionTimeline({
versions,
currentId,
}: {
versions: PersonalityVersion[];
currentId?: string | null;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirm, setConfirm] = useState<PersonalityVersion | null>(null);
const ordered = [...versions].sort((a, b) => b.version - a.version);
async function rollback() {
if (!confirm) return;
setBusy(true);
setError(null);
try {
await personalityApi.rollbackPersonality(confirm.id);
setConfirm(null);
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
return (
<div className="space-y-3">
{ordered.map((v) => {
const isCurrent = v.id === currentId;
return (
<div
key={v.id}
className={cn(
"glass rounded-2xl px-5 py-4",
isCurrent && "ring-2 ring-patina/40",
)}
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-serif text-sm font-semibold text-fg">
v{v.version}
</span>
{isCurrent && (
<span className="rounded bg-patina/15 px-1.5 py-0.5 text-[10px] text-patina">
当前
</span>
)}
<span className="text-[11px] text-muted">
{v.created_by ? `${v.created_by} · ` : ""}
{formatDateTime(v.created_at)}
</span>
</div>
{v.reason && (
<p className="mt-1.5 text-sm text-fg/90">{v.reason}</p>
)}
{v.identity_profile && (
<p className="mt-1 line-clamp-2 text-xs text-muted">
{v.identity_profile}
</p>
)}
{!isCurrent && (
<div className="mt-2">
<Button variant="ghost" size="sm" onClick={() => setConfirm(v)}>
回滚到此版本
</Button>
</div>
)}
</div>
);
})}
<Modal
open={confirm !== null}
onClose={() => setConfirm(null)}
title={`回滚到 v${confirm?.version ?? ""}?`}
footer={
<>
<Button variant="ghost" size="sm" onClick={() => setConfirm(null)}>
取消
</Button>
<Button
variant="danger"
size="sm"
loading={busy}
onClick={rollback}
>
确认回滚
</Button>
</>
}
>
回滚后当前人格指针将切换至此版本,被取代的版本会标记为已回滚。
</Modal>
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -1,222 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { PersonalityEditor } from "@/components/admin/PersonalityEditor";
import { PendingProposalCard } from "@/components/personality/PendingProposalCard";
import { PersonalityVersionTimeline } from "@/components/personality/PersonalityVersionTimeline";
import { cn } from "@/lib/utils/cn";
import type { PersonalityProposal, PersonalityVersion } from "@/types/api";
interface MirrorItem {
glyph: string;
name: string;
src: string;
tier: string;
}
/**
* 十镜映射(对齐 personality-core §7):根据后端字段值判断落地/预留。
*/
const MIRRORS: MirrorItem[] = [
{ glyph: "本", name: "本我镜", src: "identity_profile", tier: "L4" },
{ glyph: "命", name: "策命镜", src: "role_destiny", tier: "L4" },
{ glyph: "心", name: "明心镜", src: "values", tier: "L4" },
{ glyph: "波", name: "情波镜", src: "emotion_baseline", tier: "L2" },
{ glyph: "缘", name: "缘生镜", src: "relationship_pattern", tier: "L1" },
{ glyph: "作", name: "匠作镜", src: "expression_style", tier: "L1" },
{ glyph: "识", name: "慧识镜", src: "reasoning_style", tier: "L2" },
{ glyph: "世", name: "观世镜", src: "world_focus", tier: "L2" },
{ glyph: "律", name: "戒律镜", src: "safety_constraints", tier: "L4" },
{ glyph: "照", name: "返照镜", src: "reflection_goals", tier: "L3" },
];
/** traits 可读标签 */
const TRAIT_LABELS: Record<string, string> = {
openness: "开放",
conscientiousness: "尽责",
extraversion: "外向",
agreeableness: "宜人",
neuroticism: "情绪",
};
/**
* 人格工作区:十镜网格 + 种子带 + 明德条 + 编辑器 + 版本时间线。
* 设计稿 personality-preview.html 对齐 personality-core §7/§8。
*/
export function PersonalityWorkspace({
personality,
versions,
}: {
personality: PersonalityVersion;
versions: PersonalityVersion[];
}) {
const [pending, setPending] = useState<PersonalityProposal | null>(null);
// 十镜数据:给每个镜子注入实际字段值
const mirrors = useMemo(
() =>
MIRRORS.map((m) => ({
...m,
val: personality[m.src as keyof PersonalityVersion] as
| string
| undefined
| null,
})),
[personality],
);
// 提取 traits 权重条
const traits = personality.traits as
| Record<string, number>
| undefined
| null;
return (
<div className="space-y-5">
{/* ═══ 种子带(设计稿 seed-band)═══ */}
<div className="seed-band">
<div className="seed-mirror">
<span className="glyph">种</span>
</div>
<div className="seed-body">
<div className="seed-lab">人格种子 · personality_seed</div>
<div className="seed-text">
{personality.personality_seed || "未设定"}
</div>
</div>
<div className="tier-legend">
<span>
<b className="l1">L1</b> 表达 / 关系
</span>
<span>
<b className="l2">L2</b> 特质 / 情绪
</span>
<span>
<b className="l4">L4</b> 身份 / 命格 / 价值
</span>
</div>
</div>
{/* ═══ 十镜网格(设计稿 mirror-grid)═══ */}
<section>
<div className="section-label">
<span className="glyph">⟡</span> 十镜{" "}
<span className="sub">人格面板的 10 视角切片</span>
</div>
<div className="mirror-grid">
{mirrors.map((m) => {
const landed = !!m.val;
return (
<div key={m.src} className={cn("mcell", !landed && "reserved")}>
<div className="mc-head">
<div className="mc-glyph">{m.glyph}</div>
<div className="mc-titles">
<div className="mc-name">{m.name}</div>
<div className="mc-src">{m.src}</div>
</div>
<span className={cn("mc-status", landed && "landed")}>
{landed ? m.tier : "预留"}
</span>
</div>
{landed ? (
<div className="mc-val">
<span className="em">
{typeof m.val === "string" && m.val.length > 80
? m.val.slice(0, 80) + "…"
: typeof m.val === "object" && m.val !== null
? JSON.stringify(m.val).slice(0, 80)
: String(m.val ?? "")}
</span>
</div>
) : (
<div className="mc-val">待完整十镜落地</div>
)}
</div>
);
})}
</div>
</section>
{/* ═══ 人格权重(traits 条)═══ */}
{traits && Object.keys(traits).length > 0 && (
<section>
<h3 className="section-heading">人格权重</h3>
<div
className="mirror-grid"
style={{ gridTemplateColumns: "repeat(4, 1fr)", gap: 14 }}
>
{Object.entries(traits).map(([key, val]) => {
const label = TRAIT_LABELS[key] ?? key;
const pct = Math.min(
100,
Math.max(0, Math.round((val as number) * 100)),
);
const grade = pct >= 70 ? "高" : pct >= 40 ? "中" : "低";
return (
<div
key={key}
className="mcell landed"
style={{ minHeight: "auto" }}
>
<div className="mc-bars">
<div className="br">
<span className="tn">{label}</span>
<span className="tbar">
<span className="fl" style={{ width: `${pct}%` }} />
</span>
<span className="tv">{grade}</span>
</div>
</div>
</div>
);
})}
</div>
</section>
)}
{/* ═══ 明德恒定层(预留提示,设计稿 mingde-strip)═══ */}
<div className="mingde-strip">
<span className="seal">明德三镜</span>
<span className="desc">
道体恒定层(<code>spiritual_core</code>:见地 / 明心 /
守正)是世界观、价值观、道德观的结构化内核。 属 L4
人工确认,尚未落地独立字段。
</span>
<span className="tag">预留 · 待落地</span>
</div>
{/* ═══ 下方双栏:编辑器 + 版本层积 ═══ */}
<div className="lower-region">
{/* 左主栏:编辑器 + 待审卡片 */}
<div className="lower-main">
<PersonalityEditor
persona={personality}
onProposed={(p) => setPending(p)}
/>
{pending && pending.status === "pending" && (
<PendingProposalCard
proposal={pending}
onResolved={() => setPending(null)}
/>
)}
</div>
{/* 右栏:版本层积(设计稿 strata-rail) */}
{versions.length > 0 && (
<div className="strata-rail">
<div className="strata-card">
<h3 className="section-heading" style={{ marginBottom: 12 }}>
版本层积
</h3>
<PersonalityVersionTimeline
versions={versions}
currentId={personality.id}
/>
</div>
</div>
)}
</div>
</div>
);
}
@@ -1,173 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { EventCard } from "./EventCard";
import { PlanTraceCard } from "./PlanTraceCard";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { RunStatusBadge } from "@/components/ui/StatusBadge";
import { EmptyState } from "@/components/shared/StateViews";
import { runsApi } from "@/lib/api";
import { useRunStream } from "@/lib/hooks/useRunStream";
import { formatDateTime } from "@/lib/utils/format";
import type { Run, RunEvent } from "@/types/api";
import type { RunStatus } from "@/types/domain";
/**
* 运行轨迹视图:承接 Server Component 预取的 run + 事件,
* 并通过 SSE 实时追加新事件、同步 run 状态。
*/
export function RunTraceView({
runId,
initialRun,
initialEvents,
}: {
runId: string;
initialRun: Run | null;
initialEvents: RunEvent[];
}) {
const [run, setRun] = useState<Run | null>(initialRun);
const [events, setEvents] = useState<RunEvent[]>(initialEvents);
const seenSequences = useRef<Set<number>>(
new Set(initialEvents.map((e) => e.sequence)),
);
useRunStream(runId, {
onEvent: (event) => {
if (event.type === "heartbeat") return;
if (seenSequences.current.has(event.sequence)) return;
seenSequences.current.add(event.sequence);
setEvents((prev) =>
[...prev, event].sort((a, b) => a.sequence - b.sequence),
);
const nextStatus = statusFromEvent(event.type);
if (nextStatus) {
setRun((prev) => (prev ? { ...prev, status: nextStatus } : prev));
}
},
});
const [cancelling, setCancelling] = useState(false);
const canCancel =
run?.status === "queued" ||
run?.status === "running" ||
run?.status === "waiting_confirmation";
async function handleCancel() {
setCancelling(true);
try {
const res = await runsApi.cancelRun(runId);
// 乐观更新为后端返回状态;SSE run.cancelled 事件到达后会再次对齐
setRun((prev) =>
prev ? { ...prev, status: res.status as RunStatus } : prev,
);
} catch {
/* 取消失败不阻断:SSE 事件流会反馈最终状态 */
} finally {
setCancelling(false);
}
}
return (
<div className="space-y-4">
<Card className="p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<h2 className="font-mono text-sm text-fg/80">{runId}</h2>
{run && (
<p className="mt-1 text-xs text-muted">
{[
run.goal ? `目标:${run.goal}` : null,
run.model_hint ? `模型:${run.model_hint}` : null,
run.character_id ? `人物:${run.character_id}` : null,
]
.filter(Boolean)
.join(" · ")}
</p>
)}
</div>
<div className="flex items-center gap-2">
<RunStatusBadge status={run?.status ?? "queued"} />
{canCancel && (
<Button
size="sm"
variant="ghost"
onClick={handleCancel}
loading={cancelling}
>
取消运行
</Button>
)}
</div>
</div>
{run && (
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 font-mono text-xs text-muted">
<span>开始:{formatDateTime(run.started_at)}</span>
<span>结束:{formatDateTime(run.completed_at)}</span>
{durationLabel(run) && <span>耗时:{durationLabel(run)}</span>}
{run.usage?.total_tokens != null && (
<span>
token:{run.usage.total_tokens}
{run.usage.prompt_tokens != null &&
`(入 ${run.usage.prompt_tokens} / 出 ${run.usage.completion_tokens ?? "?"})`}
</span>
)}
</div>
)}
{run?.error_message && (
<p className="mt-2 rounded-md bg-cinnabar/10 px-2 py-1 text-xs text-cinnabar backdrop-blur-sm">
{run.error_message}
</p>
)}
</Card>
<PlanTraceCard events={events} />
<Card className="p-4">
<h3 className="mb-3 font-serif text-base font-semibold">事件轨迹</h3>
{events.length === 0 ? (
<EmptyState
title="暂无事件"
description="Run 启动后事件将按 sequence 顺序实时追加。"
/>
) : (
<div className="space-y-3">
{events.map((event) => (
<EventCard key={event.event_id} event={event} />
))}
</div>
)}
</Card>
</div>
);
}
function statusFromEvent(type: string): RunStatus | null {
switch (type) {
case "run.started":
return "running";
case "run.completed":
return "completed";
case "run.failed":
return "failed";
case "run.cancelled":
return "cancelled";
default:
return null;
}
}
/** 计算运行耗时(started → completed),不足 1s 显示 ms */
function durationLabel(run: Run): string | null {
if (!run.started_at || !run.completed_at) return null;
const ms =
new Date(run.completed_at).getTime() - new Date(run.started_at).getTime();
if (!Number.isFinite(ms) || ms < 0) return null;
if (ms < 1000) return `${ms}ms`;
const s = Math.round(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`;
}
+72
View File
@@ -976,3 +976,75 @@ export function ShieldCheckIcon(props: IconProps) {
</svg>
);
}
/** 境界(Lucide Globe) */
export function GlobeIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.6" />
<path
d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"
stroke="currentColor"
strokeWidth="1.6"
/>
<path d="M2 12h20" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
/** 地点(Lucide MapPin) */
export function MapPinIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<path
d="M20 10c0 4.99-5.54 10.19-7.4 11.79a1 1 0 0 1-1.2 0C9.54 20.19 4 14.99 4 10a8 8 0 0 1 16 0"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
}
/** 缘契(Lucide HeartHandshake) */
export function HeartHandshakeIcon(props: IconProps) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
aria-hidden
{...props}
>
<path
d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M12 5 9.04 7.96a2.17 2.17 0 0 0 0 3.08c.82.82 2.13.85 3 .07l2.07-1.9a2.5 2.5 0 0 1 3.44 0l.49.46"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -1,348 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import type { WorldEntity, WorldRelation } from "@/types/api";
interface Node {
id: string;
name: string;
type: string;
x: number;
y: number;
}
/** 简单力导向布局:将实体分布在同心圆上 */
function computeLayout(
entities: WorldEntity[],
_relations: WorldRelation[],
width: number,
height: number,
): Node[] {
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) * 0.35;
return entities.map((e, i) => {
const angle = (2 * Math.PI * i) / entities.length - Math.PI / 2;
return {
id: e.id,
name: e.name,
type: e.type,
x: centerX + radius * Math.cos(angle),
y: centerY + radius * Math.sin(angle),
};
});
}
/** 节点颜色映射(与设计稿 ent-disc 色系一致) */
function nodeColor(type: string): string {
const map: Record<string, string> = {
person: "var(--gilt)",
place: "var(--patina)",
object: "var(--cinnabar)",
concept: "var(--patina-bright)",
device: "var(--fg-dim)",
project: "var(--cinnabar-dim)",
task: "var(--patina-bright)",
};
return map[type] ?? "var(--muted)";
}
/** 节点字形映射 */
function nodeGlyph(type: string): string {
const map: Record<string, string> = {
person: "人",
self: "我",
place: "境",
object: "器",
concept: "念",
device: "器",
project: "业",
task: "事",
};
return map[type] ?? "·";
}
const TYPE_LABEL: Record<string, string> = {
self: "self · 自身",
person: "person · 人物",
project: "project · 事务",
concept: "concept · 概念",
device: "device · 设备",
place: "place · 地点",
object: "object · 物品",
task: "task · 任务",
};
const STATE_LABEL: Record<string, string> = {
fact: "事实",
observation: "观察",
inference: "推断",
hypothesis: "假设",
stale: "过期",
};
const STATE_COLOR: Record<string, string> = {
fact: "var(--patina)",
observation: "var(--patina-bright)",
inference: "var(--gilt)",
hypothesis: "var(--cinnabar)",
stale: "var(--fg-faint)",
};
/**
* 河图关系图谱(设计稿 world-preview.html §graph-region)。
* 带图例 + 状态分布侧栏;节点为字形 + type 色,关系为边(predicate 标签)。
*/
export function WorldRelationGraph({
entities,
relations,
}: {
entities: WorldEntity[];
relations: WorldRelation[];
}) {
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const W = 480;
const H = 320;
const nodes = useMemo(
() => computeLayout(entities, relations, W, H),
[entities, relations],
);
const nodeMap = useMemo(
() => Object.fromEntries(nodes.map((n) => [n.id, n])),
[nodes],
);
const connectedIds = useMemo(() => {
const set = new Set<string>();
relations.forEach((r) => {
set.add(r.subject_id);
set.add(r.object_id);
});
return set;
}, [relations]);
/* 状态分布 */
const stateDist = useMemo(() => {
const counts: Record<string, number> = {};
for (const e of entities) {
counts[e.state_type] = (counts[e.state_type] || 0) + 1;
}
const total = entities.length;
const order = ["fact", "observation", "inference", "hypothesis", "stale"];
return order
.filter((k) => counts[k])
.map((k) => ({
key: k,
label: STATE_LABEL[k] ?? k,
count: counts[k],
pct: total > 0 ? Math.round((counts[k] / total) * 100) : 0,
color: STATE_COLOR[k] ?? "var(--fg-faint)",
}));
}, [entities]);
if (entities.length === 0) {
return (
<div className="flex items-center justify-center rounded-xl border border-dashed border-border-glass py-12 text-sm text-muted">
暂无实体,图谱为空
</div>
);
}
if (relations.length === 0) {
return (
<div className="flex items-center justify-center rounded-xl border border-dashed border-border-glass py-12 text-sm text-muted">
暂无关系,新增关系后图谱自动生成
</div>
);
}
/* 唯一样式列表 */
const types = [...new Set(entities.map((e) => e.type))];
return (
<div className="graph-region">
<div className="graph-card">
<svg
viewBox={`0 0 ${W} ${H}`}
className="graph-svg"
role="img"
aria-label="河图关系图谱"
>
{/* 边 */}
{relations.map((r) => {
const src = nodeMap[r.subject_id];
const dst = nodeMap[r.object_id];
if (!src || !dst) return null;
const highlight =
hoveredNode === r.subject_id || hoveredNode === r.object_id;
const hi = r.confidence >= 0.8;
const mid = r.confidence >= 0.5 && r.confidence < 0.8;
const low = r.confidence < 0.5;
return (
<g key={r.id}>
<line
x1={src.x}
y1={src.y}
x2={dst.x}
y2={dst.y}
stroke={
highlight
? "var(--gilt)"
: low
? "var(--cinnabar-dim)"
: "var(--patina-dim)"
}
strokeWidth={highlight ? 2.5 : Math.max(1, r.confidence * 3)}
strokeDasharray={low ? "5 4" : undefined}
className={
low
? "edge tent"
: hi
? "edge hi"
: mid
? "edge mid"
: "edge"
}
/>
{/* 边的中点标签 */}
<g>
<rect
x={(src.x + dst.x) / 2 - r.predicate.length * 3.5}
y={(src.y + dst.y) / 2 - 10}
width={r.predicate.length * 7 + 4}
height={14}
rx={3}
fill="var(--ink)"
stroke="var(--rule)"
strokeWidth={0.5}
/>
<text
x={(src.x + dst.x) / 2}
y={(src.y + dst.y) / 2 - 3}
textAnchor="middle"
fontSize="9"
fill="var(--fg-faint)"
fontFamily='"JetBrains Mono", ui-monospace, monospace'
>
{r.predicate}
</text>
</g>
</g>
);
})}
{/* 节点 */}
{nodes.map((node) => {
const hasEdges = connectedIds.has(node.id);
const highlight = hoveredNode === node.id;
const color = nodeColor(node.type);
const opacity = hasEdges ? 1 : 0.55;
return (
<g
key={node.id}
onMouseEnter={() => setHoveredNode(node.id)}
onMouseLeave={() => setHoveredNode(null)}
style={{ cursor: "pointer" }}
opacity={opacity}
>
<circle
cx={node.x}
cy={node.y}
r={highlight ? 26 : 24}
fill="var(--ink)"
stroke={color}
strokeWidth={highlight ? 2 : 1.5}
/>
<circle
cx={node.x}
cy={node.y}
r={highlight ? 26 : 24}
fill={color}
fillOpacity={0.16}
stroke="none"
/>
<text
x={node.x}
y={node.y}
textAnchor="middle"
dominantBaseline="central"
fontSize="16"
fontWeight={700}
fontFamily='"Songti SC", serif'
fill={color}
>
{nodeGlyph(node.type)}
</text>
<text
x={node.x}
y={node.y + 20}
textAnchor="middle"
fontSize="10"
fill="var(--fg-dim)"
fontFamily="inherit"
>
{node.name.slice(0, 8)}
</text>
</g>
);
})}
</svg>
</div>
{/* 图谱侧栏:图例 + 状态分布 */}
<div className="graph-side">
<div className="side-card">
<div className="side-title">图例</div>
{types.map((t) => (
<div key={t} className="legend-row">
<span
className="legend-dot"
style={{
borderColor: nodeColor(t),
background: `color-mix(in srgb, ${nodeColor(t)} 16%, transparent)`,
}}
/>
{TYPE_LABEL[t] ?? t}
</div>
))}
<div style={{ marginTop: 6 }}>
<div className="legend-row">
<span className="legend-line"></span>
高置信关系
</div>
<div className="legend-row">
<span className="legend-line tent"></span>
假设态 · 待验
</div>
</div>
</div>
<div className="side-card">
<div className="side-title">状态分布</div>
{stateDist.map((d) => (
<div key={d.key} className="dist-bar">
<span className="dk">{d.label}</span>
<div className="dtrack">
<div
className="dfill"
style={{
width: `${d.pct}%`,
background: d.color,
opacity:
d.key === "hypothesis"
? 0.8
: d.key === "stale"
? 1
: undefined,
}}
/>
</div>
<span className="dv">{d.count}</span>
</div>
))}
</div>
</div>
</div>
);
}
@@ -1,189 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader } from "@/components/ui/Card";
import { worldApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { WorldEntity, WorldRelation } from "@/types/api";
/** 关系列表项 — 带删除 */
function RelationRow({
relation,
entityName,
onDelete,
busy,
}: {
relation: WorldRelation;
entityName: (id: string) => string;
onDelete: (id: string) => void;
busy: boolean;
}) {
return (
<li className="flex flex-wrap items-center gap-2 rounded-lg bg-glass/40 px-3 py-2 text-sm">
<span className="font-medium text-fg">
{entityName(relation.subject_id)}
</span>
<span className="rounded bg-gold/15 px-1.5 py-0.5 text-xs text-gold">
{relation.predicate}
</span>
<span className="font-medium text-fg">
{entityName(relation.object_id)}
</span>
<span className="font-mono text-[10px] text-muted">
{relation.confidence.toFixed(2)}
</span>
<button
type="button"
onClick={() => onDelete(relation.id)}
disabled={busy}
className="ml-auto text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</li>
);
}
/**
* 世界关系管理(河图枢,独立 /world/relations)。
* 列出 subject-predicate-object 三元组;新建时从实体列表选主体/客体 + 填谓词。
* 创建后 router.refresh()。实体本身的 CRUD 由 WorldEntityList(admin 域)承担。
*/
export function WorldRelationManager({
relations,
entities,
}: {
relations: WorldRelation[];
entities: WorldEntity[];
}) {
const router = useRouter();
const [showAdd, setShowAdd] = useState(false);
const [subjectId, setSubjectId] = useState("");
const [predicate, setPredicate] = useState("");
const [objectId, setObjectId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [relationList, setRelationList] = useState(relations);
const entityName = (id: string) =>
entities.find((e) => e.id === id)?.name ?? id;
async function add() {
if (!subjectId || !predicate.trim() || !objectId) {
setError("请选择主体与客体,并填写关系谓词。");
return;
}
setBusy(true);
setError(null);
try {
await worldApi.createWorldRelation({
subject_id: subjectId,
predicate: predicate.trim(),
object_id: objectId,
});
setShowAdd(false);
setSubjectId("");
setPredicate("");
setObjectId("");
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
async function remove(id: string) {
setBusy(true);
setError(null);
try {
await worldApi.deleteWorldRelation(id);
setRelationList((p) => p.filter((r) => r.id !== id));
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
const inputCls =
"rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
return (
<Card className="p-5">
<CardHeader
title="世界关系"
subtitle="实体间的三元组(主体—谓词—客体),独立 /world/relations 域。"
action={
<Button
variant="ghost"
size="sm"
onClick={() => setShowAdd((s) => !s)}
>
{showAdd ? "收起" : "+ 新增关系"}
</Button>
}
/>
{showAdd && (
<div className="mb-3 rounded-xl border border-border-glass bg-glass/50 p-3">
<div className="flex flex-wrap items-center gap-2">
<select
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
className={inputCls}
aria-label="关系主体"
>
<option value="">主体…</option>
{entities.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</select>
<input
value={predicate}
onChange={(e) => setPredicate(e.target.value)}
placeholder="谓词(如 栖居于)"
className={`${inputCls} min-w-[8rem] flex-1`}
/>
<select
value={objectId}
onChange={(e) => setObjectId(e.target.value)}
className={inputCls}
aria-label="关系客体"
>
<option value="">客体…</option>
{entities.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</select>
<Button variant="primary" size="sm" loading={busy} onClick={add}>
新增
</Button>
</div>
</div>
)}
{relationList.length === 0 ? (
<p className="text-sm text-muted">暂无关系。新增以连接世界实体。</p>
) : (
<ul className="space-y-2">
{relationList.map((r) => (
<RelationRow
key={r.id}
relation={r}
entityName={entityName}
onDelete={remove}
busy={busy}
/>
))}
</ul>
)}
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</Card>
);
}
@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import {
GlobeIcon,
MapPinIcon,
PlusIcon,
ScrollTextIcon,
} from "@/components/ui/Icons";
import { Button } from "@/components/ui/Button";
import type { PersistentWorld, WorldEntity } from "@/lib/api/worlds";
import { worldApi } from "@/lib/api";
import { cn } from "@/lib/utils/cn";
interface Props {
initialWorlds: PersistentWorld[];
error: string | null;
}
const KIND_LABEL: Record<string, string> = {
character: "人物",
location: "地点",
organization: "组织",
item: "物品",
concept: "概念",
};
/**
* 世界工作台:境界目录 + 详情(实体/关系/正史概览)。
* 境界正史(canonizations scope=persistent_world)只读呈现——境界不进真界本相。
*/
export function WorldWorkbench({ initialWorlds, error }: Props) {
const [worlds, setWorlds] = useState(initialWorlds);
const [selectedId, setSelectedId] = useState<string | null>(
initialWorlds[0]?.id ?? null,
);
const [entities, setEntities] = useState<WorldEntity[]>([]);
const [chronicleCount, setChronicleCount] = useState(0);
const [showCreate, setShowCreate] = useState(initialWorlds.length === 0);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const selected = worlds.find((w) => w.id === selectedId) ?? null;
async function selectWorld(id: string) {
setSelectedId(id);
setEntities([]);
setChronicleCount(0);
try {
const [ents, chronicle] = await Promise.all([
worldApi.listWorldEntities(id),
worldApi.worldChronicle(id),
]);
setEntities(ents);
setChronicleCount(chronicle.entries.length);
} catch {
/* 详情失败保持空态 */
}
}
async function handleCreate() {
if (!name.trim()) {
setNotice("境界名不能为空。");
return;
}
setBusy(true);
try {
const created = await worldApi.createWorld({
name: name.trim(),
description,
});
setWorlds(await worldApi.listWorlds());
setShowCreate(false);
setName("");
setDescription("");
setSelectedId(created.id);
await selectWorld(created.id);
setNotice(`境界「${created.name}」已建立`);
} catch (e) {
setNotice(e instanceof Error ? e.message : "建立失败");
} finally {
setBusy(false);
}
}
if (error) {
return (
<div className="glass rounded-2xl p-6 text-sm text-muted">{error}</div>
);
}
return (
<div className="flex flex-col gap-4">
{notice && (
<div className="glass rounded-xl px-4 py-2 text-sm text-patina">
{notice}
</div>
)}
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<GlobeIcon className="size-4 text-patina" />
境界
</h3>
<span className="set-card-eng">Persistent Worlds</span>
</div>
<p className="set-card-desc">
版本化世界设定与叙事正史;境界内容不进入真界本相(跨界引用只是引文)。
</p>
<div className="mb-3 flex items-center justify-between">
<span className="text-xs text-muted">共 {worlds.length} 个境界</span>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreate((v) => !v)}
>
<PlusIcon className="mr-1 size-4" />
{showCreate ? "收起" : "建立境界"}
</Button>
</div>
{showCreate && (
<div className="mb-4 grid gap-3 rounded-xl border border-rule bg-surface p-4 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs text-muted">境界名</span>
<input
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="如:云荒"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">描述</span>
<input
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="世界背景一句话"
/>
</label>
<div className="sm:col-span-2">
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={busy}
>
建立
</Button>
</div>
</div>
)}
{worlds.length === 0 && !showCreate ? (
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
尚未建立境界。
</p>
) : (
<ul className="divide-y divide-rule rounded-xl border border-rule">
{worlds.map((w) => (
<li key={w.id}>
<button
type="button"
className={cn(
"flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-hi",
selectedId === w.id && "bg-surface",
)}
onClick={() => selectWorld(w.id)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-serif text-sm text-fg">
{w.name}
</span>
{w.lorebook_id && (
<span className="rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
世谱已接
</span>
)}
</div>
<p className="mt-0.5 line-clamp-1 text-xs text-muted">
{w.description || "(无描述)"}
</p>
</div>
<span className="shrink-0 font-mono text-xs text-muted">
正史 {w.canon_count} 条
</span>
</button>
</li>
))}
</ul>
)}
</div>
{selected && (
<div className="set-card">
<div className="set-card-head">
<h3 className="set-card-title flex items-center gap-2">
<MapPinIcon className="size-4 text-gold" />
{selected.name} · 实体
</h3>
<span className="set-card-eng">Entities</span>
</div>
<p className="set-card-desc">
河图世界认知(人物/地点/组织/物品);实体归属境界作用域。
</p>
{entities.length === 0 ? (
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
该境界暂无实体(实体经归真与认知管线渐进形成,或由 API 登记)。
</p>
) : (
<ul className="divide-y divide-rule rounded-xl border border-rule">
{entities.map((e) => (
<li key={e.id} className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
{KIND_LABEL[e.kind] ?? e.kind}
</span>
<span className="font-serif text-sm text-fg">{e.name}</span>
</div>
{e.description && (
<p className="mt-0.5 line-clamp-2 text-xs text-muted">
{e.description}
</p>
)}
</li>
))}
</ul>
)}
<div className="mt-3 flex items-center gap-2 text-xs text-muted">
<ScrollTextIcon className="size-3.5" />
境界正史 {chronicleCount || selected.canon_count}{" "}
条(只读;不进真界本相)
</div>
</div>
)}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
/**
* 缘契 API Client:提议—问心—铭契、撤回(立即生效)、边界与事件历史。
*
* 多维关系并存,禁单一好感度(后端拒绝 affinity 形态)。
*/
import { apiGet, apiPatch, apiPost } from "./client";
/** 缘契(当前生效版本) */
export interface Covenant {
id: string;
character_id: string;
dimensions: Record<string, string>;
primary_title: string | null;
boundaries: Record<string, unknown>;
adult_consent: boolean;
status: "pending" | "sealed" | "withdrawn" | string;
version: number;
created_at: string | null;
updated_at: string | null;
}
/** 缘契事件(不可变历史) */
export interface CovenantEvent {
id: string;
type: "propose" | "wenxin" | "seal" | "withdraw" | "boundary" | string;
actor: "user" | "character" | string;
payload: Record<string, unknown>;
created_at: string | null;
}
/** 提议缘契 */
export function proposeCovenant(body: {
character_id: string;
dimensions: Record<string, string>;
primary_title?: string | null;
boundaries?: Record<string, unknown>;
}) {
return apiPost<Covenant>("/covenants", body);
}
/** 缘契清单(不含已撤回) */
export function listCovenants() {
return apiGet<Covenant[]>("/covenants");
}
/** 人物当前生效缘契 */
export function getActiveCovenant(characterId: string) {
return apiGet<Covenant | null>(
`/covenants/by-character/${encodeURIComponent(characterId)}`,
);
}
/** 问心:人物自陈候选 */
export function wenxinCovenant(
covenantId: string,
body: { candidates: Record<string, string>; note?: string | null },
) {
return apiPost<{ covenant_id: string; recorded: boolean }>(
`/covenants/${encodeURIComponent(covenantId)}/wenxin`,
body,
);
}
/** 铭契:确认最终维度并生效 */
export function sealCovenant(
covenantId: string,
body: {
dimensions: Record<string, string>;
boundaries?: Record<string, unknown>;
},
) {
return apiPost<Covenant>(
`/covenants/${encodeURIComponent(covenantId)}/seal`,
body,
);
}
/** 撤回(立即生效) */
export function withdrawCovenant(covenantId: string, reason?: string | null) {
return apiPost<{ covenant_id: string; status: string; effective: string }>(
`/covenants/${encodeURIComponent(covenantId)}/withdraw`,
{ reason: reason ?? null },
);
}
/** 边界变更(立即生效) */
export function updateBoundaries(
covenantId: string,
boundaries: Record<string, unknown>,
) {
return apiPatch<Covenant>(
`/covenants/${encodeURIComponent(covenantId)}/boundaries`,
{ boundaries },
);
}
/** 事件历史(撤回后仍可查证) */
export function listCovenantEvents(covenantId: string) {
return apiGet<CovenantEvent[]>(
`/covenants/${encodeURIComponent(covenantId)}/events`,
);
}
-57
View File
@@ -1,57 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
import { apiGet, apiPost } from "./client";
import {
approveSubgoal,
deferSubgoal,
listCuriosities,
listSubgoals,
rejectSubgoal,
} from "./drive";
describe("drive api", () => {
it("listCuriosities 构造 path 与 query", async () => {
vi.mocked(apiGet).mockResolvedValue({
items: [],
page: 1,
page_size: 20,
total: 0,
});
await listCuriosities("char_1", { source: "memory_gap" });
expect(apiGet).toHaveBeenCalledWith("/characters/char_1/curiosities", {
source: "memory_gap",
});
});
it("listSubgoals 构造 path 与 status query", async () => {
vi.mocked(apiGet).mockResolvedValue({
items: [],
page: 1,
page_size: 20,
total: 0,
});
await listSubgoals("char_1", { status: "candidate" });
expect(apiGet).toHaveBeenCalledWith("/characters/char_1/subgoals", {
status: "candidate",
});
});
it("approve/defer/reject 各自 POST 正确 path", async () => {
vi.mocked(apiPost).mockResolvedValue({ status: "approved" } as never);
await approveSubgoal("subgoal_1");
expect(apiPost).toHaveBeenCalledWith("/subgoals/subgoal_1/approve");
vi.mocked(apiPost).mockResolvedValue({ status: "deferred" } as never);
await deferSubgoal("subgoal_1");
expect(apiPost).toHaveBeenCalledWith("/subgoals/subgoal_1/defer");
vi.mocked(apiPost).mockResolvedValue({ status: "rejected" } as never);
await rejectSubgoal("subgoal_1");
expect(apiPost).toHaveBeenCalledWith("/subgoals/subgoal_1/reject");
});
});
-48
View File
@@ -1,48 +0,0 @@
/**
* 内驱力 API Client(对应 docs/domain/core-domains.md §5)。
* 大周天生成的好奇问题与探索性次生目标;次生目标 approve/defer/reject 仅标记不执行。
*/
import { apiGet, apiPost } from "./client";
import type {
CuriosityQuestion,
ExploratorySubgoal,
Paginated,
} from "@/types/api";
/** 列出好奇问题(按 source 过滤) */
export function listCuriosities(
characterId: string,
params?: { source?: string; page?: number; page_size?: number },
) {
return apiGet<Paginated<CuriosityQuestion>>(
`/characters/${characterId}/curiosities`,
params,
);
}
/** 列出探索性次生目标(按 status 过滤) */
export function listSubgoals(
characterId: string,
params?: { status?: string; page?: number; page_size?: number },
) {
return apiGet<Paginated<ExploratorySubgoal>>(
`/characters/${characterId}/subgoals`,
params,
);
}
/** 通过次生目标(status → approved,不自动执行) */
export function approveSubgoal(subgoalId: string) {
return apiPost<ExploratorySubgoal>(`/subgoals/${subgoalId}/approve`);
}
/** 延后次生目标(status → deferred) */
export function deferSubgoal(subgoalId: string) {
return apiPost<ExploratorySubgoal>(`/subgoals/${subgoalId}/defer`);
}
/** 驳回次生目标(status → rejected) */
export function rejectSubgoal(subgoalId: string) {
return apiPost<ExploratorySubgoal>(`/subgoals/${subgoalId}/reject`);
}
+2 -3
View File
@@ -9,8 +9,6 @@ export * from "./client";
export * as charactersApi from "./characters";
export * as runsApi from "./runs";
export * as sandboxApi from "./sandbox";
export * as personalityApi from "./personality";
export * as driveApi from "./drive";
export * as healthApi from "./health";
export * as toolsApi from "./tools";
export * as safetyApi from "./safety";
@@ -18,9 +16,10 @@ export * as adminApi from "./admin";
export * as settingsApi from "./settings";
export * as meApi from "./me";
export * as baigongApi from "./baigong";
export * as covenantApi from "./covenants";
export * as worldApi from "./worlds";
export * as importsApi from "./imports";
export * as extractionApi from "./extraction";
export * as worldApi from "./world";
export * as memoriesApi from "./memories";
export * as lorebooksApi from "./lorebooks";
export * as skillsApi from "./skills";
-73
View File
@@ -1,73 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
apiRequest: vi.fn(),
}));
import { apiGet, apiPost, apiRequest } from "./client";
import {
approveProposal,
getCurrentPersonality,
listPersonalityVersions,
proposeUpdate,
rejectProposal,
rollbackPersonality,
} from "./personality";
describe("personality api", () => {
it("getCurrentPersonality GET /personality/current", async () => {
vi.mocked(apiGet).mockResolvedValue({ id: "pv_1" });
await getCurrentPersonality({ character_id: "char_1" });
expect(apiGet).toHaveBeenCalledWith("/personality/current", {
character_id: "char_1",
});
});
it("proposeUpdate POST /personality/propose-update", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "prop_1" });
await proposeUpdate({
fields: { expression_style: "简洁清晰" },
reason: "优化表达",
});
expect(apiPost).toHaveBeenCalledWith("/personality/propose-update", {
fields: { expression_style: "简洁清晰" },
reason: "优化表达",
});
});
it("listPersonalityVersions GET /personality/versions", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listPersonalityVersions({ page: 1, page_size: 20 });
expect(apiGet).toHaveBeenCalledWith("/personality/versions", {
page: 1,
page_size: 20,
});
});
it("approveProposal POST /personality/proposals/:id/approve", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "prop_1" });
await approveProposal("prop_1");
expect(apiPost).toHaveBeenCalledWith(
"/personality/proposals/prop_1/approve",
);
});
it("rejectProposal POST /personality/proposals/:id/reject", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "prop_1" });
await rejectProposal("prop_1");
expect(apiPost).toHaveBeenCalledWith(
"/personality/proposals/prop_1/reject",
);
});
it("rollbackPersonality POST 带 query 参数", async () => {
vi.mocked(apiRequest).mockResolvedValue({ id: "pv_1" });
await rollbackPersonality("pv_1", "char_1");
expect(apiRequest).toHaveBeenCalledWith(
"/personality/versions/pv_1/rollback",
{ method: "POST", query: { character_id: "char_1" } },
);
});
});
-66
View File
@@ -1,66 +0,0 @@
/**
* 人格 API Client(对应 后端生成的 OpenAPI 契约 §11)。
* 当前开发周期要求只读展示当前人格版本;propose-update 为增强能力(审核后写入)。
*/
import { apiGet, apiPost, apiRequest } from "./client";
import type {
Paginated,
PersonalityProposal,
PersonalityVersion,
ProposePersonalityUpdateRequest,
} from "@/types/api";
/** 获取当前激活人物的人格版本(只读摘要) */
export function getCurrentPersonality(params?: { character_id?: string }) {
return apiGet<PersonalityVersion>("/personality/current", params);
}
/** 提交人格更新建议(§11,核心人格 L4 变更审核通过才写入新版本) */
export function proposeUpdate(body: ProposePersonalityUpdateRequest) {
return apiPost<PersonalityProposal>("/personality/propose-update", body);
}
/** 人格版本历史(GET /personality/versions,省略 character_id 取当前激活人物) */
export function listPersonalityVersions(params?: {
character_id?: string;
page?: number;
page_size?: number;
}): Promise<Paginated<PersonalityVersion>> {
return apiGet<Paginated<PersonalityVersion>>("/personality/versions", params);
}
/** 审核通过人格建议(POST /personality/proposals/{id}/approve,派生新版本并切换指针) */
export function approveProposal(
proposalId: string,
): Promise<PersonalityProposal> {
return apiPost<PersonalityProposal>(
`/personality/proposals/${proposalId}/approve`,
);
}
/** 驳回人格建议(POST /personality/proposals/{id}/reject) */
export function rejectProposal(
proposalId: string,
): Promise<PersonalityProposal> {
return apiPost<PersonalityProposal>(
`/personality/proposals/${proposalId}/reject`,
);
}
/**
* 回滚到指定人格版本(POST /personality/versions/{id}/rollback)。
* character_id 为 query 参数——apiPost 的 extra 已 Omit query,故直接用 apiRequest。
*/
export function rollbackPersonality(
versionId: string,
characterId?: string,
): Promise<PersonalityVersion> {
return apiRequest<PersonalityVersion>(
`/personality/versions/${versionId}/rollback`,
{
method: "POST",
query: characterId ? { character_id: characterId } : undefined,
},
);
}
-76
View File
@@ -1,76 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
import { apiGet, apiPost } from "./client";
import {
approveRunConfirmation,
cancelRun,
denyRunConfirmation,
followUpRun,
getRun,
listRunEvents,
steerRun,
} from "./runs";
describe("runs api", () => {
it("getRun GET /runs/:id", async () => {
vi.mocked(apiGet).mockResolvedValue({ id: "run_1", status: "completed" });
const result = await getRun("run_1");
expect(apiGet).toHaveBeenCalledWith("/runs/run_1");
expect(result.status).toBe("completed");
});
it("cancelRun POST /runs/:id/cancel", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "run_1", status: "cancelled" });
await cancelRun("run_1");
expect(apiPost).toHaveBeenCalledWith("/runs/run_1/cancel");
});
it("steerRun POST /runs/:id/steer with content", async () => {
vi.mocked(apiPost).mockResolvedValue({ accepted: true });
await steerRun("run_1", "改用表格输出");
expect(apiPost).toHaveBeenCalledWith("/runs/run_1/steer", {
content: "改用表格输出",
});
});
it("followUpRun POST /runs/:id/follow-up with content", async () => {
vi.mocked(apiPost).mockResolvedValue({ accepted: true });
await followUpRun("run_1", "再总结一次要点");
expect(apiPost).toHaveBeenCalledWith("/runs/run_1/follow-up", {
content: "再总结一次要点",
});
});
it("approveRunConfirmation POST 确认批准", async () => {
vi.mocked(apiPost).mockResolvedValue({
status: "resolved",
approved: true,
});
await approveRunConfirmation("run_1", "confirm_abc");
expect(apiPost).toHaveBeenCalledWith(
"/runs/run_1/confirmations/confirm_abc/approve",
);
});
it("denyRunConfirmation POST 确认拒绝", async () => {
vi.mocked(apiPost).mockResolvedValue({
status: "resolved",
approved: false,
});
await denyRunConfirmation("run_1", "confirm_abc");
expect(apiPost).toHaveBeenCalledWith(
"/runs/run_1/confirmations/confirm_abc/deny",
);
});
it("listRunEvents GET /runs/:id/events", async () => {
vi.mocked(apiGet).mockResolvedValue([]);
await listRunEvents("run_1");
expect(apiGet).toHaveBeenCalledWith("/runs/run_1/events");
});
});
-65
View File
@@ -1,65 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("./client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
import { apiGet, apiPost } from "./client";
import {
createWorldEntity,
createWorldRelation,
getWorldEntity,
listWorldEntities,
listWorldRelations,
} from "./world";
describe("world api", () => {
it("listWorldEntities GET /world/entities 支持分页过滤", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listWorldEntities({ page: 1, page_size: 20, type: "person" });
expect(apiGet).toHaveBeenCalledWith("/world/entities", {
page: 1,
page_size: 20,
type: "person",
});
});
it("createWorldEntity POST /world/entities", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "entity_1" });
await createWorldEntity({ type: "concept", name: "测试" });
expect(apiPost).toHaveBeenCalledWith("/world/entities", {
type: "concept",
name: "测试",
});
});
it("getWorldEntity GET /world/entities/:id", async () => {
vi.mocked(apiGet).mockResolvedValue({ id: "entity_1" });
await getWorldEntity("entity_1");
expect(apiGet).toHaveBeenCalledWith("/world/entities/entity_1");
});
it("listWorldRelations GET /world/relations", async () => {
vi.mocked(apiGet).mockResolvedValue({ items: [], total: 0 });
await listWorldRelations({ page: 1, page_size: 10 });
expect(apiGet).toHaveBeenCalledWith("/world/relations", {
page: 1,
page_size: 10,
});
});
it("createWorldRelation POST /world/relations", async () => {
vi.mocked(apiPost).mockResolvedValue({ id: "rel_1" });
await createWorldRelation({
subject_id: "e1",
predicate: "related_to",
object_id: "e2",
});
expect(apiPost).toHaveBeenCalledWith("/world/relations", {
subject_id: "e1",
predicate: "related_to",
object_id: "e2",
});
});
});
-58
View File
@@ -1,58 +0,0 @@
/**
* 世界模型 API(河图枢,独立 /world 域)。
* 实体列表/创建 + 关系图谱(relations)。独立域无 PATCH/DELETE——实体编辑/删除仍走
* adminApi(保留 WorldEntityList 的完整 CRUD);本模块提供独立域的分页查询与关系管理。
* 世界实体候选审核(approve/reject)见 extractionApi。
*/
import { apiDelete, apiGet, apiPost } from "./client";
import type {
CreateWorldEntityRequest,
CreateWorldRelationRequest,
Paginated,
WorldEntity,
WorldRelation,
} from "@/types/api";
/** 列出世界实体(GET /world/entities,独立域分页) */
export function listWorldEntities(params?: {
character_id?: string;
type?: string;
page?: number;
page_size?: number;
}): Promise<Paginated<WorldEntity>> {
return apiGet<Paginated<WorldEntity>>("/world/entities", params);
}
/** 创建世界实体(POST /world/entities) */
export function createWorldEntity(
body: CreateWorldEntityRequest,
): Promise<WorldEntity> {
return apiPost<WorldEntity>("/world/entities", body);
}
/** 获取单个世界实体(GET /world/entities/{id}) */
export function getWorldEntity(entityId: string): Promise<WorldEntity> {
return apiGet<WorldEntity>(`/world/entities/${entityId}`);
}
/** 列出世界关系(GET /world/relations) */
export function listWorldRelations(params?: {
character_id?: string;
page?: number;
page_size?: number;
}): Promise<Paginated<WorldRelation>> {
return apiGet<Paginated<WorldRelation>>("/world/relations", params);
}
/** 创建世界关系(POST /world/relations,subject-predicate-object 三元组) */
export function createWorldRelation(
body: CreateWorldRelationRequest,
): Promise<WorldRelation> {
return apiPost<WorldRelation>("/world/relations", body);
}
/** 删除世界关系(DELETE /world/relations/{relation_id}) */
export function deleteWorldRelation(relationId: string): Promise<void> {
return apiDelete<void>(`/world/relations/${relationId}`);
}
+131
View File
@@ -0,0 +1,131 @@
/**
* 河图世界 API Client:境界 CRUD、实体关系、境界正史。
*/
import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
/** 境界 */
export interface PersistentWorld {
id: string;
name: string;
description: string;
rules: Record<string, unknown>;
lorebook_id: string | null;
canon_count: number;
status: string;
created_at: string | null;
updated_at: string | null;
}
/** 世界实体 */
export interface WorldEntity {
id: string;
kind: string;
name: string;
description: string;
attrs: Record<string, unknown>;
}
/** 世界关系 */
export interface WorldRelation {
id: string;
source: string;
target: string;
relation: string;
note: string;
}
/** 境界正史条目 */
export interface ChronicleEntry {
canonization_id: string;
tree_id: string;
endpoint_node_id: string;
path_nodes: string[];
created_at: string | null;
}
export function createWorld(body: {
name: string;
description?: string;
rules?: Record<string, unknown>;
lorebook_id?: string | null;
}) {
return apiPost<PersistentWorld>("/worlds", body);
}
export function listWorlds() {
return apiGet<PersistentWorld[]>("/worlds");
}
export function getWorld(worldId: string) {
return apiGet<PersistentWorld>(`/worlds/${encodeURIComponent(worldId)}`);
}
export function updateWorld(
worldId: string,
body: {
name?: string;
description?: string;
rules?: Record<string, unknown>;
lorebook_id?: string | null;
},
) {
return apiPatch<PersistentWorld>(
`/worlds/${encodeURIComponent(worldId)}`,
body,
);
}
export function archiveWorld(worldId: string) {
return apiDelete<{ world_id: string; status: string }>(
`/worlds/${encodeURIComponent(worldId)}`,
);
}
export function addWorldEntity(
worldId: string,
body: {
kind?: string;
name: string;
description?: string;
attrs?: Record<string, unknown>;
},
) {
return apiPost<WorldEntity>(
`/worlds/${encodeURIComponent(worldId)}/entities`,
body,
);
}
export function listWorldEntities(worldId: string) {
return apiGet<WorldEntity[]>(
`/worlds/${encodeURIComponent(worldId)}/entities`,
);
}
export function addWorldRelation(
worldId: string,
body: {
source_entity_id: string;
target_entity_id: string;
relation: string;
note?: string;
},
) {
return apiPost<WorldRelation>(
`/worlds/${encodeURIComponent(worldId)}/relations`,
body,
);
}
export function listWorldRelations(worldId: string) {
return apiGet<WorldRelation[]>(
`/worlds/${encodeURIComponent(worldId)}/relations`,
);
}
export function worldChronicle(worldId: string) {
return apiGet<{ world_id: string; entries: ChronicleEntry[] }>(
`/worlds/${encodeURIComponent(worldId)}/chronicle`,
);
}
-1
View File
@@ -26,7 +26,6 @@ export const API_SSE_ROOT = "/api/sse";
export const FEATURE_FLAGS = {
debugPanel: process.env.NEXT_PUBLIC_ENABLE_DEBUG_PANEL !== "false",
avatar: process.env.NEXT_PUBLIC_ENABLE_AVATAR === "true",
drivePanel: process.env.NEXT_PUBLIC_ENABLE_DRIVE_PANEL !== "false",
worldPanel: process.env.NEXT_PUBLIC_ENABLE_WORLD_PANEL !== "false",
/** 辰正时态面板(默认启用) */
timePanel: process.env.NEXT_PUBLIC_ENABLE_TIME_PANEL !== "false",
@@ -1,319 +0,0 @@
/* personality 领域专属样式。 */
/* ============================================================
* 人格页面(PersonalityWorkspace)
* 十镜网格 + 种子带 + 明德条 + 版本层积
* 对齐 personality-core §7/§8 设计稿
* ============================================================ */
.section-heading {
font-family: "Songti SC", serif;
font-size: 14px;
font-weight: 600;
color: var(--fg);
margin-bottom: 14px;
}
/* 种子带 */
.seed-band {
position: relative;
overflow: hidden;
border: 1px solid var(--gilt-dim);
border-radius: 8px;
background: var(--face-sheen), var(--surface);
box-shadow:
inset 0 1px 0 var(--face-in-top),
inset 0 -1px 0 var(--face-in-bot),
var(--shadow);
padding: 20px 24px;
display: flex;
align-items: center;
gap: 20px;
}
.seed-band::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' seed='7'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E");
opacity: 0.018;
mix-blend-mode: overlay;
}
.seed-band > * {
position: relative;
z-index: 1;
}
.seed-mirror {
width: 58px;
height: 58px;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1.5px solid var(--patina-dim);
background:
radial-gradient(circle at 35% 30%, var(--face-in-top), transparent 60%),
var(--patina-wash);
}
.seed-mirror .glyph {
font-family: "Songti SC", serif;
font-size: 25px;
font-weight: 700;
color: var(--gilt);
}
.seed-body {
flex: 1;
min-width: 0;
}
.seed-lab {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg-faint);
}
.seed-text {
font-family: "Songti SC", serif;
font-size: 18px;
font-weight: 600;
color: var(--fg);
margin-top: 3px;
letter-spacing: 0.04em;
}
.tier-legend {
display: flex;
flex-direction: column;
gap: 4px;
font-family: "JetBrains Mono", monospace;
font-size: 10.5px;
color: var(--fg-faint);
text-align: right;
flex-shrink: 0;
}
.tier-legend b {
font-style: normal;
font-weight: 600;
}
.tier-legend .l1 {
color: var(--patina);
}
.tier-legend .l2 {
color: var(--gilt);
}
.tier-legend .l4 {
color: var(--cinnabar);
}
/* 十镜网格 */
.mirror-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 11px;
}
.mcell {
border: 1px solid var(--rule);
border-radius: 7px;
padding: 13px 14px 12px;
background: var(--surface);
display: flex;
flex-direction: column;
gap: 9px;
min-height: 138px;
transition: border-color 0.15s;
}
.mcell.landed {
border-color: var(--gilt-dim);
}
.mcell.reserved {
border-style: dashed;
opacity: 0.62;
}
.mc-head {
display: flex;
align-items: center;
gap: 8px;
}
.mc-glyph {
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--gilt-dim);
background: var(--gilt-faint);
font-family: "Songti SC", serif;
font-size: 14px;
font-weight: 700;
color: var(--gilt);
}
.mc-titles {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.mc-name {
font-size: 13px;
font-weight: 600;
color: var(--fg);
white-space: nowrap;
}
.mc-src {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
color: var(--fg-faint);
}
.mc-status {
margin-left: auto;
font-family: "JetBrains Mono", monospace;
font-size: 9.5px;
font-weight: 600;
padding: 1px 6px;
border-radius: 4px;
background: var(--gilt-faint);
color: var(--gilt);
border: 1px solid var(--gilt-dim);
white-space: nowrap;
}
.mc-status.landed {
background: var(--surface);
}
.mc-val {
font-size: 11.5px;
color: var(--fg-dim);
line-height: 1.45;
}
.mc-val .em {
font-style: normal;
}
/* 人格权重条 */
.mc-bars {
display: flex;
flex-direction: column;
gap: 4px;
}
.mc-bars .br {
display: flex;
align-items: center;
gap: 6px;
}
.mc-bars .tn {
font-family: "Songti SC", serif;
font-size: 11px;
color: var(--fg-dim);
width: 30px;
flex-shrink: 0;
}
.mc-bars .tbar {
flex: 1;
height: 3px;
border-radius: 2px;
background: var(--rule);
overflow: hidden;
}
.mc-bars .tbar .fl {
height: 100%;
background: linear-gradient(90deg, var(--patina-dim), var(--patina));
}
.mc-bars .tv {
font-family: "JetBrains Mono", monospace;
font-size: 9.5px;
color: var(--fg-faint);
width: 18px;
text-align: right;
flex-shrink: 0;
}
/* 明德恒定层 */
.mingde-strip {
border: 1px dashed var(--gilt-dim);
border-left: 3px solid var(--gilt-dim);
border-radius: 6px;
padding: 13px 18px;
background: var(--gilt-faint);
display: flex;
align-items: center;
gap: 14px;
}
.mingde-strip .seal {
font-family: "Songti SC", serif;
font-size: 14px;
font-weight: 700;
color: var(--gilt);
letter-spacing: 0.06em;
white-space: nowrap;
}
.mingde-strip .desc {
font-size: 12.5px;
color: var(--fg-dim);
line-height: 1.55;
}
.mingde-strip .desc code {
font-family: "JetBrains Mono", monospace;
font-size: 11.5px;
color: var(--gilt);
background: var(--surface);
padding: 1px 5px;
border-radius: 3px;
}
.mingde-strip .tag {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
padding: 2px 9px;
border-radius: 999px;
border: 1px dashed var(--gilt-dim);
color: var(--gilt);
white-space: nowrap;
margin-left: auto;
flex-shrink: 0;
}
/* 下方双栏 */
.lower-region {
display: flex;
gap: 20px;
align-items: flex-start;
}
.lower-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
.strata-rail {
width: 300px;
flex-shrink: 0;
}
@media (max-width: 1080px) {
.mirror-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 900px) {
.lower-region {
flex-direction: column;
}
.strata-rail {
width: 100%;
}
.mirror-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.mirror-grid {
grid-template-columns: 1fr;
}
.seed-band {
flex-wrap: wrap;
}
.tier-legend {
text-align: left;
}
}
-5
View File
@@ -1,13 +1,8 @@
@import "tailwindcss";
@import "./features/cosmos.css";
@import "./features/chat.css";
@import "./features/characters.css";
@import "./features/memory.css";
@import "./features/time.css";
@import "./features/drive.css";
@import "./features/world.css";
@import "./features/evolution.css";
@import "./features/personality.css";
/*
* ============================================================