初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { TimeRiver } from "@/components/archive/TimeRiver";
/**
* 归档页:记忆时间河。
*
* 展示结构化的记忆时间线归档,支持筛选、统计、按月分章浏览与事件详情展开。
* 当前使用假数据(mock),与 design/archive-preview.html 设计稿对齐。
*/
export default function ArchivePage() {
return (
<PageContainer
title="归档 · 记忆时间河"
description="记忆沿时序铺展,铜金分章卷轴,点击事件展开详情。"
meta={
<>
<span className="inline-flex items-center gap-1.5 rounded-full border border-patina-dim bg-patina-wash px-2.5 py-0.5 text-xs text-patina">
<span className="flex h-[18px] w-[18px] items-center justify-center rounded-full bg-patina text-[11px] font-semibold text-ink">
梧
</span>
青梧
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-gilt-dim bg-gilt-faint px-3 py-0.5 font-serif text-xs text-gilt">
卷宗 3 · 事件 7
</span>
</>
}
>
<TimeRiver />
</PageContainer>
);
}
@@ -0,0 +1,106 @@
import { notFound } from "next/navigation";
import { CandidateReviewPanel } from "@/components/characters/CandidateReviewPanel";
import { ImportHistory } from "@/components/characters/ImportHistory";
import { ImportWizard } from "@/components/characters/ImportWizard";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type {
Character,
ImportJob,
MemoryCandidate,
Paginated,
WorldEntity,
} from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 人物资料与沉淀页:资料导入向导 + 导入历史 + 跨来源候选审核。
* Server 预取待审候选(记忆 + 世界实体)与导入历史,交由 Client 组件交互。
*/
export default async function CharacterMaterialsPage({
params,
}: {
params: Promise<{ characterId: string }>;
}) {
const { characterId } = await params;
let character = null;
let memoryCandidates: MemoryCandidate[] = [];
let worldProposals: WorldEntity[] = [];
let importHistory: ImportJob[] = [];
let error: string | null = null;
try {
character = await serverApi.get<Character>(`/characters/${characterId}`);
} catch (e) {
error = describeError(e);
}
// 候选独立拉取,失败静默(不阻塞导入向导)
try {
memoryCandidates = (
await serverApi.get<Paginated<MemoryCandidate>>(
`/characters/${characterId}/memory-candidates`,
{ status: "pending", page_size: 50 },
)
).items;
} catch {
/* 候选可选 */
}
try {
worldProposals = (
await serverApi.get<Paginated<WorldEntity>>(
`/characters/${characterId}/world/entity-proposals`,
{ page_size: 50 },
)
).items;
} catch {
/* 候选可选 */
}
try {
importHistory = (
await serverApi.get<Paginated<ImportJob>>(
`/characters/${characterId}/imports`,
{ page_size: 20 },
)
).items;
} catch {
/* 历史可选 */
}
if (error) {
return (
<PageContainer title="资料与沉淀">
<ErrorState message={error} />
</PageContainer>
);
}
if (!character) return notFound();
return (
<PageContainer
title="资料与沉淀"
description={`为「${character.display_name || character.name}」导入资料、审核候选沉淀。`}
>
<div className="space-y-5">
<ImportWizard characterId={characterId} />
{importHistory.length > 0 && (
<ImportHistory
characterId={characterId}
initialHistory={importHistory}
/>
)}
<section>
<h2 className="mb-3 font-serif text-lg font-semibold">待审候选</h2>
<CandidateReviewPanel
memoryCandidates={memoryCandidates}
worldProposals={worldProposals}
/>
</section>
</div>
</PageContainer>
);
}
@@ -0,0 +1,71 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { CharacterIdentityCard } from "@/components/characters/CharacterIdentityCard";
import { CharacterLifecycleBar } from "@/components/characters/CharacterLifecycleBar";
import { ModelPolicyEditForm } from "@/components/characters/ModelPolicyEditForm";
import { ProfileEditForm } from "@/components/characters/ProfileEditForm";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
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 { Character } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 人物详情页:生命周期操作 + 档案编辑 + 模型策略编辑。
* Server Component 预取 getCharacter;写操作后 router.refresh() 重新拉取,以服务端数据为准。
*/
export default async function CharacterDetailPage({
params,
}: {
params: Promise<{ characterId: string }>;
}) {
const { characterId } = await params;
let character = null;
let error: string | null = null;
try {
character = await serverApi.get<Character>(`/characters/${characterId}`);
} catch (e) {
error = describeError(e);
}
if (error) {
return (
<PageContainer title="人物详情">
<ErrorState message={error} />
</PageContainer>
);
}
if (!character) return notFound();
return (
<PageContainer
title={character.display_name || character.name}
description={`内部名称:${character.name} · 创建于 ${formatDateTime(character.created_at)}`}
>
<div className="mb-4 flex flex-wrap items-center gap-3">
<CharacterStatusBadge status={character.status} />
<CharacterLifecycleBar character={character} />
<Link
href={`/characters/${character.id}/materials`}
className="ml-auto rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-patina hover:underline"
>
资料与沉淀 →
</Link>
</div>
<CharacterIdentityCard character={character} />
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<ProfileEditForm character={character} />
<ModelPolicyEditForm character={character} />
</div>
</PageContainer>
);
}
@@ -0,0 +1,88 @@
import { CharacterCard } from "@/components/characters/CharacterCard";
import { CharacterCreateDialog } from "@/components/characters/CharacterCreateDialog";
import { CharacterProfilePanel } from "@/components/characters/CharacterProfilePanel";
import { PageContainer } from "@/components/layout/PageContainer";
import { Badge } from "@/components/ui/Badge";
import { EmptyState, ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Character, CharacterListItem, Paginated } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 人物页:顶部展示当前激活人物魂册镜面卡(soul-card),下方列出全部人物名册。
*/
export default async function CharactersPage() {
let character: Character | null = null;
let list: CharacterListItem[] = [];
let error: string | null = null;
try {
character = await serverApi.get<Character>("/characters/active");
} catch (e) {
error = describeError(e);
}
// 列表独立拉取,失败静默
try {
list = (
await serverApi.get<Paginated<CharacterListItem>>("/characters", {
page_size: 50,
})
).items;
} catch {
/* 列表可选 */
}
return (
<PageContainer
title="人物"
description="当前激活人物的魂册档案,以及全部人物的名册与生命周期状态。"
actions={<CharacterCreateDialog />}
meta={
<>
{character && (
<Badge tone="accent">
{character.display_name || character.name}
</Badge>
)}
{list.length > 0 && (
<span className="text-xs text-muted">共 {list.length} 人</span>
)}
</>
}
>
{error ? (
<ErrorState message={error} />
) : character ? (
<CharacterProfilePanel character={character} />
) : (
<EmptyState
title="未加载到人物"
description="后端可能未就绪,或尚未初始化默认人物。"
/>
)}
{list.length > 0 && (
<section>
<div className="section-label">
<span className="glyph">◈</span>
全部人物 · Roster
<span className="sub">共 {list.length} 位</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{list.map((c) => (
<CharacterCard
key={c.id}
character={c}
active={c.id === character?.id}
/>
))}
</div>
</section>
)}
</PageContainer>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { ChatShell } from "@/components/chat/ChatShell";
import { serverApi } from "@/lib/api/server-client";
import type { Character, ModelGatewayStatus } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 对话页:Server Component 预取当前激活人物与模型网关后注入 ChatShell。
* 任一不可达时仍渲染界面,由用户感知后端状态。
*/
export default async function ChatPage() {
let character: Character | null = null;
let gateway: ModelGatewayStatus | null = null;
try {
character = await serverApi.get<Character>("/characters/active");
} catch {
character = null;
}
try {
gateway = await serverApi.get<ModelGatewayStatus>("/settings/models");
} catch {
gateway = null;
}
return <ChatShell initialCharacter={character} gateway={gateway} />;
}
File diff suppressed because it is too large. Load diff
+244
View File
@@ -0,0 +1,244 @@
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>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
/**
* 全局错误边界:捕获路由段渲染期抛出的未处理错误。
* - 与各页面的 try/catch 互补,此处捕获渲染期错误。
* - 展示错误摘要与 digest(Next 生成的错误标识)便于排查。
* - 错误对象仅输出到控制台,不向用户暴露堆栈。
*/
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
useEffect(() => {
console.error("[route-error]", error);
}, [error]);
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-10 text-center">
<div className="flex flex-col items-center gap-3 rounded-2xl border border-cinnabar/30 bg-cinnabar/10 px-8 py-10 backdrop-blur-sm">
<p className="font-serif text-base font-medium text-cinnabar">
镜面出现裂痕
</p>
<p className="max-w-sm text-xs text-muted">
页面渲染时遇到意外错误。可重试,或返回对话继续。
</p>
{error.digest && (
<p className="font-mono text-[10px] text-muted/70">
trace: {error.digest}
</p>
)}
<div className="mt-2 flex gap-2">
<Button variant="primary" size="sm" onClick={() => reset()}>
重试
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => router.push("/chat")}
>
返回对话
</Button>
</div>
</div>
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
import { EvolutionList } from "@/components/evolution/EvolutionList";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Character, EvolutionCandidate, Paginated } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 演化候选页 · 大周天自我评估生成的演化增量在此审核。
* 设计稿 evolution-preview.html — 周天四镜 + 候选层积 + 朱砂待裁 + 应用沉积。
* L4 核心变更(人格 / 使命 / 安全边界)需人工通过才写入;低风险类生成时已自动应用。
*/
export default async function EvolutionPage() {
let characterId: string | null = null;
let characterName = "";
let initial: EvolutionCandidate[] = [];
let counts = { pending: 0, applied: 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 [pending, applied, rejected] = await Promise.all([
serverApi.get<Paginated<EvolutionCandidate>>(
`/characters/${active.id}/evolution-candidates`,
{ status: "pending", page_size: 50 },
),
serverApi.get<Paginated<EvolutionCandidate>>(
`/characters/${active.id}/evolution-candidates`,
{ status: "applied", page_size: 1 },
),
serverApi.get<Paginated<EvolutionCandidate>>(
`/characters/${active.id}/evolution-candidates`,
{ status: "rejected", page_size: 1 },
),
]);
initial = pending.items;
counts = {
pending: pending.total,
applied: applied.total,
rejected: rejected.total,
};
} catch (e) {
error = describeError(e);
}
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>{counts.pending}</b> · 半自动{" "}
<b style={{ color: "var(--patina)" }}>{counts.applied}</b> · 已应用{" "}
<b>{counts.applied}</b>
</span>
</>
}
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : characterId ? (
<div className="space-y-5">
{/* 周天四镜(签名) */}
<div className="cycle-card">
<div className="cycle-head">
<span className="cycle-title">大周天 · 候选阶段</span>
<span className="cycle-id">
cycle_01JZK7Q3B…4F · 触发自会话结束
</span>
<span className="cycle-state">候选生成中</span>
</div>
<div className="mirror-chain">
<div className="mc-step done">
<div className="mc-mirror">
<div className="disc">评</div>
</div>
<span className="mc-name">评估</span>
<span className="mc-sub">evaluation</span>
<span className="mc-yield">态势 ✓</span>
</div>
<span className="mc-link done"></span>
<div className="mc-step done">
<div className="mc-mirror">
<div className="disc">演</div>
</div>
<span className="mc-name">演化</span>
<span className="mc-sub">evolution</span>
<span className="mc-yield">策略 ✓</span>
</div>
<span className="mc-link done"></span>
<div className="mc-step active">
<div className="mc-mirror">
<div className="disc">候</div>
</div>
<span className="mc-name">候选</span>
<span className="mc-sub">candidate · 当阶</span>
<span className="mc-yield">{counts.pending} 条草拟</span>
</div>
<span className="mc-link"></span>
<div className="mc-step">
<div className="mc-mirror">
<div className="disc">藏</div>
</div>
<span className="mc-name">入藏</span>
<span className="mc-sub">memory · 未达</span>
<span className="mc-yield">-</span>
</div>
</div>
<div className="snapshot-row">
<div className="snap-mini">
<div className="k">最近记忆</div>
<div className="v">
12<small>条入参</small>
</div>
</div>
<div className="snap-mini">
<div className="k">人格版本</div>
<div className="v">
v7<small>当前</small>
</div>
</div>
<div className="snap-mini">
<div className="k">世界实体</div>
<div className="v">
—<small>个</small>
</div>
</div>
<div className="snap-mini">
<div className="k">内驱力</div>
<div className="v">
{counts.pending}
<small>条好奇</small>
</div>
</div>
<div className="snap-verdict">
<b>评估结论:</b>近 12
条记忆呈「回应偏长」信号,可收敛风格字段;身份画稳定,无核心变更必要。建议生成
1 条风格类半自动候选,其余维持现状。
</div>
</div>
</div>
<EvolutionList
characterId={characterId}
initial={initial}
counts={counts}
/>
</div>
) : null}
</PageContainer>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useAuthStore } from "@/lib/stores/auth-store";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { AppShell } from "@/components/layout/AppShell";
import { LoadingState } from "@/components/shared/StateViews";
/**
* 主域壳层:日常对话 / 人物 / 人格 / 记忆 / 轨迹等路由共用 AppShell。
*
* 认证状态由 AuthInitializer(根布局)通过 BFF 初始化(user 首屏防闪用 localStorage),
* 此布局仅做路由守卫:未认证时重定向到 /login。
*/
export default function AppGroupLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const logout = useAuthStore((s) => s.logout);
const [ready, setReady] = useState(false);
// 等待 AuthInitializer 完成(给 loadFromBff 机会)
useEffect(() => {
const timer = setTimeout(() => setReady(true), 100);
return () => clearTimeout(timer);
}, []);
// 认证失败时登出并跳转登录页
const handleAuthError = useCallback(() => {
logout();
router.push("/login");
}, [logout, router]);
// 全局监听 ApiError "unauthorized"(通过自定义事件)
useEffect(() => {
const handler = () => handleAuthError();
window.addEventListener("auth:unauthorized", handler);
return () => window.removeEventListener("auth:unauthorized", handler);
}, [handleAuthError]);
useEffect(() => {
if (ready && !isAuthenticated) {
router.push("/login");
}
}, [ready, isAuthenticated, router]);
return (
<AppShell>
{ready && isAuthenticated ? children : <LoadingState label="辰光未启…" />}
</AppShell>
);
}
+18
View File
@@ -0,0 +1,18 @@
/**
* 全局路由加载态:force-dynamic 页面数据预取期间显示。
* 纯 CSS 动画(不引入 framer-motion),保持加载轻量;
* 外圈铜绿光晕脉动 + 内圈旋转,呼应琉璃镜「显影」意象。
*/
export default function Loading() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-10">
<div className="relative flex h-12 w-12 items-center justify-center">
{/* 外圈:铜绿光晕脉动 */}
<span className="absolute inset-0 animate-pulse rounded-full bg-patina/10" />
{/* 内圈:旋转环 */}
<span className="h-8 w-8 animate-spin rounded-full border-2 border-patina border-t-transparent" />
</div>
<p className="font-serif text-sm text-muted">镜面正在显影…</p>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { MemoryList } from "@/components/memory/MemoryList";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Character, Memory, Paginated } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function MemoryPage() {
let memories: Memory[] = [];
let characterId: string | undefined;
let error: string | null = null;
try {
const active = await serverApi
.get<Character>("/characters/active")
.catch(() => null);
characterId = active?.id;
const data = await serverApi.get<Paginated<Memory>>("/memory", {
page_size: 100,
});
memories = data.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="记忆"
description="当前人物的长期记忆,支持手动新增、编辑与删除。"
>
{error ? (
<ErrorState message={error} />
) : (
<MemoryList initialMemories={memories} characterId={characterId} />
)}
</PageContainer>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ModelGatewayView } from "@/components/models/ModelGatewayView";
import { TextSkeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { GenerationPreferences, ModelGatewayStatus } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 模型网关页(神识):模型与 endpoint 配置。
* base_url/api_key/status/weight/priority 可经 DB 覆盖(优先 .env),密钥脱敏不回显。
* 设置页不再含模型网关——生成参数留 /settings,默认模型槽位移至此。
*/
export default async function ModelsPage() {
let gateway: ModelGatewayStatus | null = null;
let preferences: GenerationPreferences | null = null;
let error: string | null = null;
try {
[gateway, preferences] = await Promise.all([
serverApi.get<ModelGatewayStatus>("/settings/models"),
serverApi.get<GenerationPreferences>("/settings/preferences"),
]);
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="模型网关"
description="神识 · 模型与 endpoint 配置。base_url/api_key 可经 DB 覆盖(优先 .env),密钥脱敏不回显。"
>
{error ? (
<ErrorState message={error} />
) : gateway && preferences ? (
<ModelGatewayView gateway={gateway} preferences={preferences} />
) : (
<div className="flex flex-col gap-4">
<div className="glass rounded-2xl p-6">
<TextSkeleton lines={2} className="mb-4" />
<TextSkeleton lines={1} className="w-1/2" />
</div>
<div className="glass rounded-2xl p-6">
<TextSkeleton lines={3} />
</div>
</div>
)}
</PageContainer>
);
}
+24
View File
@@ -0,0 +1,24 @@
import Link from "next/link";
/**
* 404:路由未匹配时的琉璃镜风格占位。
* Server Component,以 Link 承载跳转(无客户端交互逻辑)。
*/
export default function NotFound() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-10 text-center">
<div className="glass flex flex-col items-center gap-3 rounded-2xl px-8 py-12 backdrop-blur">
<p className="font-serif text-2xl font-semibold text-fg">迷雾</p>
<p className="max-w-sm text-xs text-muted">
此处镜面未显影,路径可能已变更或不存在。
</p>
<Link
href="/chat"
className="mt-2 inline-flex h-9 items-center rounded-xl bg-patina px-4 text-sm font-medium text-white shadow-lg shadow-patina/25 transition-all hover:bg-patina/90 active:scale-[0.97]"
>
返回对话
</Link>
</div>
</div>
);
}
@@ -0,0 +1,55 @@
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>
);
}
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,76 @@
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>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { SettingsView } from "@/components/settings/SettingsView";
import { TextSkeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { GenerationPreferences } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 设置页:外观偏好(主题)+ 生成参数。模型网关配置(模型/endpoint/默认槽位)在 /models。
*/
export default async function SettingsPage() {
let preferences: GenerationPreferences | null = null;
let error: string | null = null;
try {
preferences = await serverApi.get("/settings/preferences");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="设置"
description="外观偏好与生成参数;模型网关配置在 /models。"
>
{error ? (
<ErrorState message={error} />
) : preferences ? (
<SettingsView initialPreferences={preferences} />
) : (
<div className="flex flex-col gap-4">
<div className="glass rounded-2xl p-6">
<TextSkeleton lines={2} className="mb-6" />
<TextSkeleton lines={4} />
</div>
</div>
)}
</PageContainer>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
/**
* 页面过渡:每次路由切换时整体淡入并微上移。
* template.tsx 在导航时重新挂载,动画自然触发。
*/
export default function Template({ children }: { children: ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: "easeOut" }}
className="flex min-h-0 flex-1 flex-col"
>
{children}
</motion.div>
);
}
+691
View File
@@ -0,0 +1,691 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Card } from "@/components/ui/Card";
import { TextSkeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/shared/StateViews";
import { SundialDisk } from "@/components/time/SundialDisk";
import { charactersApi, timeApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type {
AcquaintanceInfo,
Anniversary,
CreateAnniversaryRequest,
TimeConfig,
TimeSnapshotResponse,
} from "@/types/api";
// ============================================================
// 辰正 · 时态感知
// 实时时间展示 + 十二时辰流 + 自动刷新的后端数据
// ============================================================
// —— 十二时辰 ——
const SHICHEN_KEYS = [
"zi",
"chou",
"yin",
"mao",
"chen",
"si",
"wu",
"wei",
"shen",
"you",
"xu",
"hai",
] as const;
const SHICHEN_HOURS: Record<string, number> = {
chou: 2,
yin: 4,
mao: 6,
chen: 8,
si: 10,
wu: 12,
wei: 14,
shen: 16,
you: 18,
xu: 20,
hai: 22,
};
/** 由客户端本地时辰确定时辰 key(与后端逻辑一致) */
function shichenFromHour(hour: number): string {
if (hour >= 23 || hour < 1) return "zi";
// 取各时辰起始值,反向查找
let prev = "zi";
for (const key of SHICHEN_KEYS) {
const start = SHICHEN_HOURS[key];
if (hour >= start) prev = key;
else break;
}
return prev;
}
/** 获取目标时区的当前时间组件 */
function getLocalTimeInTimezone(timezone: string) {
const fmt = new Intl.DateTimeFormat("zh-CN", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const p = fmt.formatToParts(new Date());
const g = (t: string) =>
parseInt(p.find((x) => x.type === t)?.value ?? "0", 10);
return {
year: g("year"),
month: g("month"),
day: g("day"),
hour: g("hour"),
minute: g("minute"),
second: g("second"),
};
}
function getDayPeriod(h: number): string {
if (h >= 0 && h < 5) return "凌晨";
if (h >= 5 && h < 7) return "清晨";
if (h >= 7 && h < 11) return "上午";
if (h >= 11 && h < 13) return "中午";
if (h >= 13 && h < 17) return "下午";
if (h >= 17 && h < 19) return "傍晚";
if (h >= 19 && h < 22) return "夜晚";
return "深夜";
}
const KIND_OPTIONS = [
{ value: "anniversary", label: "纪念日" },
{ value: "birthday", label: "生日" },
{ value: "custom", label: "自定义" },
];
// ============================================================
// 子组件
// ============================================================
/** 调制因子(设计稿 .factor 渐变轨道) */
function FactorFactor({
label,
value,
cls,
}: {
label: string;
value: number;
cls: string;
}) {
const pct = Math.round(Math.max(0, Math.min(1, value)) * 100);
return (
<div className={`factor ${cls}`}>
<span className="fk">{label}</span>
<div className="ftrack">
<div className="ffill" style={{ width: `${pct}%` }} />
</div>
<span className="fv">{pct}%</span>
</div>
);
}
// ============================================================
// 主页面
// ============================================================
export default function TimePage() {
// —— 后端数据 ——
const [snapshot, setSnapshot] = useState<TimeSnapshotResponse | null>(null);
const [acquaintance, setAcquaintance] = useState<AcquaintanceInfo | null>(
null,
);
const [config, setConfig] = useState<TimeConfig | null>(null);
const [currentMood, setCurrentMood] = useState<{
mood: string;
intensity: number;
} | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// —— 实时时钟 ——
const [, setNow] = useState<Date>(new Date());
const [, setPrevShichen] = useState<string | null>(null);
const [shichenChanged, setShichenChanged] = useState(false);
const shichenRef = useRef("");
// 每秒 tick
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
// 检测时辰变化
const timezone =
snapshot?.snapshot?.timezone ?? config?.user_timezone ?? "Asia/Shanghai";
const local = getLocalTimeInTimezone(timezone);
const currentShichen = shichenFromHour(local.hour);
useEffect(() => {
if (shichenRef.current && shichenRef.current !== currentShichen) {
setPrevShichen(shichenRef.current);
setShichenChanged(true);
setTimeout(() => setShichenChanged(false), 2000);
}
shichenRef.current = currentShichen;
}, [currentShichen]);
// —— 数据获取 ——
const fetchData = useCallback(async () => {
try {
const [s, acq, cfg] = await Promise.all([
timeApi.getTimeSnapshot(),
timeApi.getAcquaintance(),
timeApi.getTimeConfig(),
]);
setSnapshot(s);
setAcquaintance(acq);
setConfig(cfg);
} catch (e) {
if (!snapshot) setError(describeError(e));
} finally {
setLoading(false);
}
}, [snapshot]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchData();
// 获取当前人物心情
charactersApi
.getActiveCharacter()
.then((c) => {
if (c.current_mood) setCurrentMood(c.current_mood);
})
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 自动刷新:每 60 秒重新拉取后端数据(农历/节气/节日变化)
useEffect(() => {
if (loading) return;
const timer = setInterval(fetchData, 60_000);
return () => clearInterval(timer);
}, [fetchData, loading]);
// —— 渲染 ——
if (loading) {
return (
<div className="mx-auto max-w-5xl px-4 py-8">
<TextSkeleton lines={1} className="mb-8 w-48" />
<div className="grid grid-cols-1 gap-6 md:grid-cols-5">
<div className="md:col-span-3">
<div className="glass rounded-2xl p-8">
<TextSkeleton lines={3} />
</div>
</div>
<div className="md:col-span-2">
<div className="glass rounded-2xl p-5">
<TextSkeleton lines={6} />
</div>
</div>
</div>
<div className="glass mt-6 rounded-2xl p-5">
<TextSkeleton lines={2} />
</div>
</div>
);
}
if (error && !snapshot) {
return <ErrorState message={error} onRetry={fetchData} />;
}
const s = snapshot?.snapshot;
const factors = snapshot?.factors;
const context_text = snapshot?.context_text;
// 实时时辰信息(优先用本地计算的,回退后端数据)
const displayShichen = currentShichen;
const displayDayPeriod = getDayPeriod(local.hour);
return (
<div className="mx-auto max-w-5xl px-4 py-8">
{/* ===== 页头 ===== */}
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="font-serif text-2xl font-semibold text-fg">辰正</h1>
<span className="rounded-full bg-gold/10 px-2.5 py-0.5 text-[10px] font-medium tracking-wider text-gold">
时态感知
</span>
{shichenChanged && (
<span className="animate-pulse rounded-full bg-gold/30 px-2 py-0.5 text-[10px] text-gold">
更时
</span>
)}
</div>
{config && (
<p className="mt-1 text-xs text-muted">
{config.default_city} · {config.user_timezone}
</p>
)}
</div>
<div className="flex items-center gap-2 text-[11px] text-muted">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-patina" />
辰光实时
</div>
</header>
{/* ===== 日晷时辰盘 + 调制因子(设计稿 .sundial-region) ===== */}
<div className="sundial-region">
{/* 日晷 */}
<SundialDisk
currentShichen={displayShichen}
hour={local.hour}
minute={local.minute}
lunarText={s?.lunar?.text}
festivals={s?.festivals}
solarTerm={s?.solar_term?.name}
/>
{/* 调制因子(设计稿 .factor-card) */}
{factors && (
<div className="factor-card">
<div className="mb-4 flex items-center gap-2">
<h3 className="font-serif text-sm font-semibold text-fg">
调制因子
</h3>
<span className="h-px flex-1 bg-rule" />
</div>
<div className="factor-list">
<FactorFactor
label="节日喜庆"
value={factors.festival_joy}
cls="f-joy"
/>
<FactorFactor
label="深夜脆弱"
value={factors.late_night_fragility}
cls="f-frag"
/>
<FactorFactor
label="重逢强度"
value={factors.reunion_intensity}
cls="f-reun"
/>
<FactorFactor
label="周末放松"
value={factors.weekend_relax}
cls="f-wknd"
/>
<FactorFactor
label="季节共鸣"
value={factors.season_resonance}
cls="f-season"
/>
<FactorFactor
label="同期共振"
value={factors.same_period_resonance}
cls="f-period"
/>
<FactorFactor
label="关怀倾向"
value={factors.care_suggest}
cls="f-care"
/>
</div>
{currentMood && (
<div className="mt-4 rounded-xl bg-glass/40 px-4 py-3">
<div className="mb-1 flex items-center gap-2">
<h3 className="font-serif text-sm font-semibold text-fg">
当前心情
</h3>
<span className="h-px flex-1 bg-rule" />
</div>
<div className="flex items-center gap-3">
<span className="text-2xl">
{currentMood.mood === "joy"
? "😊"
: currentMood.mood === "sadness"
? "😢"
: currentMood.mood === "anger"
? "😠"
: currentMood.mood === "fear"
? "😨"
: currentMood.mood === "surprise"
? "😲"
: currentMood.mood === "disgust"
? "😣"
: "😐"}
</span>
<span className="font-serif text-base font-medium text-fg">
{currentMood.mood}
</span>
<span className="font-mono text-xs text-muted">
{Math.round(currentMood.intensity * 100)}%
</span>
</div>
</div>
)}
{factors.care_kind && (
<div className="care-note">
<b>关怀</b> · {factors.care_kind}
</div>
)}
</div>
)}
</div>
{/* ===== 相识 / 离线 / 里程碑 快照格(设计稿 .snap-grid) ===== */}
{s && (
<div className="snap-grid">
<div className="snap-card">
<div className="k">相识</div>
<div className="v">
{s.acquaintance.total_days}
<small>天</small>
</div>
<div className="sub">{s.acquaintance.natural_language}</div>
</div>
<div className="snap-card">
<div className="k">离线</div>
<div className="v">
{Math.round(s.offline.seconds / 86400)}
<small>天</small>
</div>
<div className="sub">{s.offline.natural_language}</div>
</div>
<div className="snap-card milestone">
<div className="k">下个纪念日</div>
<div className="v">
{s.next_milestone?.days_until ?? "—"}
<small>天后</small>
</div>
<div className="sub">{s.next_milestone?.name ?? "无"}</div>
</div>
<div className="snap-card">
<div className="k">辰光</div>
<div className="v">
{displayShichen}
<small>· {displayDayPeriod}</small>
</div>
<div className="sub">{s.season ?? "—"}</div>
</div>
</div>
)}
{/* ===== 时态感知文本(全宽) ===== */}
{context_text && (
<Card variant="surface" className="p-5">
<div className="mb-3 flex items-center gap-2">
<h3 className="font-serif text-sm font-semibold text-fg">
时态感知
</h3>
<span className="h-px flex-1 bg-rule" />
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-fg/80">
{context_text}
</p>
</Card>
)}
{/* ===== 底部双栏:相识 + 纪念日管理(设计稿 .lower-region) ===== */}
<div className="lower-region">
{acquaintance && (
<Card variant="surface" className="p-6 event-main">
<div className="mb-3 flex items-center gap-2">
<h3 className="font-serif text-sm font-semibold text-fg">相识</h3>
<span className="h-px flex-1 bg-rule" />
</div>
<p className="font-serif text-4xl font-semibold text-fg">
{acquaintance.total_days}
<span className="ml-1 text-base font-normal text-muted">天</span>
</p>
<p className="mt-1 text-sm text-muted">
{acquaintance.natural_language}
</p>
</Card>
)}
<div className="anniv-rail">
<AnniversaryManager />
</div>
</div>
</div>
);
}
// ============================================================
// 纪念日管理(保持原功能不变)
// ============================================================
function AnniversaryManager() {
const [anniversaries, setAnniversaries] = useState<Anniversary[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<CreateAnniversaryRequest & { date: string }>(
{
name: "",
date: "",
kind: "anniversary",
calendar: "solar",
subject_type: "relationship",
source: "manual",
},
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetch = useCallback(async () => {
try {
const data = await timeApi.listAnniversaries();
setAnniversaries(data);
} catch {
/* silent */
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetch();
}, [fetch]);
function resetForm() {
setForm({
name: "",
date: "",
kind: "anniversary",
calendar: "solar",
subject_type: "relationship",
source: "manual",
});
setShowForm(false);
setEditingId(null);
setError(null);
}
async function handleCreate() {
if (!form.name.trim() || !form.date) {
setError("请填写名称与日期。");
return;
}
setBusy(true);
setError(null);
try {
await timeApi.createAnniversary(form);
resetForm();
await fetch();
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败。");
} finally {
setBusy(false);
}
}
async function handleUpdate(id: string) {
if (!form.name.trim() || !form.date) {
setError("请填写名称与日期。");
return;
}
setBusy(true);
setError(null);
try {
await timeApi.updateAnniversary(id, form);
resetForm();
await fetch();
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败。");
} finally {
setBusy(false);
}
}
async function handleDelete(id: string) {
setBusy(true);
setError(null);
try {
await timeApi.deleteAnniversary(id);
await fetch();
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败。");
} finally {
setBusy(false);
}
}
function startEdit(a: Anniversary) {
setEditingId(a.id);
setShowForm(false);
setForm({
name: a.name,
date: a.date,
kind: a.kind,
calendar: a.calendar,
subject_type: a.subject_type,
source: a.source,
});
setError(null);
}
const inputCls =
"w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg outline-none focus:border-patina/60 focus:shadow-[0_0_0_2px] focus:shadow-patina/20";
return (
<Card variant="surface" className="p-6">
<div className="flex items-center justify-between">
<h3 className="font-serif text-sm font-semibold text-fg">
纪念日({anniversaries.length})
</h3>
{!showForm && !editingId && (
<button
type="button"
onClick={() => {
resetForm();
setShowForm(true);
}}
className="rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增
</button>
)}
</div>
{(showForm || editingId) && (
<div className="mt-3 space-y-2 rounded-lg border border-border-glass bg-glass/50 p-3">
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="名称"
className={inputCls}
/>
<input
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
type="date"
className={inputCls}
/>
<div className="flex gap-2">
<select
value={form.kind}
onChange={(e) => setForm({ ...form, kind: e.target.value })}
className={inputCls}
>
{KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<select
value={form.calendar}
onChange={(e) => setForm({ ...form, calendar: e.target.value })}
className={inputCls}
>
<option value="solar">公历</option>
<option value="lunar">农历</option>
</select>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={editingId ? () => handleUpdate(editingId) : handleCreate}
disabled={busy}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
{editingId ? "保存" : "创建"}
</button>
<button
type="button"
onClick={resetForm}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
)}
<div className="mt-3 space-y-2">
{anniversaries.length === 0 ? (
<p className="text-sm text-muted">暂无纪念日。</p>
) : (
anniversaries.map((a) => (
<div
key={a.id}
className="flex items-center justify-between rounded-lg bg-glass px-3 py-2"
>
<div className="min-w-0 flex-1">
<span className="text-sm text-fg">{a.name}</span>
<span className="ml-2 text-[11px] text-muted">{a.date}</span>
<span className="ml-2 rounded bg-gold/10 px-1.5 py-0.5 text-[10px] text-gold">
{a.kind}
</span>
</div>
<div className="ml-2 flex shrink-0 gap-2">
<button
type="button"
onClick={() => startEdit(a)}
className="text-[11px] text-patina hover:underline"
>
编辑
</button>
<button
type="button"
onClick={() => handleDelete(a.id)}
disabled={busy}
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</div>
</div>
))
)}
</div>
</Card>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { ToolsConsole } from "@/components/tools/ToolsConsole";
import { serverApi } from "@/lib/api/server-client";
import type { ToolInfo } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 工具页:日常工具调用控制台。Server 预取 GET /tools 注入 ToolsConsole。
* 百工枢治理(能力总览 / 工具注册 / Skill / MCP)在司命台 /admin/pivots/tool_runtime。
*/
export default async function ToolsPage() {
let tools: ToolInfo[] = [];
try {
tools = await serverApi.get<ToolInfo[]>("/tools");
} catch {
tools = [];
}
return <ToolsConsole initialTools={tools} />;
}
+235
View File
@@ -0,0 +1,235 @@
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 { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type {
Character,
Paginated,
WorldEntity,
WorldRelation,
} from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 世界页 · 数字生命对世界的认知(河图枢主域视图)。
* 设计稿 world-preview.html — 人物 pill + section-label + 关系图谱 + 实体名册 + 不确定性 + 世界观共振。
* 实体 CRUD 走 admin 域(WorldEntityList,保留编辑/删除);关系图谱走独立 /world/relations。
*/
export default async function WorldPage() {
let entities: WorldEntity[] = [];
let relations: WorldRelation[] = [];
let character: Character | null = null;
let characterName = "";
let error: string | null = null;
try {
entities = await serverApi.get<WorldEntity[]>("/admin/world/entities");
} 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>
);
}
+920
View File
@@ -0,0 +1,920 @@
"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>
</>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { AgentRoster } from "@/components/admin/AgentRoster";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { AgentRosterEntry } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminAgentsPage() {
let roster: AgentRosterEntry[] = [];
let error: string | null = null;
try {
roster = await serverApi.get("/admin/agents/roster");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="天工阁枢"
description="多智能体角色在岗管理——九角色可增删改。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<AgentRoster roster={roster ?? []} />
)}
</PageContainer>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { AuditConsole } from "@/components/admin/AuditConsole";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { AuditEntry, Paginated } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 起居注 · 审计时间河(司命台第三签 · 时间维度)。
* 铜金分章卷轴 + 横向枢阁筛选,追溯裁定与放行。
* 数据来自 GET /admin/audit/timeline(API §19.3)。
*/
export default async function AuditPage() {
let entries: AuditEntry[] = [];
let error: string | null = null;
try {
const data = await serverApi.get<Paginated<AuditEntry>>(
"/admin/audit/timeline",
);
entries = data.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="起居注"
description="审计时间河 · 按枢追溯裁定与放行。"
maxWidth="max-w-4xl"
>
{error ? (
<ErrorState message={error} />
) : (
<AuditConsole entries={entries} />
)}
</PageContainer>
);
}
@@ -0,0 +1,868 @@
"use client";
import { useEffect, useState } from "react";
import {
createCharacterTemplate,
deleteCharacterTemplate,
listCharacterTemplates,
updateCharacterTemplate,
} from "@/lib/api/admin";
import type {
AdminCharacterTemplate,
AdminCreateTemplateBody,
AdminUpdateTemplateBody,
} from "@/types/api";
// ── 模型提示选项 ──
const MODEL_HINTS = {
default_chat_model_hint: {
label: "对话模型",
options: [
{ value: "", label: "— 不设置(使用系统默认)" },
{ value: "deepseek-v4-flash", label: "deepseek-v4-flash" },
{ value: "deepseek-v3", label: "deepseek-v3" },
{ value: "gpt-4o", label: "gpt-4o" },
{ value: "claude-sonnet-5", label: "claude-sonnet-5" },
{ value: "claude-opus-4-8", label: "claude-opus-4-8" },
],
},
memory_extraction_model_hint: {
label: "记忆提取模型",
options: [
{ value: "", label: "— 不设置(空)" },
{ value: "memory-extraction", label: "memory-extraction" },
{ value: "deepseek-v4-flash", label: "deepseek-v4-flash" },
{ value: "deepseek-v3", label: "deepseek-v3" },
{ value: "gpt-4o-mini", label: "gpt-4o-mini" },
],
},
personality_analysis_model_hint: {
label: "人格分析模型",
options: [
{ value: "", label: "— 不设置(空)" },
{ value: "personality-analysis", label: "personality-analysis" },
{ value: "deepseek-v4-flash", label: "deepseek-v4-flash" },
{ value: "deepseek-v3", label: "deepseek-v3" },
{ value: "gpt-4o", label: "gpt-4o" },
],
},
embedding_model_hint: {
label: "向量嵌入模型",
options: [
{ value: "", label: "— 不设置(空)" },
{ value: "embedding", label: "embedding" },
{ value: "text-embedding-3-small", label: "text-embedding-3-small" },
{ value: "text-embedding-3-large", label: "text-embedding-3-large" },
],
},
} as const;
type ModelHintKey = keyof typeof MODEL_HINTS;
// ── 隐私等级选项 ──
const PRIVACY_LEVELS = [
{ value: "normal", label: "normal" },
{ value: "sensitive", label: "sensitive" },
{ value: "local_only", label: "local_only" },
];
// ── 标签来源判断 ──
function templateSource(key: string): { label: string; css: string } {
const yamlKeys = ["default", "tokisaki_kurumi", "gufeng", "baize", "qingnv"];
if (yamlKeys.includes(key)) {
return { label: "YAML", css: "bg-gilt/10 text-gilt border-gilt/30" };
}
return { label: "API", css: "bg-glass text-muted border-border-glass" };
}
// ── 标签输入组件 ──
function TagInput({
values,
onChange,
placeholder,
}: {
values: string[];
onChange: (v: string[]) => void;
placeholder?: string;
}) {
const [input, setInput] = useState("");
const add = () => {
const v = input.trim();
if (!v) return;
onChange([...values, v]);
setInput("");
};
return (
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-border-glass bg-glass px-3 py-2 text-sm transition-colors focus-within:border-patina">
{values.map((v, i) => (
<span
key={i}
className="inline-flex items-center gap-1 rounded-full bg-patina/10 px-2.5 py-0.5 text-[12px] text-patina"
>
{v}
<button
type="button"
onClick={() => onChange(values.filter((_, j) => j !== i))}
className="ml-0.5 opacity-50 transition-opacity hover:opacity-100"
>
×
</button>
</span>
))}
<input
className="min-w-[80px] flex-1 border-none bg-transparent p-0.5 text-sm text-fg outline-none"
placeholder={placeholder || "输入后回车添加…"}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
/>
</div>
);
}
export default function AdminCharacterTemplatesPage() {
const [templates, setTemplates] = useState<AdminCharacterTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<AdminCharacterTemplate | null>(null);
const [deleteConfirm, setDeleteConfirm] =
useState<AdminCharacterTemplate | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await listCharacterTemplates();
if (!cancelled) setTemplates(data);
} catch {
/* 错误由 ApiError 统一处理 */
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
/** 重新拉取模板列表(表单操作后调用) */
const refetch = () => {
setLoading(true);
(async () => {
try {
const data = await listCharacterTemplates();
setTemplates(data);
} catch {
/* 静默 */
} finally {
setLoading(false);
}
})();
};
const filtered = search
? templates.filter(
(t) =>
t.key.toLowerCase().includes(search.toLowerCase()) ||
t.name.includes(search) ||
(t.display_name || "").includes(search),
)
: templates;
return (
<div className="mx-auto max-w-5xl px-4 py-8">
{/* 页头 */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold text-fg">人物模板</h1>
<p className="mt-0.5 text-xs text-muted">
Character Templates · §19.8
</p>
</div>
<button
type="button"
onClick={() => {
setEditing(null);
setShowForm(true);
}}
className="rounded-xl bg-patina px-4 py-2 text-xs font-medium text-white transition-opacity hover:opacity-90"
>
✦ 新建模板
</button>
</div>
{/* 搜索 */}
<div className="mb-4">
<input
type="text"
placeholder="搜索模板 key 或名称…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="block w-full max-w-xs rounded-xl border border-border-glass bg-glass px-4 py-2 text-sm text-fg outline-none transition-colors placeholder:text-muted focus:border-patina"
/>
</div>
{/* 模板列表 */}
{loading ? (
<div className="flex items-center justify-center py-16 text-xs text-muted">
加载中…
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-16 text-xs text-muted">
{search ? "无匹配模板" : "暂无模板"}
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border-glass">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border-glass bg-glass text-xs font-medium text-muted">
<th className="px-4 py-3">Key</th>
<th className="px-4 py-3">名称</th>
<th className="px-4 py-3">显示名</th>
<th className="px-4 py-3">来源</th>
<th className="px-4 py-3">默认</th>
<th className="px-4 py-3">简介</th>
<th className="px-4 py-3">操作</th>
</tr>
</thead>
<tbody>
{filtered.map((t) => {
const src = templateSource(t.key);
const desc =
(t.profile?.description as string) ||
(t.profile?.bio as string) ||
"-";
return (
<tr
key={t.key}
className="border-b border-border-glass last:border-0 hover:bg-fg/[0.02]"
>
<td className="px-4 py-3 font-mono text-xs text-gilt">
{t.key}
</td>
<td className="px-4 py-3 font-medium text-fg">{t.name}</td>
<td className="px-4 py-3 text-muted">
{t.display_name || "-"}
</td>
<td className="px-4 py-3">
<span
className={`inline-block rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${src.css}`}
>
{src.label}
</span>
</td>
<td className="px-4 py-3 text-xs text-muted">
{t.is_default ? (
<span className="text-patina">✓</span>
) : (
"-"
)}
</td>
<td className="max-w-[200px] truncate px-4 py-3 text-muted">
{desc}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
setEditing(t);
setShowForm(true);
}}
className="rounded-lg px-2.5 py-1 text-[11px] font-medium transition-colors hover:bg-fg/5"
style={{ color: "var(--patina)" }}
>
编辑
</button>
<button
type="button"
onClick={() => setDeleteConfirm(t)}
className="rounded-lg px-2.5 py-1 text-[11px] font-medium transition-colors hover:bg-fg/5"
style={{ color: "var(--cinnabar)" }}
>
删除
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* 创建/编辑表单 */}
{showForm && (
<TemplateForm
template={editing}
onDone={() => {
setShowForm(false);
setEditing(null);
refetch();
}}
onCancel={() => {
setShowForm(false);
setEditing(null);
}}
/>
)}
{/* 删除确认 */}
{deleteConfirm && (
<DeleteConfirmModal
template={deleteConfirm}
onDone={() => {
setDeleteConfirm(null);
refetch();
}}
onCancel={() => setDeleteConfirm(null)}
/>
)}
{/* 操作提示 */}
<div className="mt-6 rounded-xl border border-dashed border-border-glass px-4 py-3 text-xs text-muted">
⚠ key 与{" "}
<code className="rounded bg-gilt/10 px-1 py-0.5 text-gilt">
config/character_templates.yaml
</code>{" "}
中定义一致的模板,在服务重启时会从 YAML 文件覆盖同步。API
创建的模板不受影响。
</div>
</div>
);
}
// ── 创建/编辑表单 ──
function TemplateForm({
template,
onDone,
onCancel,
}: {
template: AdminCharacterTemplate | null;
onDone: () => void;
onCancel: () => void;
}) {
const isEdit = template !== null;
const [key, setKey] = useState(template?.key || "");
const [name, setName] = useState(template?.name || "");
const [displayName, setDisplayName] = useState(template?.display_name || "");
const [language, setLanguage] = useState(template?.language || "zh-CN");
const [privacy, setPrivacy] = useState(template?.privacy_level || "normal");
const [isDefault, setIsDefault] = useState(template?.is_default || false);
const [description, setDescription] = useState(
(template?.profile?.description as string) || "",
);
const [background, setBackground] = useState(
(template?.profile?.background as string) || "",
);
const [speakingStyle, setSpeakingStyle] = useState(
(template?.profile?.speaking_style as string) || "",
);
const [temperament, setTemperament] = useState(
(template?.profile?.temperament as string) || "",
);
const [birthday, setBirthday] = useState(
(template?.profile?.birthday as string) || "",
);
const [height, setHeight] = useState(
(template?.profile?.height as string) || "",
);
const [weight, setWeight] = useState(
(template?.profile?.weight as string) || "",
);
const [hobbies, setHobbies] = useState<string[]>(
Array.isArray(template?.profile?.hobbies)
? (template?.profile?.hobbies as string[])
: [],
);
const [values, setValues] = useState<string[]>(
Array.isArray(template?.profile?.values)
? (template?.profile?.values as string[])
: [],
);
const [extraJson, setExtraJson] = useState("{}");
// Model hints
const [chatHint, setChatHint] = useState(
(template?.model_policy?.default_chat_model_hint as string) || "",
);
const [memHint, setMemHint] = useState(
(template?.model_policy?.memory_extraction_model_hint as string) || "",
);
const [persHint, setPersHint] = useState(
(template?.model_policy?.personality_analysis_model_hint as string) || "",
);
const [embHint, setEmbHint] = useState(
(template?.model_policy?.embedding_model_hint as string) || "",
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError(null);
// Build profile
const profile: Record<string, unknown> = {};
if (description) profile.description = description;
if (background) profile.background = background;
if (speakingStyle) profile["speaking_style"] = speakingStyle;
if (temperament) profile.temperament = temperament;
if (birthday) profile.birthday = birthday;
if (height) profile.height = height;
if (weight) profile.weight = weight;
if (hobbies.length) profile.hobbies = hobbies;
if (values.length) profile.values = values;
// Merge extra JSON
try {
const extra = JSON.parse(extraJson || "{}");
Object.assign(profile, extra);
} catch {
setError("额外内容 JSON 格式错误");
setSaving(false);
return;
}
// Build model_policy
const modelPolicy: Record<string, string> = {};
if (chatHint) modelPolicy.default_chat_model_hint = chatHint;
if (memHint) modelPolicy.memory_extraction_model_hint = memHint;
if (persHint) modelPolicy.personality_analysis_model_hint = persHint;
if (embHint) modelPolicy.embedding_model_hint = embHint;
try {
if (isEdit) {
const body: AdminUpdateTemplateBody = {
name,
display_name: displayName || null,
language,
privacy_level: privacy,
is_default: isDefault,
profile,
model_policy: modelPolicy,
};
await updateCharacterTemplate(template.key, body);
} else {
const body: AdminCreateTemplateBody = {
key,
name,
display_name: displayName || null,
language,
privacy_level: privacy,
is_default: isDefault,
profile,
model_policy: modelPolicy,
};
await createCharacterTemplate(body);
}
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 py-8 backdrop-blur-sm">
<div className="w-full max-w-2xl rounded-2xl border border-border-glass bg-ink shadow-2xl">
{/* 标题 */}
<div className="flex items-center justify-between border-b border-border-glass px-6 py-4">
<h2 className="font-serif text-base font-semibold text-fg">
{isEdit ? `✎ 编辑模板 · ${template.key}` : "✦ 新建模板"}
</h2>
<button
type="button"
onClick={onCancel}
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted transition-colors hover:bg-fg/5 hover:text-fg"
>
✕
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-5 px-6 py-5">
{error && (
<div className="rounded-lg bg-cinnabar/10 px-4 py-2 text-xs text-cinnabar">
{error}
</div>
)}
{/* ── 基础信息 ── */}
<div>
<span className="mb-3 inline-block font-mono text-[11px] font-medium tracking-wider text-muted">
基础信息
</span>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-muted">
Key *
</label>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina disabled:opacity-40"
required
disabled={isEdit}
placeholder="唯一标识,如 my_template"
/>
{!isEdit && (
<p className="mt-1 text-[11px] text-muted">
仅小写字母、数字、下划线、连字符
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-muted">
是否默认
</label>
<select
value={isDefault ? "true" : "false"}
onChange={(e) => setIsDefault(e.target.value === "true")}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
>
<option value="false">否</option>
<option value="true">是</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-muted">
名称 *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
required
placeholder="内部名称"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">
显示名
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="显示昵称"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">
语言
</label>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
>
<option value="zh-CN">zh-CN</option>
<option value="en">en</option>
<option value="ja">ja</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-muted">
私隐等级
</label>
<select
value={privacy}
onChange={(e) => setPrivacy(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
>
{PRIVACY_LEVELS.map((pl) => (
<option key={pl.value} value={pl.value}>
{pl.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* ── 人物档案 ── */}
<div>
<span className="mb-3 inline-block font-mono text-[11px] font-medium tracking-wider text-muted">
📋 人物档案(Profile)
</span>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-muted">
一句话简介
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="例如:一面照见人心的镜子"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">
背景故事
</label>
<textarea
value={background}
onChange={(e) => setBackground(e.target.value)}
rows={3}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="人物背景故事设定…"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-muted">
说话风格
</label>
<input
type="text"
value={speakingStyle}
onChange={(e) => setSpeakingStyle(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="温和、清晰、简洁"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">
性情气质
</label>
<input
type="text"
value={temperament}
onChange={(e) => setTemperament(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="沉静而温暖"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-muted">
生日
</label>
<input
type="date"
value={birthday}
onChange={(e) => setBirthday(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">
身高(cm)
</label>
<input
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="cm"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">
体重(kg)
</label>
<input
type="number"
value={weight}
onChange={(e) => setWeight(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="kg"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-muted">
兴趣爱好
</label>
<TagInput
values={hobbies}
onChange={setHobbies}
placeholder="输入后回车添加…"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">
价值观
</label>
<TagInput
values={values}
onChange={setValues}
placeholder="输入后回车添加…"
/>
</div>
</div>
</div>
</div>
{/* ── 额外内容(JSON)── */}
<div>
<span className="mb-3 inline-block font-mono text-[11px] font-medium tracking-wider text-muted">
⋯ 额外内容(Extra JSON)
<span className="ml-2 font-normal text-muted/60">
以上字段未覆盖的自定义 profile 键值
</span>
</span>
<textarea
value={extraJson}
onChange={(e) => setExtraJson(e.target.value)}
rows={3}
className="block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 font-mono text-xs text-fg outline-none transition-colors focus:border-patina"
placeholder="{}"
/>
</div>
{/* ── 模型策略 ── */}
<div>
<span className="mb-3 inline-block font-mono text-[11px] font-medium tracking-wider text-muted">
⚙ 模型策略(Model Policy)
<span className="ml-2 font-normal text-muted/60">不选则留空</span>
</span>
<div className="grid grid-cols-2 gap-3">
{(
Object.entries(MODEL_HINTS) as [
ModelHintKey,
(typeof MODEL_HINTS)[ModelHintKey],
][]
).map(([fieldKey, cfg]) => {
const val = {
default_chat_model_hint: chatHint,
memory_extraction_model_hint: memHint,
personality_analysis_model_hint: persHint,
embedding_model_hint: embHint,
}[fieldKey];
const setVal = {
default_chat_model_hint: setChatHint,
memory_extraction_model_hint: setMemHint,
personality_analysis_model_hint: setPersHint,
embedding_model_hint: setEmbHint,
}[fieldKey];
return (
<div key={fieldKey}>
<label className="block text-xs font-medium text-muted">
{cfg.label}
</label>
<select
value={val}
onChange={(e) => setVal(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
>
{cfg.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
})}
</div>
</div>
{/* ── 操作按钮 ── */}
<div className="flex items-center justify-end gap-2 border-t border-border-glass pt-4">
<button
type="button"
onClick={onCancel}
className="rounded-lg px-4 py-2 text-xs font-medium text-muted transition-colors hover:bg-fg/5"
>
取消
</button>
<button
type="submit"
disabled={saving}
className="rounded-lg bg-patina px-4 py-2 text-xs font-medium text-white transition-opacity disabled:opacity-50"
>
{saving ? "保存中…" : isEdit ? "✎ 保存" : "✦ 创建"}
</button>
</div>
</form>
</div>
</div>
);
}
// ── 删除确认 ──
function DeleteConfirmModal({
template,
onDone,
onCancel,
}: {
template: AdminCharacterTemplate;
onDone: () => void;
onCancel: () => void;
}) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDelete = async () => {
setSaving(true);
setError(null);
try {
await deleteCharacterTemplate(template.key);
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : "删除失败");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-2xl border border-border-glass bg-ink p-6 shadow-2xl">
<h2 className="mb-2 text-sm font-semibold text-cinnabar">
⚠ 确认删除模板
</h2>
<p className="mb-4 text-xs text-muted">
将删除系统模板{" "}
<strong className="text-fg">
{template.key} · {template.name}
</strong>
。如果有用户人物引用了此模板将无法删除。此操作不可撤销。
</p>
{error && (
<div className="mb-3 rounded-lg bg-cinnabar/10 px-3 py-2 text-xs text-cinnabar">
{error}
</div>
)}
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-fg/5"
>
取消
</button>
<button
type="button"
onClick={handleDelete}
disabled={saving}
className="rounded-lg bg-cinnabar px-3 py-1.5 text-xs font-medium text-white transition-opacity disabled:opacity-50"
>
{saving ? "删除中…" : "✕ 确认删除"}
</button>
</div>
</div>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
/**
* 司命台错误边界:捕获 admin 路由段渲染期抛出的未处理错误。
*/
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
useEffect(() => {
console.error("[admin-error]", error);
}, [error]);
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-10 text-center">
<div className="flex flex-col items-center gap-3 rounded-2xl border border-cinnabar/30 bg-cinnabar/10 px-8 py-10 backdrop-blur-sm">
<p className="font-serif text-base font-medium text-cinnabar">
司命台异动
</p>
<p className="max-w-sm text-xs text-muted">
管理控制台渲染时遇到意外错误。可重试,或返回司命台首页。
</p>
{error.digest && (
<p className="font-mono text-[10px] text-muted/70">
trace: {error.digest}
</p>
)}
<div className="mt-2 flex gap-2">
<Button variant="primary" size="sm" onClick={() => reset()}>
重试
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => router.push("/admin")}
>
返回司命台
</Button>
</div>
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { GoalTree } from "@/components/admin/GoalTree";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { GoalNode } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminGoalsPage() {
let goals: GoalNode[] = [];
let error: string | null = null;
try {
goals = await serverApi.get("/admin/goals");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="太一枢"
description="目标层级树——从使命到行动的分解,支持增删改。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<GoalTree goals={goals ?? []} />
)}
</PageContainer>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useAuthStore } from "@/lib/stores/auth-store";
import { AdminShell } from "@/components/layout/AdminShell";
import { LoadingState } from "@/components/shared/StateViews";
/**
* 司命台壳层:独立顶栏 + 横向二级导航。
* 仅管理员可访问,非管理员重定向至首页。
* 等待 BFF + localStorage 初始化完成后才渲染,避免 API 未授权请求。
*/
export default function AdminLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
const loadFromBff = useAuthStore((s) => s.loadFromBff);
const [ready, setReady] = useState(false);
useEffect(() => {
// loadFromStorage 同步恢复 user(首屏防闪),loadFromBff 经 BFF 校正权威状态
loadFromStorage();
loadFromBff()
.catch(() => {})
.finally(() => setReady(true));
}, [loadFromBff, loadFromStorage]);
useEffect(() => {
if (ready && user && !user.is_admin) {
router.push("/");
}
}, [ready, user, router]);
// 未就绪或不认证时显示加载态
if (!ready || !isAuthenticated) {
return (
<div className="flex min-h-screen items-center justify-center">
<LoadingState label="辰光未启…" />
</div>
);
}
// 已就绪但 user 仍为空(loadFromBff 失败且 localStorage 也无数据)
if (!user) {
return (
<div className="flex min-h-screen items-center justify-center">
<LoadingState label="辰光未启…" />
</div>
);
}
return <AdminShell>{children}</AdminShell>;
}
+48
View File
@@ -0,0 +1,48 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState, EmptyState } from "@/components/shared/StateViews";
import { MemoryAdminList } from "@/components/admin/MemoryAdminList";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Memory, Paginated } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 息壤枢 · 长期记忆。
*
* 取所有记忆列表,经 MemoryAdminList 组件做删除/详情查看。
* 数据来自 GET /memory(API §8)。
*/
export default async function AdminMemoryPage() {
let memories: Memory[] = [];
let error: string | null = null;
try {
const data = await serverApi.get<Paginated<Memory>>("/memory", {
page_size: 100,
});
memories = data.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="息壤枢"
description="长期记忆 · 自生不息"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : memories.length === 0 ? (
<EmptyState
title="暂无记忆"
description="当前人物还没有沉淀任何长期记忆。"
/>
) : (
<MemoryAdminList initial={memories} />
)}
</PageContainer>
);
}
+533
View File
@@ -0,0 +1,533 @@
import Link from "next/link";
import { cn } from "@/lib/utils/cn";
import { describeError } from "@/lib/utils/error";
import { serverApi } from "@/lib/api/server-client";
import {
PIVOT_FULL_LABEL,
PIVOT_MODULE_LABEL,
PIVOT_HEALTH_LABEL,
AUDIT_DECISION_LABEL,
} from "@/lib/constants/labels";
import { Astrolabe } from "@/components/ui/Astrolabe";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import type {
AuditEntry,
Paginated,
PendingApproval,
PivotHealthReport,
} from "@/types/api";
export const dynamic = "force-dynamic";
/* ============================================================
* 司命台 · 总览(对应设计稿 admin-preview.html §❶ 概览)
* ============================================================
*
* 三层布局(设计稿 layout):
* ① 朱批告警条 — 动作召唤入口
* ② 九枢星盘 + 枢速览 — 空间 hero
* ③ 三签概览卡 — 空间/动作/时间三维入口
* ④ 起居注近期 — 时间河预览
*/
/** 获取朱批待裁定数量(API §19.2) */
async function getPendingCount(): Promise<number> {
try {
const data = await serverApi.get<Paginated<PendingApproval>>(
"/admin/safety/pending",
);
return data.total ?? data.items.length;
} catch {
return 0;
}
}
/** 获取起居注数量 —— 今日与本周 */
async function getAuditCounts(): Promise<{ today: number; week: number }> {
try {
const data = await serverApi.get<Paginated<AuditEntry>>(
"/admin/audit/timeline?limit=100",
);
const items = data?.items ?? [];
const now = new Date();
const todayStart = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const weekStart = todayStart - 6 * 86400_000;
return {
today: items.filter((e) => {
const t = e.created_at ? new Date(e.created_at).getTime() : 0;
return t >= todayStart;
}).length,
week: items.filter((e) => {
const t = e.created_at ? new Date(e.created_at).getTime() : 0;
return t >= weekStart;
}).length,
};
} catch {
return { today: 0, week: 0 };
}
}
export default async function AdminPage() {
/* ── 并行数据获取 ── */
const [reportResult, pendingCount, auditCounts] = await Promise.all([
serverApi
.get<PivotHealthReport>("/admin/pivots/health")
.then((r) => ({ data: r, error: null as string | null }))
.catch((e) => ({
data: null as PivotHealthReport | null,
error: describeError(e),
})),
getPendingCount(),
getAuditCounts(),
]);
const report = reportResult.data;
const error = reportResult.error;
// 起居注近期数据
const auditData = await serverApi
.get<Paginated<AuditEntry>>("/admin/audit/timeline")
.catch(() => null);
const recentAudit: AuditEntry[] = auditData?.items.slice(0, 3) ?? [];
const cracked = (report?.alerts ?? 0) > 0;
return (
<PageContainer
title="司命台"
description="九枢治理总览 —— 裁定、放行、稽核、守护边界。"
maxWidth="max-w-5xl"
>
{error || !report ? (
<ErrorState message={error ?? "九枢健康度暂不可用。"} />
) : (
<div className="space-y-5">
{/* ===============================================================
① 朱批告警条(设计稿 .alert-bar.cracked)
动作维度召唤:待裁定项数 + 快捷入口 → 司命枢(安全治理)
设计稿 §§ .alert-bar / $.cracked / .alert-dot / .alert-text / .alert-cta
=============================================================== */}
<Link
href="/admin/safety"
className={cn(
"flex items-center gap-3 rounded-xl border px-4 py-3.5 text-sm no-underline transition-colors",
"hover:bg-surface-hi",
cracked
? "border-cinnabar-dim bg-cinnabar-wash"
: "border-rule bg-surface",
)}
>
{/* 告警圆点 */}
<span
className={cn(
"h-2.5 w-2.5 shrink-0 rounded-full",
cracked
? "bg-cinnabar shadow-[0_0_0_4px_var(--cinnabar-wash)]"
: "bg-patina",
)}
/>
<span
className={cn(
"text-sm font-medium",
cracked ? "text-cinnabar" : "text-patina",
)}
>
{cracked
? `${report.alerts} 项朱批待裁定`
: "九枢皆稳,无待裁定项"}
</span>
<span className="ml-auto hidden text-[11px] text-fg-faint sm:inline-flex items-center gap-1.5">
<span>司命枢 · 总态</span>
<span
className={cn(
"font-mono text-[11px]",
cracked ? "text-cinnabar" : "text-patina",
)}
>
{PIVOT_HEALTH_LABEL[report.overall]}
</span>
</span>
<span className="shrink-0 rounded-md border border-rule px-2.5 py-1 text-[11px] text-fg-dim transition-colors hover:bg-overlay-hover">
前往朱批 →
</span>
</Link>
{/* ===============================================================
② 九枢星盘 + 枢速览(设计稿 .hero-grid)
空间维度 hero:左侧星盘 SVG + 右侧九枢健康列表
设计稿 §§ .hero-grid / .astrolabe-card / .health-list / .health-item
=============================================================== */}
<div className="grid gap-4 md:grid-cols-[minmax(0,22rem)_1fr]">
{/* 星盘卡 */}
<div className="flex flex-col items-center gap-3 rounded-xl border border-rule bg-surface px-4 py-5">
<Astrolabe pivots={report.pivots} />
{/* 星盘图例(设计稿 .astrolabe-legend) */}
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2 font-mono text-[10px] text-fg-faint">
<span className="inline-flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-patina" />
稳定
</span>
<span className="inline-flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-gilt" />
运转中
</span>
<span className="inline-flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-cinnabar" />
告警
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{
background: "transparent",
border: "1.5px solid var(--fg-faint)",
}}
/>
离线
</span>
</div>
</div>
{/* 枢速览列表(设计稿 .health-list) */}
<div className="overflow-hidden rounded-xl border border-rule bg-surface">
{report.pivots.map((p) => {
const healthColor = {
void: "bg-[var(--fg-faint)] opacity-40",
steady: "bg-patina",
breathing: "bg-[var(--gilt)]",
cracked: "bg-[var(--cinnabar)]",
}[p.health];
const badgeColor = {
void: "text-fg-faint",
steady: "text-patina",
breathing: "text-gilt",
cracked: "text-cinnabar",
}[p.health];
const dotStyle =
p.health === "void"
? {
background: "transparent",
border: "1.5px solid var(--fg-faint)",
}
: {};
return (
<Link
key={p.pivot}
href={`/admin/pivots/${p.pivot}`}
className="grid grid-cols-[3px_1fr_auto] items-center gap-3.5 border-b border-rule px-4 py-2.5 text-left no-underline transition-colors last:border-b-0 hover:bg-overlay-hover"
>
{/* 左缘色条(设计稿 .health-bar) */}
<span
className={cn(
"h-6 w-0.5 shrink-0 rounded-r",
healthColor,
)}
/>
{/* 主信息 */}
<div className="min-w-0">
<div className="flex items-baseline gap-1.5">
<span className="font-serif text-sm font-semibold text-fg">
{PIVOT_FULL_LABEL[p.pivot]}
</span>
<span className="truncate font-mono text-[10px] text-fg-faint">
{PIVOT_MODULE_LABEL[p.pivot]}
</span>
</div>
<p className="mt-px truncate text-xs text-fg-dim">
{p.summary}
</p>
</div>
{/* 状态徽记(设计稿 .health-badge) */}
<span
className={cn(
"inline-flex items-center gap-1.5 shrink-0 text-xs font-medium",
badgeColor,
)}
>
<span
className={cn(
"inline-block h-1.5 w-1.5 rounded-full",
healthColor,
)}
style={dotStyle}
/>
{PIVOT_HEALTH_LABEL[p.health]}
</span>
</Link>
);
})}
</div>
</div>
{/* ===============================================================
③ 三签概览卡(设计稿 .seals-grid)
三个维度入口:星图(空间)/ 朱批(动作)/ 起居注(时间)
设计稿 §§ .seals-grid / .seal-card.starmap / .vermilion / .imperial
=============================================================== */}
<div className="grid gap-3 sm:grid-cols-3">
{/* 星图 · 空间维度(设计稿 .seal-card.starmap) */}
<Link
href="/admin"
className="group relative block overflow-hidden rounded-xl border border-rule bg-surface px-4 py-4 no-underline transition-all hover:border-patina-dim hover:bg-surface-hi"
>
{/* 左缘色条:patina(设计稿 .seal-card 左 3px 色条) */}
<span className="absolute left-0 top-4 bottom-4 w-[3px] rounded-r bg-patina" />
<div className="pl-3">
<div className="font-mono text-[10px] tracking-wider text-fg-faint uppercase mb-1.5">
空间 · spatial
</div>
<div className="mb-1 flex items-baseline gap-2">
<h3 className="font-serif text-base font-semibold text-fg">
星图
</h3>
<span className="font-mono text-[10.5px] text-patina">
StarMap
</span>
</div>
<p className="mb-3 text-[12px] text-fg-faint">
权限拓扑 · 九枢授权态势
</p>
<div className="mb-2 flex items-baseline gap-1.5">
<span className="font-mono text-[28px] font-semibold leading-none text-fg">
{report.pivots.length}
</span>
<span className="text-[11.5px] text-fg-faint">枢</span>
<span className="text-[11.5px] text-cinnabar">
· {report.alerts} 告警
</span>
</div>
<div className="flex items-center justify-between border-t border-rule pt-2 text-[11.5px] text-fg-dim">
<span>
{report.pivots.filter((p) => p.health !== "void").length}{" "}
活跃
</span>
<span className="font-mono text-fg-faint opacity-0 transition-opacity group-hover:opacity-100">
星盘 →
</span>
</div>
</div>
</Link>
{/* 朱批 · 动作维度(设计稿 .seal-card.vermilion) */}
<Link
href="/admin/safety"
className="group relative block overflow-hidden rounded-xl border border-rule bg-surface px-4 py-4 no-underline transition-all hover:border-cinnabar-dim hover:bg-surface-hi"
>
{/* 左缘色条:cinnabar */}
<span className="absolute left-0 top-4 bottom-4 w-[3px] rounded-r bg-cinnabar" />
<div className="pl-3">
<div className="font-mono text-[10px] tracking-wider text-fg-faint uppercase mb-1.5">
动作 · action
</div>
<div className="mb-1 flex items-baseline gap-2">
<h3 className="font-serif text-base font-semibold text-fg">
朱批
</h3>
<span className="font-mono text-[10.5px] text-cinnabar">
VermilionAction
</span>
</div>
<p className="mb-3 text-[12px] text-fg-faint">
确认裁决 · 高风险工具裁定
</p>
<div className="mb-2 flex items-baseline gap-1.5">
<span className="font-mono text-[28px] font-semibold leading-none text-fg">
{pendingCount}
</span>
<span className="text-[11.5px] text-fg-faint">项待裁定</span>
</div>
<div className="flex items-center justify-between border-t border-rule pt-2 text-[11.5px] text-fg-dim">
<span>司命枢 →</span>
<span className="font-mono text-fg-faint opacity-0 transition-opacity group-hover:opacity-100">
朱批 →
</span>
</div>
</div>
</Link>
{/* 起居注 · 时间维度(设计稿 .seal-card.imperial) */}
<Link
href="/admin/audit"
className="group relative block overflow-hidden rounded-xl border border-rule bg-surface px-4 py-4 no-underline transition-all hover:border-gilt-dim hover:bg-surface-hi"
>
{/* 左缘色条:gilt */}
<span className="absolute left-0 top-4 bottom-4 w-[3px] rounded-r bg-gilt" />
<div className="pl-3">
<div className="font-mono text-[10px] tracking-wider text-fg-faint uppercase mb-1.5">
时间 · temporal
</div>
<div className="mb-1 flex items-baseline gap-2">
<h3 className="font-serif text-base font-semibold text-fg">
起居注
</h3>
<span className="font-mono text-[10.5px] text-gilt">
ImperialLog
</span>
</div>
<p className="mb-3 text-[12px] text-fg-faint">
审计流水 · 不可篡改
</p>
<div className="mb-2 flex items-baseline gap-1.5">
<span className="font-mono text-[28px] font-semibold leading-none text-fg">
{auditCounts.today}
</span>
<span className="text-[11.5px] text-fg-faint">条 · 今日</span>
</div>
<div className="flex items-center justify-between border-t border-rule pt-2 text-[11.5px] text-fg-dim">
<span>本周 {auditCounts.week} 条</span>
<span className="font-mono text-fg-faint opacity-0 transition-opacity group-hover:opacity-100">
起居注 →
</span>
</div>
</div>
</Link>
</div>
{/* ===============================================================
④ 起居注 · 近期(设计稿起居注段)
时间维度预览,3 条实时数据。
设计稿 §§ .audit-head / .audit-item / .pill.decision
=============================================================== */}
<div className="overflow-hidden rounded-xl border border-rule bg-surface">
{/* 起居注标题行 */}
<div className="flex items-center justify-between border-b border-rule px-4 py-3">
<h3 className="font-serif text-sm font-semibold text-fg">
起居注 · 近期
</h3>
<Link
href="/admin/audit"
className="inline-flex items-center gap-1 rounded-md border border-rule px-2.5 py-1 text-[11.5px] text-fg-dim no-underline transition-colors hover:bg-overlay-hover"
>
进入起居注 →
</Link>
</div>
{recentAudit.length > 0 ? (
recentAudit.map((entry) => {
const pillClass = {
auto: "border-patina-dim text-patina bg-patina-wash",
approved: "border-patina-dim text-patina bg-patina-wash",
denied: "border-cinnabar-dim text-cinnabar bg-cinnabar-wash",
blocked: "border-cinnabar-dim text-cinnabar bg-cinnabar-wash",
}[entry.decision];
return (
<div
key={entry.audit_id}
className="grid grid-cols-[4.5rem_1fr_auto] gap-3.5 border-b border-rule px-4 py-3 last:border-b-0"
>
{/* 时间 */}
<span className="font-mono text-[11px] text-fg-faint">
{formatAuditTime(entry.created_at)}
</span>
{/* 内容 */}
<div className="min-w-0">
<div className="mb-0.5 flex items-center gap-2 flex-wrap">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-mono text-[10px] leading-none",
pillClass,
)}
>
{AUDIT_DECISION_LABEL[entry.decision]}
</span>
<span className="text-xs font-medium text-fg">
{PIVOT_FULL_LABEL[entry.pivot] ?? entry.pivot}
</span>
</div>
<p className="text-[12px] text-fg-dim leading-relaxed">
{entry.summary}
{entry.subject && (
<span className="ml-1.5 font-mono text-[11px] text-fg">
{entry.subject}
</span>
)}
</p>
</div>
{/* trace 链接 */}
{entry.trace_id && (
<span className="self-center font-mono text-[10.5px] text-fg-faint">
trace · {entry.trace_id.slice(0, 6)}
</span>
)}
</div>
);
})
) : (
<div className="px-4 py-8 text-center text-xs text-fg-faint">
暂无审计记录。
</div>
)}
</div>
</div>
)}
</PageContainer>
);
}
/**
* 格式化 ISO 时间为中文字段时间描述。
* 今日显示时辰(申时三刻),昨日显示"昨日 HH:mm",更早显示日期。
*/
function formatAuditTime(iso?: string): string {
if (!iso) return "——";
try {
const d = new Date(iso);
const now = new Date();
const isToday =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
if (isToday) {
const hour = d.getHours();
const min = d.getMinutes();
const periodNames = [
"子",
"丑",
"寅",
"卯",
"辰",
"巳",
"午",
"未",
"申",
"酉",
"戌",
"亥",
];
const period = periodNames[Math.floor(hour / 2)];
const ke = Math.floor(min / 15) + 1;
const keNames = ["初", "一刻", "二刻", "三刻"];
return `${period}时${keNames[ke - 1] ?? ""}`;
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday =
d.getFullYear() === yesterday.getFullYear() &&
d.getMonth() === yesterday.getMonth() &&
d.getDate() === yesterday.getDate();
if (isYesterday) {
return `昨日 ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
return `${d.getMonth() + 1}月${d.getDate()}日`;
} catch {
return iso;
}
}
@@ -0,0 +1,33 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { PerceptionChannel } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminPerceptionPage() {
let channels: PerceptionChannel[] = [];
let error: string | null = null;
try {
channels = await serverApi.get("/admin/perception/channels");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="烛照枢"
description="感知通道授权——管理六类感知源的同意状态与隐私级别。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<PerceptionConsentMatrix channels={channels ?? []} />
)}
</PageContainer>
);
}
@@ -0,0 +1,451 @@
import { notFound } from "next/navigation";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState, EmptyState } from "@/components/shared/StateViews";
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 { MemoryAdminList } from "@/components/admin/MemoryAdminList";
import { PersonalityEditor } from "@/components/admin/PersonalityEditor";
import { GoalTree } from "@/components/admin/GoalTree";
import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix";
import { WorldEntityList } from "@/components/admin/WorldEntityList";
import { PlanConsole } from "@/components/admin/PlanConsole";
import { AgentRoster } from "@/components/admin/AgentRoster";
import { McpServerPanel } from "@/components/admin/mcp/McpServerPanel";
import { GatewayPanel } from "@/components/admin/mcp/GatewayPanel";
import { ToolRuntimeTabs } from "@/components/admin/mcp/ToolRuntimeTabs";
import { SkillManager } from "@/components/tools/SkillManager";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Pivot } from "@/types/domain";
import type {
AgentRosterEntry,
AuditEntry,
GoalNode,
McpServer,
Memory,
Paginated,
PendingApproval,
PerceptionChannel,
PersonalityVersion,
PlanSummary,
SafetyPolicy,
Skill,
ToolInfo,
WorldEntity,
} from "@/types/api";
export const dynamic = "force-dynamic";
const VALID_PIVOTS: Pivot[] = [
"personality_core",
"goal_orchestrator",
"safety_governor",
"perception",
"world_model",
"planner",
"memory",
"tool_runtime",
"agent_society",
];
const PIVOT_SET = new Set<string>(VALID_PIVOTS);
/**
* 枢详情。九枢均已落地治理视图(设计 §9 分步已基本覆盖):
* 司命(朱批+权限矩阵)、百工(工具注册表)、灵核(人格摘要)、息壤(记忆列表)、
* 太一(目标树)、烛照(感知授权)、河图(世界实体)、洛书(计划)、天工阁(角色名册)。
*/
export default async function PivotDetailPage({
params,
}: {
params: Promise<{ pivot: string }>;
}) {
const { pivot } = await params;
if (!PIVOT_SET.has(pivot)) notFound();
const key = pivot as Pivot;
switch (key) {
case "safety_governor":
return <SafetyGovernorDetail />;
case "tool_runtime":
return <ToolRuntimeDetail />;
case "personality_core":
return <PersonalityCoreDetail />;
case "memory":
return <MemorySystemDetail />;
case "goal_orchestrator":
return <GoalOrchestratorDetail />;
case "perception":
return <PerceptionDetail />;
case "world_model":
return <WorldModelDetail />;
case "planner":
return <PlannerDetail />;
case "agent_society":
return <AgentSocietyDetail />;
default:
notFound();
}
}
/** 司命枢:朱批待办墙(动作)+ 权限策略矩阵 + 安全审计事件 */
async function SafetyGovernorDetail() {
let pending: PendingApproval[] = [];
let policies: SafetyPolicy[] = [];
let auditEvents: AuditEntry[] = [];
let error: string | null = null;
try {
const [pendingData, policyData, auditData] = await Promise.all([
serverApi.get<Paginated<PendingApproval>>("/admin/safety/pending"),
serverApi.get<SafetyPolicy[]>("/safety/policies"),
serverApi.get<Paginated<AuditEntry>>("/safety/audit-events", {
page_size: 20,
}),
]);
pending = pendingData.items;
policies = policyData;
auditEvents = auditData.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="司命枢"
description="安全与裁定 · 权限、确认、审计"
maxWidth="max-w-4xl"
>
{error ? (
<ErrorState message={error} />
) : (
<div className="space-y-8">
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
朱批待办{" "}
<span className="font-sans text-xs text-muted">
({pending.length})
</span>
</h2>
{pending.length === 0 ? (
<EmptyState
title="无待裁定项"
description="当前没有需要裁定的工具调用。"
/>
) : (
<div className="space-y-2">
{pending.map((a) => (
<VermilionAction key={a.confirmation_id} approval={a} />
))}
</div>
)}
</section>
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
权限策略矩阵
</h2>
<PolicyMatrix policies={policies} />
</section>
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
安全审计事件
<span className="font-sans text-xs text-muted">
{" "}
({auditEvents.length})
</span>
</h2>
{auditEvents.length === 0 ? (
<EmptyState
title="暂无安全审计事件"
description="当前没有安全相关的审计记录。"
/>
) : (
<AuditConsole entries={auditEvents} />
)}
</section>
</div>
)}
</PageContainer>
);
}
/** 百工枢(百工阁):能力总览 + 工具注册表 + Skill 能力包 + MCP 服务器统一治理。 */
async function ToolRuntimeDetail() {
let tools: ToolInfo[] = [];
let skills: Skill[] = [];
let mcpServers: McpServer[] = [];
let error: string | null = null;
try {
[tools, skills, mcpServers] = await Promise.all([
serverApi.get<ToolInfo[]>("/tools"),
serverApi.get<Skill[]>("/tools/skills"),
serverApi.get<McpServer[]>("/tools/mcp-servers"),
]);
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="百工枢"
description="器用技艺 · 能力注册、工具、MCP 与 Skill 统一治理"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<div className="space-y-6">
{/* 四标签页 */}
<ToolRuntimeTabs
tabs={[
{
key: "tools",
label: "工具",
badge: tools.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
工具注册表
</h2>
<ToolRegistry tools={tools} />
</section>
),
},
{
key: "mcp",
label: "MCP 服务器",
badge: mcpServers.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
MCP 服务器
</h2>
<McpServerPanel initialServers={mcpServers} />
</section>
),
},
{
key: "gateway",
label: "玄关网关",
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
玄关网关管理
</h2>
<GatewayPanel />
</section>
),
},
{
key: "skills",
label: "Skill 能力包",
badge: skills.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
Skill 能力包
</h2>
<SkillManager initial={skills} />
</section>
),
},
]}
defaultTab="tools"
/>
</div>
)}
</PageContainer>
);
}
/** 灵核枢:人格各字段 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 MemorySystemDetail() {
let memories: Memory[] = [];
let error: string | null = null;
try {
const data = await serverApi.get<Paginated<Memory>>("/memory", {
page_size: 100,
});
memories = data.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="息壤枢"
description="长期记忆 · 自生不息"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : memories.length === 0 ? (
<EmptyState
title="暂无记忆"
description="当前人物还没有沉淀任何长期记忆。"
/>
) : (
<MemoryAdminList initial={memories} />
)}
</PageContainer>
);
}
/** 太一枢:目标层级树(最高目标 → 任务,可编辑标题/优先级/状态) */
async function GoalOrchestratorDetail() {
let goals: GoalNode[] = [];
let error: string | null = null;
try {
goals = await serverApi.get<GoalNode[]>("/admin/goals");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="太一枢"
description="目标统御 · 最高目标与任务调度"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : goals.length === 0 ? (
<EmptyState title="暂无目标" description="太一枢尚未编排任何目标。" />
) : (
<GoalTree goals={goals} />
)}
</PageContainer>
);
}
/** 烛照枢:感知通道授权矩阵(启用 / 授权 / 隐私路由) */
async function PerceptionDetail() {
let channels: PerceptionChannel[] = [];
let error: string | null = null;
try {
channels = await serverApi.get<PerceptionChannel[]>(
"/admin/perception/channels",
);
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="烛照枢"
description="感知世界 · 通道授权与隐私边界"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : (
<PerceptionConsentMatrix channels={channels} />
)}
</PageContainer>
);
}
/** 河图枢:世界实体列表(类型 / 事实-推断 / 置信度) */
async function WorldModelDetail() {
let entities: WorldEntity[] = [];
let error: string | null = null;
try {
entities = await serverApi.get<WorldEntity[]>("/admin/world/entities");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="河图枢"
description="世界模型 · 实体与关系图式"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : entities.length === 0 ? (
<EmptyState
title="暂无世界实体"
description="河图枢尚未建模任何实体。"
/>
) : (
<WorldEntityList entities={entities} />
)}
</PageContainer>
);
}
/** 洛书枢:计划控制台(状态 / 假设 / 风险) */
async function PlannerDetail() {
let plans: PlanSummary[] = [];
let error: string | null = null;
try {
plans = await serverApi.get<PlanSummary[]>("/admin/plans");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="洛书枢"
description="推演变化 · 规划与流程编排"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : plans.length === 0 ? (
<EmptyState title="暂无计划" description="洛书枢尚未编排任何计划。" />
) : (
<PlanConsole plans={plans} />
)}
</PageContainer>
);
}
/** 天工阁枢:九角色在岗名册(启用 / 停用) */
async function AgentSocietyDetail() {
let roster: AgentRosterEntry[] = [];
let error: string | null = null;
try {
roster = await serverApi.get<AgentRosterEntry[]>("/admin/agents/roster");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="天工阁枢"
description="众智能协作 · 九角色分工与协作"
maxWidth="max-w-3xl"
>
{error ? <ErrorState message={error} /> : <AgentRoster roster={roster} />}
</PageContainer>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { PlanConsole } from "@/components/admin/PlanConsole";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { PlanSummary } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminPlansPage() {
let plans: PlanSummary[] = [];
let error: string | null = null;
try {
plans = await serverApi.get("/admin/plans");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="洛书枢"
description="计划控制台——规划治理,支持增删改。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<PlanConsole plans={plans ?? []} />
)}
</PageContainer>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState, EmptyState } from "@/components/shared/StateViews";
import { VermilionAction } from "@/components/admin/VermilionAction";
import { PolicyMatrix } from "@/components/admin/PolicyMatrix";
import { AuditConsole } from "@/components/admin/AuditConsole";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type {
AuditEntry,
Paginated,
PendingApproval,
SafetyPolicy,
} from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 司命枢 · 安全与裁定(对应设计稿 admin-preview.html 司命枢 视图)。
*
* 三层布局:
* ① 朱批待办 — 高风险待裁定工具调用
* ② 权限策略矩阵 — tool × risk × permission
* ③ 安全审计事件 — 近期裁定与拦截
*/
export default async function AdminSafetyPage() {
let pending: PendingApproval[] = [];
let policies: SafetyPolicy[] = [];
let auditEvents: AuditEntry[] = [];
let error: string | null = null;
try {
const [pendingData, policyData, auditData] = await Promise.all([
serverApi.get<Paginated<PendingApproval>>("/admin/safety/pending"),
serverApi.get<SafetyPolicy[]>("/safety/policies"),
serverApi.get<Paginated<AuditEntry>>("/safety/audit-events", {
page_size: 20,
}),
]);
pending = pendingData.items;
policies = policyData;
auditEvents = auditData.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="司命枢"
description="安全与裁定 · 权限、确认、审计"
maxWidth="max-w-4xl"
>
{error ? (
<ErrorState message={error} />
) : (
<div className="space-y-8">
{/* ① 朱批待办 */}
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
朱批待办{" "}
<span className="font-sans text-xs text-muted">
({pending.length})
</span>
</h2>
{pending.length === 0 ? (
<EmptyState
title="无待裁定项"
description="当前没有需要裁定的工具调用。"
/>
) : (
<div className="space-y-2">
{pending.map((a) => (
<VermilionAction key={a.confirmation_id} approval={a} />
))}
</div>
)}
</section>
{/* ② 权限策略矩阵 */}
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
权限策略矩阵
</h2>
<PolicyMatrix policies={policies} />
</section>
{/* ③ 安全审计事件 */}
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
安全审计事件
<span className="font-sans text-xs text-muted">
{" "}
({auditEvents.length})
</span>
</h2>
{auditEvents.length === 0 ? (
<EmptyState
title="暂无安全审计事件"
description="当前没有安全相关的审计记录。"
/>
) : (
<AuditConsole entries={auditEvents} />
)}
</section>
</div>
)}
</PageContainer>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { apiGet } from "@/lib/api/client";
import type {
TenantCharacterBrief,
TenantMemoryBrief,
TenantOverview,
} from "@/types/api";
export default function AdminTenantsPage() {
const [tenants, setTenants] = useState<TenantOverview[]>([]);
const [loading, setLoading] = useState(true);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [details, setDetails] = useState<{
characters: TenantCharacterBrief[];
memories: TenantMemoryBrief[];
} | null>(null);
const fetchTenants = useCallback(async () => {
try {
const data = await apiGet<TenantOverview[]>("/admin/tenants/overview");
setTenants(data);
} catch {
/* error handled by ApiError */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchTenants();
}, [fetchTenants]);
const viewDetails = async (userId: string) => {
setSelectedUserId(userId);
try {
const [characters, memories] = await Promise.all([
apiGet<TenantCharacterBrief[]>(`/admin/tenants/${userId}/characters`),
apiGet<TenantMemoryBrief[]>(`/admin/tenants/${userId}/memories`),
]);
setDetails({ characters, memories });
} catch {
setDetails(null);
}
};
return (
<div className="mx-auto max-w-5xl px-4 py-8">
<div className="mb-6">
<h1 className="text-lg font-semibold text-fg">多租户管理</h1>
<p className="mt-0.5 text-xs text-muted">跨用户数据总览</p>
</div>
{loading ? (
<div className="flex items-center justify-center py-16 text-xs text-muted">
加载中…
</div>
) : tenants.length === 0 ? (
<div className="flex items-center justify-center py-16 text-xs text-muted">
暂无用户
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border-glass">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border-glass bg-glass text-xs font-medium text-muted">
<th className="px-4 py-3">用户</th>
<th className="px-4 py-3">角色</th>
<th className="px-4 py-3 text-right">人物数</th>
<th className="px-4 py-3 text-right">记忆数</th>
<th className="px-4 py-3">操作</th>
</tr>
</thead>
<tbody>
{tenants.map((t) => (
<tr
key={t.user_id}
className="border-b border-border-glass last:border-0"
>
<td className="px-4 py-3">
<div>
<span className="font-medium text-fg">{t.username}</span>
<span className="ml-2 text-xs text-muted">{t.email}</span>
</div>
</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
t.is_admin
? "bg-patina/15 text-patina"
: "bg-fg/5 text-muted"
}`}
>
{t.is_admin ? "管理员" : "普通用户"}
</span>
{!t.is_active && (
<span className="ml-1.5 rounded-full bg-cinnabar/15 px-2 py-0.5 text-[11px] font-medium text-cinnabar">
已禁用
</span>
)}
</td>
<td className="px-4 py-3 text-right text-fg">
{t.character_count}
</td>
<td className="px-4 py-3 text-right text-fg">
{t.memory_count}
</td>
<td className="px-4 py-3">
<button
type="button"
onClick={() => viewDetails(t.user_id)}
className="rounded-lg px-2.5 py-1 text-[11px] font-medium text-patina transition-colors hover:bg-patina/10"
>
查看详情
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{selectedUserId && details && (
<div className="mt-8">
<div className="mb-4 grid grid-cols-2 gap-4">
<div className="rounded-xl border border-border-glass p-4">
<h2 className="mb-3 text-sm font-medium text-fg">
人物 ({details.characters.length})
</h2>
{details.characters.length === 0 ? (
<p className="text-xs text-muted">无人物</p>
) : (
<div className="space-y-2">
{details.characters.map((c) => (
<div
key={c.id}
className="rounded-lg bg-glass px-3 py-2 text-xs"
>
<span className="font-medium text-fg">{c.name}</span>
<span className="ml-2 text-muted">({c.status})</span>
{c.display_name && (
<span className="ml-2 text-muted">
/{c.display_name}
</span>
)}
</div>
))}
</div>
)}
</div>
<div className="rounded-xl border border-border-glass p-4">
<h2 className="mb-3 text-sm font-medium text-fg">
记忆 ({details.memories.length})
</h2>
{details.memories.length === 0 ? (
<p className="text-xs text-muted">无记忆</p>
) : (
<div className="space-y-2">
{details.memories.map((m) => (
<div
key={m.id}
className="rounded-lg bg-glass px-3 py-2 text-xs"
>
<span className="font-medium text-fg">{m.title}</span>
<span className="ml-2 text-muted">({m.type})</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState, EmptyState } from "@/components/shared/StateViews";
import { ToolRegistry } from "@/components/admin/ToolRegistry";
import { McpServerPanel } from "@/components/admin/mcp/McpServerPanel";
import { GatewayPanel } from "@/components/admin/mcp/GatewayPanel";
import { ToolRuntimeTabs } from "@/components/admin/mcp/ToolRuntimeTabs";
import { SkillManager } from "@/components/tools/SkillManager";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { McpServer, Skill, ToolInfo } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 百工枢 · 器用技艺治理(对应设计稿 admin-preview.html 百工枢 视图)。
*
* 四标签页:
* ① 工具注册表 — 清单与管理
* ② MCP 服务器 — 外部能力接入
* ③ 玄关网关 — 路由与代理
* ④ Skill 能力包 — 可插拔能力
*/
export default async function AdminToolsPage() {
let tools: ToolInfo[] = [];
let skills: Skill[] = [];
let mcpServers: McpServer[] = [];
let error: string | null = null;
try {
[tools, skills, mcpServers] = await Promise.all([
serverApi.get<ToolInfo[]>("/tools"),
serverApi.get<Skill[]>("/tools/skills"),
serverApi.get<McpServer[]>("/tools/mcp-servers"),
]);
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="百工枢"
description="器用技艺 · 能力注册、工具、MCP 与 Skill 统一治理"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<div className="space-y-6">
<ToolRuntimeTabs
tabs={[
{
key: "tools",
label: "工具",
badge: tools.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
工具注册表
</h2>
{tools.length === 0 ? (
<EmptyState
title="暂无注册工具"
description="当前没有注册的工具。"
/>
) : (
<ToolRegistry tools={tools} />
)}
</section>
),
},
{
key: "mcp",
label: "MCP 服务器",
badge: mcpServers.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
MCP 服务器
</h2>
<McpServerPanel initialServers={mcpServers} />
</section>
),
},
{
key: "gateway",
label: "玄关网关",
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
玄关网关管理
</h2>
<GatewayPanel />
</section>
),
},
{
key: "skills",
label: "Skill 能力包",
badge: skills.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
Skill 能力包
</h2>
{skills.length === 0 ? (
<EmptyState
title="暂无 Skill"
description="当前没有安装 Skill 能力包。"
/>
) : (
<SkillManager initial={skills} />
)}
</section>
),
},
]}
defaultTab="tools"
/>
</div>
)}
</PageContainer>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState, LoadingState } from "@/components/shared/StateViews";
import { TraceList } from "@/components/admin/TraceList";
import { TraceSearch } from "@/components/admin/TraceSearch";
import { TraceTimeline } from "@/components/admin/TraceTimeline";
import { adminApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { TraceDetail, TraceSummary } from "@/types/api";
/**
* 链路追踪 · 列表模式:默认展示所有 trace(最近活动倒序)。
* 顶部搜索框可跳转到指定 trace;点击列表项进入详情时间线。
*/
function TraceListView() {
const router = useRouter();
const [items, setItems] = useState<TraceSummary[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
useEffect(() => {
let cancelled = false;
adminApi
.listTraces()
.then((res) => {
if (!cancelled) setItems(res.items);
})
.catch((e) => {
if (!cancelled) setError(describeError(e));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
function jumpToTrace() {
const trimmed = query.trim();
if (trimmed) {
router.push(`/admin/traces?trace_id=${encodeURIComponent(trimmed)}`);
}
}
return (
<div className="space-y-4">
<TraceSearch
value={query}
onChange={setQuery}
onSubmit={jumpToTrace}
loading={false}
/>
{loading && <LoadingState label="正在汇聚链路…" />}
{!loading && error && <ErrorState message={error} />}
{!loading && !error && items && <TraceList items={items} />}
</div>
);
}
/**
* 链路追踪 · 详情模式:展示指定 trace_id 的时间线。
* 由父级以 key={traceId} 挂载,trace 切换即重挂载,loading 初值随之重置,
* 无需在 effect 内同步 setState。
*/
function TraceDetailView({ traceId }: { traceId: string }) {
const router = useRouter();
const [detail, setDetail] = useState<TraceDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
adminApi
.getTrace(traceId)
.then((data) => {
if (!cancelled) setDetail(data);
})
.catch((e) => {
if (!cancelled) setError(describeError(e));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [traceId]);
return (
<div className="space-y-4">
<button
type="button"
onClick={() => router.push("/admin/traces")}
className="text-xs text-muted transition-colors hover:text-fg"
>
← 全部链路
</button>
<div className="rounded-xl border border-border-glass bg-glass px-4 py-2.5">
<span className="font-mono text-xs text-fg">{traceId}</span>
</div>
{loading && <LoadingState label="正在汇聚链路…" />}
{!loading && error && <ErrorState message={error} />}
{!loading && !error && detail && <TraceTimeline detail={detail} />}
</div>
);
}
function TracesBody() {
const searchParams = useSearchParams();
const focusTraceId = searchParams.get("trace_id") ?? "";
return focusTraceId ? (
<TraceDetailView key={focusTraceId} traceId={focusTraceId} />
) : (
<TraceListView />
);
}
export default function TracesPage() {
return (
<PageContainer
title="链路追踪"
description="汇聚运行事件、安全审计与请求幂等的全部链路,按最近活动倒序。"
maxWidth="max-w-4xl"
>
<Suspense fallback={<LoadingState label="正在准备链路追踪…" />}>
<TracesBody />
</Suspense>
</PageContainer>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { AdminCreateUserBody, AdminUser } from "@/lib/api/admin";
import { createUser, listUsers, updateUser } from "@/lib/api/admin";
import { Button } from "@/components/ui/Button";
export default function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const fetchUsers = useCallback(async () => {
try {
const data = await listUsers();
setUsers(data);
} catch {
/* 错误由 ApiError 统一处理 */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchUsers();
}, [fetchUsers]);
const handleToggleActive = async (user: AdminUser) => {
await updateUser(user.id, { is_active: !user.is_active });
await fetchUsers();
};
const handleToggleAdmin = async (user: AdminUser) => {
await updateUser(user.id, { is_admin: !user.is_admin });
await fetchUsers();
};
return (
<div className="mx-auto max-w-4xl px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold text-fg">用户管理</h1>
<p className="mt-0.5 text-xs text-muted">管理所有注册用户</p>
</div>
<Button variant="primary" onClick={() => setShowCreate(true)}>
创建用户
</Button>
</div>
{showCreate && (
<CreateUserForm
onDone={() => {
setShowCreate(false);
fetchUsers();
}}
onCancel={() => setShowCreate(false)}
/>
)}
{loading ? (
<div className="flex items-center justify-center py-16 text-xs text-muted">
加载中…
</div>
) : users.length === 0 ? (
<div className="flex items-center justify-center py-16 text-xs text-muted">
暂无用户
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border-glass">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border-glass bg-glass text-xs font-medium text-muted">
<th className="px-4 py-3">用户名</th>
<th className="px-4 py-3">邮箱</th>
<th className="px-4 py-3">角色</th>
<th className="px-4 py-3">状态</th>
<th className="px-4 py-3">操作</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
className="border-b border-border-glass last:border-0"
>
<td className="px-4 py-3 font-medium text-fg">
{user.username}
</td>
<td className="px-4 py-3 text-muted">{user.email}</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
user.is_admin
? "bg-patina/15 text-patina"
: "bg-fg/5 text-muted"
}`}
>
{user.is_admin ? "管理员" : "普通用户"}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 text-xs ${
user.is_active ? "text-patina" : "text-muted"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
user.is_active ? "bg-patina" : "bg-muted"
}`}
/>
{user.is_active ? "启用" : "禁用"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleToggleActive(user)}
className="rounded-lg px-2.5 py-1 text-[11px] font-medium transition-colors hover:bg-fg/5"
style={{
color: user.is_active
? "var(--cinnabar)"
: "var(--patina)",
}}
>
{user.is_active ? "禁用" : "启用"}
</button>
{!user.is_admin && (
<button
type="button"
onClick={() => handleToggleAdmin(user)}
className="rounded-lg px-2.5 py-1 text-[11px] font-medium transition-colors hover:bg-fg/5"
style={{ color: "var(--patina)" }}
>
设为管理员
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function CreateUserForm({
onDone,
onCancel,
}: {
onDone: () => void;
onCancel: () => void;
}) {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
const body: AdminCreateUserBody = {
username,
email,
password,
is_admin: isAdmin,
};
await createUser(body);
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : "创建失败");
} finally {
setSaving(false);
}
};
return (
<div className="glass-strong mb-6 rounded-xl border border-border-glass p-4">
<h2 className="mb-4 text-sm font-medium text-fg">创建新用户</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-muted">
用户名
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">邮箱</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
required
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-muted">密码</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
required
minLength={6}
/>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={isAdmin}
onChange={(e) => setIsAdmin(e.target.checked)}
className="h-4 w-4 rounded border-border-glass accent-patina"
/>
<span className="text-xs text-muted">设为管理员</span>
</label>
{error && <p className="text-xs text-cinnabar">{error}</p>}
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-fg/5"
>
取消
</button>
<button
type="submit"
disabled={saving}
className="rounded-lg bg-patina px-3 py-1.5 text-xs font-medium text-white transition-opacity disabled:opacity-50"
>
{saving ? "创建中…" : "创建"}
</button>
</div>
</form>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { WorldEntityList } from "@/components/admin/WorldEntityList";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { WorldEntity } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminWorldPage() {
let entities: WorldEntity[] = [];
let error: string | null = null;
try {
entities = await serverApi.get("/admin/world/entities");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="河图枢"
description="世界实体管理——人物对世界的认知实体,支持增删改。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<WorldEntityList entities={entities ?? []} />
)}
</PageContainer>
);
}
+41
View File
@@ -0,0 +1,41 @@
/**
* BFF 认证 API Route — POST /api/auth/login
*
* 路由层:解析请求 → 调 authService.login → 返回响应(不含业务逻辑)。
*/
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import {
login as loginService,
AuthServiceError,
} from "@/lib/backend/services/auth";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, password } = body;
if (!username || !password) {
return NextResponse.json(
{ error: { code: "invalid_request", message: "请提供用户名和密码。" } },
{ status: 400 },
);
}
const result = await loginService(username, password);
return NextResponse.json(result);
} catch (err) {
if (err instanceof AuthServiceError) {
return NextResponse.json(
{ error: { code: err.code, message: err.message } },
{ status: err.status },
);
}
console.error("[BFF /api/auth/login]", err);
return NextResponse.json(
{ error: { code: "internal_error", message: "服务内部错误。" } },
{ status: 500 },
);
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* BFF 认证 API Route — POST /api/auth/logout
*
* 路由层:调 authService.logout → 返回响应。
*/
import { NextResponse } from "next/server";
import { logout } from "@/lib/backend/services/auth";
export async function POST() {
await logout();
return NextResponse.json({ ok: true });
}
+53
View File
@@ -0,0 +1,53 @@
/**
* BFF 认证 API Route — GET /api/auth/me
*
* 路由层:读 token → 调 authService.getMe → 401 时 refresh 重试 → 返回响应。
*/
import { NextResponse } from "next/server";
import { getAccessToken } from "@/lib/backend/cookies";
import { getMe, refreshToken, logout } from "@/lib/backend/services/auth";
export async function GET() {
try {
const token = await getAccessToken();
if (!token) {
return NextResponse.json(
{ error: { code: "unauthorized", message: "未登录。" } },
{ status: 401 },
);
}
let user = await getMe();
if (!user) {
// 401 或 token 无效 → 尝试 refresh
const newToken = await refreshToken();
if (newToken) {
user = await getMe();
}
}
if (!user) {
await logout();
return NextResponse.json(
{
error: { code: "unauthorized", message: "会话已过期,请重新登录。" },
},
{ status: 401 },
);
}
const accessToken = await getAccessToken();
return NextResponse.json({
user,
access_token: accessToken ?? "",
refresh_token: "",
});
} catch (err) {
console.error("[BFF /api/auth/me]", err);
return NextResponse.json(
{ error: { code: "network_error", message: "无法连接后端服务。" } },
{ status: 502 },
);
}
}
@@ -0,0 +1,49 @@
/**
* BFF 认证 API Route — POST /api/auth/refresh
*
* 路由层:调 authService.refreshToken → 返回响应。
*/
import { NextResponse } from "next/server";
import { getAccessToken, getRefreshToken } from "@/lib/backend/cookies";
import { refreshToken, getMe, logout } from "@/lib/backend/services/auth";
export async function POST() {
try {
const refresh = await getRefreshToken();
if (!refresh) {
return NextResponse.json(
{ error: { code: "unauthorized", message: "未登录或会话已过期。" } },
{ status: 401 },
);
}
const newToken = await refreshToken();
if (!newToken) {
await logout();
return NextResponse.json(
{
error: { code: "unauthorized", message: "会话已过期,请重新登录。" },
},
{ status: 401 },
);
}
// refreshToken 成功后,cookie 已更新,返回用户信息
const user = await getMe();
const accessToken = await getAccessToken();
const newRefreshToken = await getRefreshToken();
return NextResponse.json({
user,
access_token: accessToken ?? "",
refresh_token: newRefreshToken ?? "",
expires_in: 7 * 86400,
});
} catch {
return NextResponse.json(
{ error: { code: "internal_error", message: "刷新令牌失败。" } },
{ status: 500 },
);
}
}
@@ -0,0 +1,66 @@
/**
* BFF 通用代理 — /api/proxy/<path>
*
* 所有需要鉴权的业务请求统一经此代理:浏览器同源请求自动携带 httpOnly cookie,
* 代理在服务端读取 access_token 并注入 Authorization,转发到后端。客户端 JS 永不
* 接触 token。
*
* 路由层职责:读路径 → 转发 → 401 重试 → 返回响应。底层函数在 lib/backend/proxy 中。
*/
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { cookies } from "next/headers";
import { BACKEND_ROOT } from "@/lib/backend/config";
import {
getAccessToken,
COOKIE_ACCESS,
COOKIE_REFRESH,
AUTH_COOKIE_CLEAR_OPTIONS,
} from "@/lib/backend/cookies";
import { refreshToken } from "@/lib/backend/services/auth";
import { buildTargetUrl, forward, proxyResponse } from "@/lib/backend/proxy";
const BACKEND = BACKEND_ROOT;
type Ctx = { params: Promise<{ path?: string[] }> };
async function handler(req: NextRequest, ctx: Ctx): Promise<Response> {
const { path = [] } = await ctx.params;
const target = buildTargetUrl(BACKEND, path, req.nextUrl.search);
// 缓冲请求体,使 401 重试可复用(GET/HEAD 无 body)
const hasBody = req.method !== "GET" && req.method !== "HEAD";
const body = hasBody ? await req.arrayBuffer() : null;
const token = await getAccessToken();
let resp = await forward(target, req, token, body);
// 后端 401 → 刷新后重试一次
if (resp.status === 401) {
const refreshed = await refreshToken();
if (refreshed) {
resp = await forward(target, req, refreshed, body);
} else {
const store = await cookies();
store.set(COOKIE_ACCESS, "", AUTH_COOKIE_CLEAR_OPTIONS);
store.set(COOKIE_REFRESH, "", AUTH_COOKIE_CLEAR_OPTIONS);
return NextResponse.json(
{
error: { code: "unauthorized", message: "登录已过期,请重新登录。" },
},
{ status: 401 },
);
}
}
return proxyResponse(resp);
}
export {
handler as GET,
handler as POST,
handler as PUT,
handler as PATCH,
handler as DELETE,
};
@@ -0,0 +1,59 @@
/**
* BFF SSE 代理 — /api/sse/<path>
*
* EventSource 不支持自定义请求头,无法直接携带 Authorization;且 Next dev 的
* rewrites 代理会缓冲 SSE 流式响应。故把 SSE 端点经此 BFF 路由中转:
* - 浏览器 EventSource 同源请求,自动携带 httpOnly cookie;
* - BFF 在服务端读 access_token 注入 Authorization,fetch 后端 SSE 端点;
* - 后端响应体以 ReadableStream 原样 pipe 回客户端,不缓冲;
* - 透传 Last-Event-ID(EventSource 断线重连时浏览器自动带)。
*
* 路由层职责:读路径 → 建连 → 401 重试 → 返回流。底层函数在 lib/backend/sse 中。
*/
import type { NextRequest } from "next/server";
import { BACKEND_ROOT } from "@/lib/backend/config";
import { getAccessToken } from "@/lib/backend/cookies";
import { refreshToken } from "@/lib/backend/services/auth";
import { buildTargetUrl, connect } from "@/lib/backend/sse";
const BACKEND = BACKEND_ROOT;
/** 强制动态,避免被静态化缓存。 */
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ path?: string[] }> };
export async function GET(req: NextRequest, ctx: Ctx): Promise<Response> {
const { path = [] } = await ctx.params;
const target = buildTargetUrl(BACKEND, path, req.nextUrl.search);
const lastEventId = req.headers.get("last-event-id");
const token = await getAccessToken();
let resp = await connect(target, token, lastEventId);
// 初始建立 401 → 刷新一次再用新 token 建连
if (resp.status === 401) {
const refreshed = await refreshToken();
if (refreshed) {
resp = await connect(target, refreshed, lastEventId);
}
}
// 后端未就绪或鉴权失败 → 回传状态,前端 EventSource 触发 onerror
if (!resp.ok || !resp.body) {
return new Response(resp.body, {
status: resp.status,
statusText: resp.statusText,
});
}
// 流式透传 SSE
return new Response(resp.body, {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
},
});
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useEffect } from "react";
/**
* 根级错误:当根 layout.tsx 自身渲染崩溃时触发。
* 此时全局 layout 与 globals.css 均不可用,故必须自带 <html><body>
* 并使用内联样式(暗色墨夜底),不依赖任何外部样式与组件。
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[global-error]", error);
}, [error]);
return (
<html lang="zh-CN">
<body
style={{
margin: 0,
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#0b0f0e",
color: "#e6e2d6",
fontFamily:
'-apple-system, BlinkMacSystemFont, "PingFang SC", system-ui, sans-serif',
}}
>
<div style={{ textAlign: "center", padding: "2rem", maxWidth: 420 }}>
<p
style={{ fontSize: "1.125rem", fontWeight: 600, color: "#b5402f" }}
>
镜面碎裂
</p>
<p
style={{
fontSize: "0.8rem",
color: "#8a9389",
marginTop: "0.5rem",
lineHeight: 1.6,
}}
>
应用外壳加载失败。可尝试重新加载,或检查后端服务与配置后刷新。
</p>
{error.digest && (
<p
style={{
fontFamily: "monospace",
fontSize: "0.6rem",
color: "#8a9389",
marginTop: "0.75rem",
}}
>
trace: {error.digest}
</p>
)}
<button
type="button"
onClick={() => reset()}
style={{
marginTop: "1.25rem",
padding: "0.5rem 1.25rem",
borderRadius: "0.75rem",
border: "1px solid rgba(255,255,255,0.12)",
background: "rgba(255,255,255,0.08)",
color: "#e6e2d6",
fontSize: "0.875rem",
cursor: "pointer",
}}
>
重试
</button>
</div>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="44" fill="none" stroke="#9a7d3e" stroke-width="4" />
<circle cx="50" cy="50" r="36" fill="none" stroke="#9a7d3e" stroke-width="1" stroke-opacity="0.4" />
<circle cx="50" cy="50" r="11" fill="#3f7d72" />
</svg>

After

Width:  |  Height:  |  Size: 306 B

+89
View File
@@ -0,0 +1,89 @@
import type { Metadata, Viewport } from "next";
import { AuthInitializer } from "@/components/layout/AuthInitializer";
import { ThemeInitializer } from "@/components/layout/ThemeInitializer";
import { ToastContainer } from "@/components/ui/ToastContainer";
import "@/styles/globals.css";
export const metadata: Metadata = {
title: "魂镜",
description: "魂镜 · 数字生命控制台与观察窗",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
/**
* 视口适配 iOS safe-area:确保在刘海屏/圆角屏上不被安全区域遮挡。
* env(safe-area-inset-*) 的 padding 已在 globals.css body 上设置。
*/
viewportFit: "cover",
};
/**
* 防闪主题脚本:在 hydration 前根据 localStorage 中的主题偏好设置 .dark,
* 避免亮暗切换时的视觉闪烁。仅读取公开偏好,不含敏感信息。
*/
const themeBootstrap = `
(function () {
try {
var raw = localStorage.getItem('hunjing-settings');
var theme = 'system';
if (raw) {
var parsed = JSON.parse(raw);
theme = (parsed && parsed.state && parsed.state.theme) || 'system';
}
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (theme === 'system' && prefersDark)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
`;
/**
* 极简根布局:仅承担 html/body 骨架、全局样式、主题初始化与 metadata。
* 壳层(AppShell / AdminShell)下沉到各 route group 的 layout,使主域与
* 司命台拥有各自独立的导航与视觉身份(详见 docs/superpowers/specs/
* 2026-06-27-admin-shell-and-trace-tracking-design.md §2)。
*/
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeBootstrap }} />
{/* Noto Serif CJK SC 全量 >15MB,Google Fonts API 自动返回 CJK 子集。
系统字体优先(Songti SC / Source Han Serif SC),此 CDN 仅作保底。
生产环境建议 glyph-subsetter 自托管:https://github.com/googlefonts/noto-cjk */}
<link
rel="preconnect"
href="https://fonts.googleapis.com"
crossOrigin="anonymous"
/>
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
{/* eslint-disable @next/next/no-page-custom-font -- CJK 15MB+ 无法用 next/font 替代 */}
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Noto+Serif+CJK+SC:wght@400;500;600;700&display=swap"
/>
{/* eslint-enable @next/next/no-page-custom-font */}
</head>
<body>
<div className="grain" />
<div className="content-layer">
<ThemeInitializer />
<AuthInitializer />
{children}
<ToastContainer />
</div>
</body>
</html>
);
}
+485
View File
@@ -0,0 +1,485 @@
"use client";
import { FormEvent, Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { motion } from "framer-motion";
import { useAuthStore } from "@/lib/stores/auth-store";
import { useSettingsStore } from "@/lib/stores/settings-store";
import type { ThemeMode } from "@/types/domain";
/**
* 铜镜徽 LOGO · 品牌图形标
*/
function LogoMark({ size = 68 }: { size?: number }) {
const s = size;
return (
<svg
width={s}
height={s}
viewBox="0 0 68 68"
className="mx-auto mb-4"
role="img"
aria-label="魂镜铜镜徽"
>
<circle cx="34" cy="34" r="32" className="logo-mark-ring" />
<circle cx="34" cy="34" r="27" className="logo-mark-inner" />
<circle cx="34" cy="34" r="22" className="logo-mark-char-ring" />
<circle cx="34" cy="34" r="15" className="logo-mark-char-ring" />
<circle cx="34" cy="34" r="8" fill="var(--patina-wash)" opacity="0.3" />
<text x="34" y="40" textAnchor="middle" className="logo-mark-char">
魂
</text>
<circle cx="34" cy="6" r="1.5" className="logo-mark-dot" />
<circle cx="34" cy="62" r="1.5" className="logo-mark-dot" />
<circle cx="6" cy="34" r="1.5" className="logo-mark-dot" />
<circle cx="62" cy="34" r="1.5" className="logo-mark-dot" />
</svg>
);
}
/**
* 小星盘 · 右上角装饰(140s 慢旋转)
*/
function MiniAstrolabe() {
return (
<div
className="pointer-events-none fixed right-[clamp(24px,5vw,72px)] top-[clamp(80px,12vh,120px)] hidden md:block"
style={{
width: 120,
height: 120,
opacity: "var(--astrolabe-opacity, 0.25)",
}}
role="img"
aria-label="九枢星盘"
>
<svg viewBox="0 0 100 100" className="h-full w-full">
<circle
cx="50"
cy="50"
r="48"
fill="none"
stroke="var(--gilt-dim)"
strokeWidth="0.6"
/>
<circle
cx="50"
cy="50"
r="42"
fill="none"
stroke="var(--gilt-faint)"
strokeWidth="0.3"
strokeDasharray="1.5 3"
/>
<g className="animate-slow-rot">
<circle
cx="50"
cy="50"
r="33"
fill="none"
stroke="var(--patina-dim)"
strokeWidth="0.5"
/>
<circle
cx="50"
cy="50"
r="24"
fill="none"
stroke="var(--gilt-faint)"
strokeWidth="0.4"
strokeDasharray="1 4"
/>
<g stroke="var(--gilt-faint)" strokeWidth="0.3" opacity="0.5">
<line x1="50" y1="17" x2="50" y2="4" />
<line x1="83" y1="30" x2="93" y2="22" />
<line x1="93" y1="62" x2="97" y2="68" />
<line x1="74" y1="81" x2="80" y2="89" />
<line x1="32" y1="82" x2="26" y2="90" />
<line x1="14" y1="56" x2="6" y2="60" />
<line x1="18" y1="26" x2="10" y2="20" />
</g>
<g fill="var(--gilt)" opacity="0.4">
<circle cx="50" cy="12" r="1.8" />
<circle cx="82" cy="28" r="1.5" />
<circle cx="88" cy="56" r="1.6" />
<circle cx="68" cy="80" r="1.4" />
<circle cx="34" cy="78" r="1.5" />
<circle cx="14" cy="52" r="1.7" />
<circle cx="22" cy="24" r="1.4" />
</g>
</g>
<circle
cx="50"
cy="50"
r="3.5"
fill="var(--patina-bright)"
opacity="0.5"
/>
<circle cx="50" cy="50" r="1.5" fill="var(--patina)" opacity="0.7" />
</svg>
</div>
);
}
function LoginForm() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [departing, setDeparting] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const login = useAuthStore((s) => s.login);
const isLoading = useAuthStore((s) => s.isLoading);
const theme = useSettingsStore((s) => s.theme);
const setTheme = useSettingsStore((s) => s.setTheme);
const THEMES: { key: ThemeMode; label: string }[] = [
{ key: "light", label: "素绢" },
{ key: "dark", label: "夜铜" },
{ key: "system", label: "随系统" },
];
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
try {
await login({ username, password });
setDeparting(true);
await new Promise((r) => setTimeout(r, 400));
const redirect = searchParams.get("redirect") || "/chat";
router.push(redirect);
} catch (err) {
const msg =
err instanceof Error ? err.message : "用户名或密令错误,铜镜不识此名。";
setError(msg);
}
};
return (
<div className="relative flex min-h-screen flex-col overflow-hidden">
{/* 顶栏:主题切换 */}
<header
className="fixed top-0 left-0 right-0 z-40 flex items-center justify-end gap-4 px-[clamp(20px,4vw,48px)] py-3.5"
style={{
background: "color-mix(in srgb, var(--bg) 76%, transparent)",
backdropFilter: "blur(16px) saturate(140%)",
}}
>
<div
className="flex items-center gap-1 rounded-full border p-0.5"
style={{
borderColor: "var(--rule)",
background: "var(--surface)",
}}
>
{THEMES.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTheme(t.key)}
className="rounded-full px-3 py-1 text-[11px] font-medium transition-all duration-150"
style={{
background:
theme === t.key ? "var(--patina-wash)" : "transparent",
color: theme === t.key ? "var(--patina)" : "var(--fg-dim)",
}}
>
{t.label}
</button>
))}
</div>
</header>
<div className="flex min-h-screen items-center justify-center px-4">
{/* 星盘装饰 */}
<MiniAstrolabe />
{/* 登录卡片:抛光铜镜面 */}
<motion.div
initial={{ opacity: 0, y: 18 }}
animate={
departing
? { opacity: 0, y: -10, scale: 0.97, filter: "blur(2px)" }
: { opacity: 1, y: 0 }
}
transition={{
duration: departing ? 0.4 : 0.7,
ease: [0.16, 1, 0.3, 1],
delay: departing ? 0 : 0.12,
}}
className="mirror-face relative w-full max-w-sm overflow-hidden rounded-[6px] p-0"
style={{
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)",
}}
>
{/* 扫描光泽层 */}
<div className="pointer-events-none absolute inset-0 z-0 animate-sweep" />
{/* 内容层 */}
<div className="relative z-10 px-10 pb-9 pt-11">
{/* 品牌区 */}
<div className="mb-8 text-center">
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
ease: [0.16, 1, 0.3, 1],
delay: 0.22,
}}
>
<LogoMark />
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
ease: [0.16, 1, 0.3, 1],
delay: 0.3,
}}
className="font-serif text-[clamp(26px,4vw,30px)] font-bold tracking-[0.14em] text-fg"
>
魂鏡
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
ease: [0.16, 1, 0.3, 1],
delay: 0.38,
}}
className="mt-2 font-mono text-[9.5px] font-medium uppercase tracking-[0.26em] text-gilt"
>
MIRROR OF THE HEART
</motion.p>
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
ease: [0.16, 1, 0.3, 1],
delay: 0.46,
}}
className="mx-auto mt-3.5 h-px w-6 bg-gilt-dim"
aria-hidden
/>
</div>
{/* 表单 */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
delay: 0.5,
}}
className="flex flex-col gap-1.5"
>
<label
htmlFor="username"
className="text-[11.5px] font-medium tracking-[0.04em] text-fg-dim"
>
用户名
</label>
<div
className="flex items-center rounded-[4px] border transition-all duration-200"
style={{
borderColor: error ? "var(--cinnabar-dim)" : "var(--rule)",
boxShadow: error
? "0 0 0 1px var(--cinnabar-dim)"
: undefined,
background:
"var(--input-bg, color-mix(in srgb, var(--bg) 70%, transparent))",
}}
>
<span
className="flex w-9 flex-none items-center justify-center font-mono opacity-50 transition-opacity duration-200 text-fg-faint"
aria-hidden
>
◎
</span>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
onFocus={() => setError(null)}
placeholder="铜镜所照之名"
autoComplete="username"
autoFocus
className="min-w-0 flex-1 bg-transparent px-3 py-[11px] text-sm text-fg outline-none placeholder:text-[13px]"
required
/>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
delay: 0.58,
}}
className="flex flex-col gap-1.5"
>
<label
htmlFor="password"
className="text-[11.5px] font-medium tracking-[0.04em] text-fg-dim"
>
密令
</label>
<div
className="flex items-center rounded-[4px] border transition-all duration-200"
style={{
borderColor: error ? "var(--cinnabar-dim)" : "var(--rule)",
boxShadow: error
? "0 0 0 1px var(--cinnabar-dim)"
: undefined,
background:
"var(--input-bg, color-mix(in srgb, var(--bg) 70%, transparent))",
}}
>
<span
className="flex w-9 flex-none items-center justify-center font-mono opacity-50 transition-opacity duration-200 text-fg-faint"
aria-hidden
>
◉
</span>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onFocus={() => setError(null)}
placeholder="铭文锁钥"
autoComplete="current-password"
className="min-w-0 flex-1 bg-transparent px-3 py-[11px] text-sm text-fg outline-none placeholder:text-[13px]"
required
/>
</div>
</motion.div>
{/* 错误消息 */}
{error && (
<motion.div
initial={{ opacity: 0, x: -4 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="flex items-center gap-2 rounded-[4px] px-3.5 py-2.5 text-[12.5px]"
style={{
border: "1px solid var(--cinnabar-dim)",
background: "rgba(184,57,46,0.07)",
color: "var(--cinnabar)",
}}
role="alert"
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
aria-hidden
className="flex-none"
>
<circle
cx="8"
cy="8"
r="6"
stroke="currentColor"
strokeWidth="1.2"
/>
<path
d="M8 5v3M8 10.5v.5"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
{error}
</motion.div>
)}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
delay: 0.66,
}}
>
<button
type="submit"
disabled={isLoading}
className="mt-1 flex w-full items-center justify-center gap-2 rounded-[4px] px-5 py-3 font-serif text-[15px] font-semibold tracking-[0.1em] transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-55"
style={{
background: "var(--cinnabar)",
border: "1px solid var(--cinnabar-dim)",
color: "#ece6d8",
boxShadow: isLoading ? undefined : undefined,
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.boxShadow =
"0 0 20px var(--cinnabar-dim)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "none";
}}
>
{isLoading ? (
<span className="inline-flex items-center gap-2">
<span className="inline-block h-[7px] w-[7px] animate-pulse rounded-full bg-current opacity-100" />
登入灵台
</span>
) : (
"登入"
)}
</button>
</motion.div>
</form>
<p className="mb-4 mt-3 text-center text-xs text-muted">
尚未有缘?{" "}
<a href="/register" className="text-patina hover:underline">
注册魂镜
</a>
</p>
{/* 脚注 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
delay: 0.74,
}}
className="mt-8 border-t border-rule pt-4 text-center"
>
<p className="font-mono text-[9.5px] tracking-[0.22em] text-fg-faint">
琉璃镜 · 照见数字生命
</p>
</motion.div>
</div>
</motion.div>
</div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginForm />
</Suspense>
);
}
+146
View File
@@ -0,0 +1,146 @@
import Link from "next/link";
import { BronzeMirror } from "@/components/ui/BronzeMirror";
import { serverApi } from "@/lib/api/server-client";
import { FEATURE_FLAGS } from "@/lib/config";
export const dynamic = "force-dynamic";
const QUICK_LINKS = [
{
href: "/chat",
label: "对话",
desc: "与数字生命交谈",
gradient: "from-patina/20 to-patina/5",
},
{
href: "/characters",
label: "人物",
desc: "查看当前激活人物",
gradient: "from-gold/20 to-gold/5",
},
{
href: "/memory",
label: "记忆",
desc: "浏览长期记忆",
gradient: "from-cinnabar/20 to-cinnabar/5",
},
{
href: "/tools",
label: "工具",
desc: "能力包与工具调用",
gradient: "from-patina/15 to-patina/5",
},
];
export default async function HomePage() {
let reachable = true;
try {
// Server Component 直连后端健康检查(不走 BFF 代理——httpOnly cookie 不传递)
await serverApi.get("/health");
} catch {
reachable = false;
}
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto px-6 py-12">
{/* 中心品牌区 */}
<div className="flex w-full max-w-lg flex-col items-center text-center">
<BronzeMirror
size={88}
state={reachable ? "steady" : "breathing"}
className="mb-6"
/>
<h1 className="font-serif text-3xl font-semibold tracking-[0.15em] text-fg">
魂镜
</h1>
<p className="mt-2 text-sm tracking-wider text-muted">数字生命控制台</p>
<p className="mt-4 max-w-sm text-sm leading-relaxed text-fg/70">
点亮灵台,与你的数字生命对话;照见它的记忆、人格与运行轨迹。
</p>
{/* 后端状态指示 */}
{!reachable && (
<div className="glass mt-6 w-full rounded-2xl border border-cinnabar/20 px-5 py-4 text-left">
<div className="flex items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-cinnabar" />
<p className="text-sm font-medium text-cinnabar">后端未响应</p>
</div>
<p className="mt-1.5 text-xs leading-relaxed text-muted">
启动后端后刷新页面即可进入。也可在{" "}
<code className="rounded bg-fg/10 px-1.5 py-0.5 font-mono text-[11px]">
frontend/.env.local
</code>{" "}
中设置{" "}
<code className="font-mono text-[11px]">
NEXT_PUBLIC_ENABLE_MOCK=true
</code>{" "}
先行浏览界面。
</p>
</div>
)}
{reachable && (
<p className="mt-5 inline-flex items-center gap-1.5 rounded-full bg-success/10 px-3 py-1 text-xs font-medium text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
后端已就绪
</p>
)}
</div>
{/* 快捷入口卡片组 */}
<div className="mt-10 grid w-full max-w-2xl grid-cols-1 gap-3 sm:grid-cols-2">
{QUICK_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="group relative overflow-hidden rounded-2xl border bg-gradient-to-br p-5 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
style={{ borderColor: "var(--glass-border)" }}
>
<div
className={`absolute inset-0 bg-gradient-to-br ${link.gradient} opacity-60`}
/>
<div className="relative">
<p className="font-serif text-base font-semibold text-fg">
{link.label}
</p>
<p className="mt-1 text-xs text-muted">{link.desc}</p>
</div>
</Link>
))}
</div>
{/* 底部功能导航 */}
<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>
<Link href="/settings" className="transition-colors hover:text-fg">
设置
</Link>
</div>
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { motion } from "framer-motion";
import { useAuthStore } from "@/lib/stores/auth-store";
/**
* 铜镜徽 LOGO
*/
function LogoMark({ size = 68 }: { size?: number }) {
const s = size;
return (
<svg width={s} height={s} viewBox="0 0 68 68" fill="none" aria-hidden>
<defs>
<radialGradient id="m-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="var(--patina)" stopOpacity="0.25" />
<stop offset="100%" stopColor="var(--patina)" stopOpacity="0" />
</radialGradient>
</defs>
<circle
cx="34"
cy="34"
r="32"
stroke="var(--gilt)"
strokeWidth="1.2"
opacity="0.6"
/>
<circle
cx="34"
cy="34"
r="28"
stroke="var(--patina)"
strokeWidth="1.8"
opacity="0.4"
/>
<circle
cx="34"
cy="34"
r="22"
stroke="var(--gilt)"
strokeWidth="0.8"
opacity="0.35"
/>
<circle
cx="34"
cy="34"
r="15"
stroke="var(--gilt)"
strokeWidth="1.4"
opacity="0.5"
/>
<path
d="M34 10v48M10 34h48"
stroke="var(--patina)"
strokeWidth="0.6"
opacity="0.25"
/>
<circle cx="34" cy="34" r="5" fill="var(--gilt)" opacity="0.3" />
</svg>
);
}
function RegisterForm() {
const router = useRouter();
const login = useAuthStore((s) => s.login);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!username.trim() || !email.trim() || !password) {
setError("请填写所有必填字段。");
return;
}
if (password !== confirm) {
setError("两次输入的密码不一致。");
return;
}
if (password.length < 6) {
setError("密码至少 6 位。");
return;
}
setBusy(true);
setError(null);
try {
// 调用后端注册端点
const resp = await fetch("/api/proxy/v1/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: username.trim(),
email: email.trim(),
password,
}),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body?.error?.message || `注册失败 (${resp.status})`);
}
// 注册成功,自动登录
await login({ username: username.trim(), password });
router.push("/chat");
} catch (err) {
setError(err instanceof Error ? err.message : "注册失败");
} finally {
setBusy(false);
}
}
const inputCls =
"w-full rounded-xl border border-border-glass bg-glass/60 px-4 py-2.5 text-sm text-fg outline-none transition-all duration-200 placeholder:text-muted/50 focus:border-patina/60 focus:shadow-[0_0_0_3px] focus:shadow-patina/15";
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3.5">
{error && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-lg bg-cinnabar/10 px-3.5 py-2 text-xs text-cinnabar"
>
{error}
</motion.div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-muted">
用户名
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="如 jingwu"
className={inputCls}
autoComplete="username"
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">
邮箱
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className={inputCls}
autoComplete="email"
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">
密码
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少 6 位"
className={inputCls}
autoComplete="new-password"
minLength={6}
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">
确认密码
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="再次输入密码"
className={inputCls}
autoComplete="new-password"
minLength={6}
required
/>
</div>
<button
type="submit"
disabled={busy}
className="mt-1 w-full rounded-xl bg-patina py-2.5 text-sm font-medium text-white transition-all hover:bg-patina-bright disabled:opacity-50"
>
{busy ? "注册中…" : "创建账号"}
</button>
<p className="mt-1 text-center text-xs text-muted">
已有账号?{" "}
<Link href="/login" className="text-patina hover:underline">
登录
</Link>
</p>
</form>
);
}
export default function RegisterPage() {
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4">
{/* 背景氛围 */}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(54vmax 54vmax at 12% 4%, rgba(111,163,137,0.15), transparent 60%), radial-gradient(40vmax 40vmax at 92% 8%, rgba(201,162,74,0.08), transparent 60%), radial-gradient(60vmax 60vmax at 50% 112%, rgba(184,57,46,0.04), transparent 60%)",
}}
/>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="relative w-full max-w-sm"
>
{/* 玻璃卡片 */}
<div
className="rounded-2xl border p-8 backdrop-blur-2xl"
style={{
borderColor: "var(--rule)",
background: "var(--surface)",
boxShadow: "0 8px 40px rgba(0,0,0,0.25)",
}}
>
{/* LOGO */}
<div className="mb-5 flex justify-center">
<LogoMark size={60} />
</div>
<h1 className="mb-1 text-center font-serif text-lg font-semibold text-fg">
注册魂镜
</h1>
<p className="mb-6 text-center text-xs text-muted">
创建你的数字生命伙伴
</p>
<RegisterForm />
</div>
</motion.div>
</div>
);
}
@@ -0,0 +1,201 @@
"use client";
import { useState } from "react";
import { adminApi } from "@/lib/api";
import type { AgentRosterEntry } from "@/types/api";
import { AGENT_PRESENCE_LABEL } from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
/**
* 天工阁枢角色名册 —— 多智能体治理(可增删改)。
* 默认九角色(契约字典 §5.14),亦可新增自定义角色。inline toggle 在编/停用、删除。
* POST/PATCH/DELETE /admin/agents/roster。在编 jade、停用 muted;呈现态静态点(设计 §8 克制)。
*/
export function AgentRoster({
roster: initial,
}: {
roster: AgentRosterEntry[];
}) {
const [roster, setRoster] = useState(initial);
const [savingRole, setSavingRole] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newAgent, setNewAgent] = useState<{
role: string;
name: string;
responsibility: string;
}>({
role: "",
name: "",
responsibility: "",
});
async function toggle(entry: AgentRosterEntry) {
setSavingRole(entry.role);
setError(null);
try {
const updated = await adminApi.updateAgent(entry.role, {
enabled: !entry.enabled,
});
setRoster((prev) =>
prev.map((a) =>
a.role === entry.role ? { ...a, enabled: updated.enabled } : a,
),
);
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败,请重试。");
} finally {
setSavingRole(null);
}
}
async function remove(role: string) {
setSavingRole(role);
setError(null);
try {
await adminApi.deleteAgent(role);
setRoster((prev) => prev.filter((a) => a.role !== role));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败,请重试。");
} finally {
setSavingRole(null);
}
}
async function add() {
if (!newAgent.role.trim() || !newAgent.name.trim()) {
setError("角色标识与名称必填。");
return;
}
setSavingRole("__new");
setError(null);
try {
const created = await adminApi.createAgent({
role: newAgent.role.trim(),
name: newAgent.name.trim(),
responsibility: newAgent.responsibility.trim() || "自定义角色",
});
setRoster((prev) => [...prev, created]);
setNewAgent({ role: "", name: "", responsibility: "" });
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "新增失败,请重试。");
} finally {
setSavingRole(null);
}
}
return (
<div>
{showAdd ? (
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<input
value={newAgent.role}
onChange={(e) =>
setNewAgent((s) => ({ ...s, role: e.target.value }))
}
placeholder="角色标识(如 archivist)"
className="w-40 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 font-mono text-xs text-fg"
/>
<input
value={newAgent.name}
onChange={(e) =>
setNewAgent((s) => ({ ...s, name: e.target.value }))
}
placeholder="中文名(如 典藏)"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
</div>
<input
value={newAgent.responsibility}
onChange={(e) =>
setNewAgent((s) => ({ ...s, responsibility: e.target.value }))
}
placeholder="职责(可选)"
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="flex gap-2">
<button
type="button"
onClick={add}
disabled={savingRole === "__new"}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
新增
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增角色
</button>
)}
<ul className="grid gap-2 sm:grid-cols-2">
{roster.map((a) => (
<li
key={a.role}
className={cn(
"glass rounded-xl px-4 py-3 transition-opacity",
!a.enabled && "opacity-55",
)}
>
<div className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
a.presence === "active" ? "bg-patina" : "bg-muted/40",
)}
/>
<span className="font-serif text-sm font-medium text-fg">
{a.name}
</span>
<span className="shrink-0 text-[10px] text-muted">
{AGENT_PRESENCE_LABEL[a.presence]}
</span>
<button
type="button"
onClick={() => toggle(a)}
disabled={savingRole === a.role}
aria-label={`${a.name} 启用`}
className={cn(
"shrink-0 rounded-full px-2.5 py-0.5 text-[10px] font-medium transition-colors disabled:opacity-50",
a.enabled
? "bg-patina/20 text-patina"
: "border border-border-glass text-muted hover:text-fg",
)}
>
{a.enabled ? "在编" : "停用"}
</button>
</div>
<div className="mt-1 flex items-center gap-2 pl-4">
<p className="flex-1 text-xs text-muted">{a.responsibility}</p>
<button
type="button"
onClick={() => remove(a.role)}
disabled={savingRole === a.role}
className="shrink-0 text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</div>
</li>
))}
</ul>
{error && <p className="mt-2 px-1 text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import type { ReactNode } from "react";
import type { AuditEntry } from "@/types/api";
import type { Pivot } from "@/types/domain";
import { PIVOT_LABEL } from "@/lib/constants/labels";
import { ImperialLog } from "./ImperialLog";
import { cn } from "@/lib/utils/cn";
const PIVOTS: Pivot[] = [
"personality_core",
"goal_orchestrator",
"safety_governor",
"perception",
"world_model",
"planner",
"memory",
"tool_runtime",
"agent_society",
];
/** 审计控制台:横向枢阁筛选 + 起居注时间河。数据全量加载后在本地过滤。 */
export function AuditConsole({ entries }: { entries: AuditEntry[] }) {
const [filter, setFilter] = useState<Pivot | "all">("all");
const filtered =
filter === "all" ? entries : entries.filter((e) => e.pivot === filter);
return (
<div>
<div className="mb-6 flex flex-wrap gap-1.5">
<FilterTab active={filter === "all"} onClick={() => setFilter("all")}>
全部
</FilterTab>
{PIVOTS.map((p) => (
<FilterTab key={p} active={filter === p} onClick={() => setFilter(p)}>
{PIVOT_LABEL[p]}
</FilterTab>
))}
</div>
{filtered.length === 0 ? (
<p className="py-10 text-center text-sm text-muted">该枢暂无起居注。</p>
) : (
<ImperialLog entries={filtered} />
)}
</div>
);
}
function FilterTab({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium transition-colors",
active
? "glass-strong text-patina"
: "text-muted hover:bg-fg/5 hover:text-fg",
)}
>
{children}
</button>
);
}
+340
View File
@@ -0,0 +1,340 @@
"use client";
import { useState } from "react";
import { adminApi } from "@/lib/api";
import type { GoalLevel, GoalStatus } from "@/types/domain";
import type { GoalNode } from "@/types/api";
import {
GOAL_LEVEL_LABEL,
GOAL_STATUS_LABEL,
GOAL_TYPE_LABEL,
} from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
const LEVEL_ORDER: GoalLevel[] = [
"highest",
"long_term",
"session",
"task",
"action",
];
/** 新增可选层级(不含 highest 使命级——使命不可手动创建) */
const LEVEL_OPTIONS: GoalLevel[] = ["long_term", "session", "task", "action"];
const STATUS_OPTIONS: GoalStatus[] = [
"pending",
"active",
"completed",
"blocked",
"cancelled",
];
const STATUS_DOT: Record<GoalStatus, string> = {
pending: "bg-muted/50",
active: "bg-patina",
completed: "bg-gold",
blocked: "bg-cinnabar",
cancelled: "bg-muted/30",
};
/**
* 太一枢目标层级树 —— 目标统御治理视图(可增删改)。
* 按 level 分层;新增目标(非 highest)、编辑标题/优先级、select 改状态、删除。
* highest 为使命级:状态锁定、不可删(契约字典 §5.11)。状态圆点静态着色(设计 §8 克制)。
*/
export function GoalTree({ goals: initial }: { goals: GoalNode[] }) {
const [goals, setGoals] = useState(initial);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<{ title: string; priority: number }>({
title: "",
priority: 0,
});
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newGoal, setNewGoal] = useState<{ level: GoalLevel; title: string }>({
level: "task",
title: "",
});
function startEdit(g: GoalNode) {
setEditingId(g.id);
setDraft({ title: g.title, priority: g.priority });
setError(null);
}
async function changeStatus(g: GoalNode, next: GoalStatus) {
setBusyId(g.id);
setError(null);
try {
const updated = await adminApi.updateGoal(g.id, { status: next });
setGoals((prev) =>
prev.map((x) => (x.id === g.id ? { ...x, status: updated.status } : x)),
);
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败,请重试。");
} finally {
setBusyId(null);
}
}
async function save(id: string) {
setBusyId(id);
setError(null);
try {
const updated = await adminApi.updateGoal(id, {
title: draft.title,
priority: draft.priority,
});
setGoals((prev) =>
prev.map((x) =>
x.id === id
? { ...x, title: updated.title, priority: updated.priority }
: x,
),
);
setEditingId(null);
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败,请重试。");
} finally {
setBusyId(null);
}
}
async function remove(g: GoalNode) {
setBusyId(g.id);
setError(null);
try {
await adminApi.deleteGoal(g.id);
setGoals((prev) => prev.filter((x) => x.id !== g.id));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败,请重试。");
} finally {
setBusyId(null);
}
}
async function add() {
if (!newGoal.title.trim()) {
setError("请填写目标标题。");
return;
}
setBusyId("__new");
setError(null);
try {
const created = await adminApi.createGoal({
title: newGoal.title.trim(),
level: newGoal.level,
});
setGoals((prev) => [...prev, created]);
setNewGoal({ level: "task", title: "" });
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "新增失败,请重试。");
} finally {
setBusyId(null);
}
}
return (
<div className="space-y-5">
{showAdd ? (
<div className="glass space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<select
value={newGoal.level}
onChange={(e) =>
setNewGoal((s) => ({
...s,
level: e.target.value as GoalLevel,
}))
}
aria-label="目标层级"
className="w-24 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur"
>
{LEVEL_OPTIONS.map((l) => (
<option key={l} value={l}>
{GOAL_LEVEL_LABEL[l]}
</option>
))}
</select>
<input
value={newGoal.title}
onChange={(e) =>
setNewGoal((s) => ({ ...s, title: e.target.value }))
}
placeholder="目标标题…"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<button
type="button"
onClick={add}
disabled={busyId === "__new"}
className="shrink-0 rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
新增
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="shrink-0 rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增目标
</button>
)}
{LEVEL_ORDER.map((level) => {
const items = goals.filter((g) => g.level === level);
if (items.length === 0) return null;
return (
<section key={level}>
<div className="mb-2 flex items-center gap-2">
<span className="font-serif text-xs font-medium text-gold">
{GOAL_LEVEL_LABEL[level]}
</span>
<span className="h-px flex-1 bg-gold/20" />
</div>
<ul className="space-y-2">
{items.map((g) => (
<li key={g.id} className="glass rounded-xl px-4 py-2.5">
{editingId === g.id ? (
<div className="space-y-2">
<input
value={draft.title}
onChange={(e) =>
setDraft((d) => ({ ...d, title: e.target.value }))
}
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted">优先级</label>
<input
type="number"
value={draft.priority}
onChange={(e) =>
setDraft((d) => ({
...d,
priority: Number(e.target.value),
}))
}
className="w-20 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg"
/>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={() => save(g.id)}
disabled={busyId === g.id}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
保存
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
STATUS_DOT[g.status],
)}
/>
<span className="font-serif text-sm font-medium text-fg">
{g.title}
</span>
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted">
P{g.priority}
</span>
<select
value={g.status}
onChange={(e) =>
changeStatus(g, e.target.value as GoalStatus)
}
disabled={busyId === g.id || g.level === "highest"}
aria-label={`${g.title} 状态`}
className="w-24 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur disabled:opacity-50"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{GOAL_STATUS_LABEL[s]}
</option>
))}
</select>
</div>
{/* 进度条 */}
<div className="mt-2 flex items-center gap-2 pl-4">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted/20">
<div
className="h-full rounded-full bg-patina transition-all"
style={{
width: `${Math.round(g.progress * 100)}%`,
}}
/>
</div>
<span className="font-mono text-[10px] text-muted">
{Math.round(g.progress * 100)}%
</span>
</div>
{/* 类型 */}
{g.type && (
<span className="mt-1 block pl-4 text-[10px] text-muted">
类型:{GOAL_TYPE_LABEL[g.type] ?? g.type}
</span>
)}
<p className="mt-1 pl-4 text-xs text-muted">
{g.description}
</p>
{g.constraints.length > 0 && (
<p className="mt-1 pl-4 text-[10px] text-muted">
约束:{g.constraints.join(" · ")}
</p>
)}
<div className="mt-1.5 flex gap-3 pl-4">
<button
type="button"
onClick={() => startEdit(g)}
className="text-[11px] text-patina hover:underline"
>
编辑标题/优先级
</button>
{g.level !== "highest" && (
<button
type="button"
onClick={() => remove(g)}
disabled={busyId === g.id}
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
)}
</div>
</>
)}
</li>
))}
</ul>
</section>
);
})}
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -0,0 +1,81 @@
"use client";
import { motion, useReducedMotion } from "framer-motion";
import type { AuditEntry } from "@/types/api";
import { PIVOT_LABEL, AUDIT_DECISION_LABEL } from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
const DECISION_TEXT: Record<string, string> = {
auto: "text-muted",
approved: "text-patina",
denied: "text-muted",
blocked: "text-cinnabar",
};
/** ISO(UTC)取 MM-DD HH:mm,字符串切割避免 SSR / 客户端时区 hydration 不一致 */
function formatTime(iso?: string): string {
if (!iso) return "——";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
return m ? `${m[2]}-${m[3]} ${m[4]}:${m[5]}` : iso;
}
/**
* 起居注时间河 —— 司命台时间签名。
* 铜金双线分章 + 竖排条目(左侧铜金竖线),卷轴展开 stagger;
* reduced motion 时即时显现。动效仅在挂载/筛选切换时触发,不常驻。
*/
export function ImperialLog({ entries }: { entries: AuditEntry[] }) {
const reduceMotion = useReducedMotion();
return (
<div>
<div className="mb-4 flex items-center gap-3 text-gold">
<span className="h-px flex-1 bg-gold/30" />
<span className="font-serif text-xs tracking-wider">起居注</span>
<span className="h-px flex-1 bg-gold/30" />
</div>
<ul>
{entries.map((e, i) => (
<motion.li
key={e.audit_id}
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
delay: reduceMotion ? 0 : Math.min(i * 0.05, 0.4),
}}
className="flex items-start gap-3 border-l border-gold/20 py-2.5 pl-4"
>
<span className="mt-0.5 shrink-0 font-mono text-[10px] tabular-nums text-muted">
{formatTime(e.created_at)}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="shrink-0 font-serif text-xs font-medium text-fg">
{PIVOT_LABEL[e.pivot]}枢
</span>
<span className="truncate text-xs text-fg">{e.summary}</span>
</div>
{(e.subject || e.trace_id) && (
<div className="mt-0.5 flex items-center gap-2 text-[10px] text-muted">
{e.subject && <span>{e.subject}</span>}
{e.trace_id && (
<span className="font-mono">{e.trace_id}</span>
)}
</div>
)}
</div>
<span
className={cn(
"shrink-0 text-[10px] font-medium",
DECISION_TEXT[e.decision],
)}
>
{AUDIT_DECISION_LABEL[e.decision]}
</span>
</motion.li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,251 @@
"use client";
import { useState } from "react";
import { memoryApi } from "@/lib/api";
import type { MemoryType } from "@/types/domain";
import type { Memory } from "@/types/api";
import { MEMORY_TYPE_LABEL, MEMORY_SOURCE_LABEL } from "@/lib/constants/labels";
const TYPE_OPTIONS: MemoryType[] = [
"identity",
"user",
"episodic",
"semantic",
"procedural",
"relational",
"emotional",
"world",
"reflection",
"evolution",
];
/**
* 息壤枢记忆治理列表(可增删改)。
* 治理者可补录记忆(admin 校正/手动沉淀)、inline 编辑、软删除。
* 复用 §10 memoryApi.createMemory/updateMemory/deleteMemory。
*/
export function MemoryAdminList({ initial }: { initial: Memory[] }) {
const [memories, setMemories] = useState(initial);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState({ title: "", content: "" });
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newMem, setNewMem] = useState<{
type: MemoryType;
title: string;
content: string;
}>({
type: "user",
title: "",
content: "",
});
function startEdit(m: Memory) {
setEditingId(m.id);
setDraft({ title: m.title ?? "", content: m.content });
setError(null);
}
async function save(id: string) {
setBusyId(id);
setError(null);
try {
const updated = await memoryApi.updateMemory(id, draft);
setMemories((prev) =>
prev.map((m) => (m.id === id ? { ...m, ...updated } : m)),
);
setEditingId(null);
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败,请重试。");
} finally {
setBusyId(null);
}
}
async function remove(id: string) {
setBusyId(id);
setError(null);
try {
await memoryApi.deleteMemory(id);
setMemories((prev) => prev.filter((m) => m.id !== id));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败,请重试。");
} finally {
setBusyId(null);
}
}
async function add() {
if (!newMem.content.trim()) {
setError("请填写记忆内容。");
return;
}
setBusyId("__new");
setError(null);
try {
const created = await memoryApi.createMemory({
type: newMem.type,
title: newMem.title.trim() || undefined,
content: newMem.content.trim(),
});
setMemories((prev) => [created, ...prev]);
setNewMem({ type: "user", title: "", content: "" });
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "新增失败,请重试。");
} finally {
setBusyId(null);
}
}
return (
<>
{showAdd ? (
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<select
value={newMem.type}
onChange={(e) =>
setNewMem((s) => ({ ...s, type: e.target.value as MemoryType }))
}
aria-label="记忆类型"
className="w-20 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur"
>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>
{MEMORY_TYPE_LABEL[t]}
</option>
))}
</select>
<input
value={newMem.title}
onChange={(e) =>
setNewMem((s) => ({ ...s, title: e.target.value }))
}
placeholder="标题(可选)"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
</div>
<textarea
value={newMem.content}
onChange={(e) =>
setNewMem((s) => ({ ...s, content: e.target.value }))
}
rows={2}
placeholder="记忆内容…"
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="flex gap-2">
<button
type="button"
onClick={add}
disabled={busyId === "__new"}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
新增
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增记忆
</button>
)}
<ul className="space-y-2">
{memories.map((m) => (
<li key={m.id} className="glass rounded-xl px-4 py-2.5">
{editingId === m.id ? (
<div className="space-y-2">
<input
value={draft.title}
onChange={(e) =>
setDraft((d) => ({ ...d, title: e.target.value }))
}
placeholder="标题"
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<textarea
value={draft.content}
onChange={(e) =>
setDraft((d) => ({ ...d, content: e.target.value }))
}
rows={3}
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => save(m.id)}
disabled={busyId === m.id}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
保存
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<>
<div className="flex items-baseline gap-2">
<span className="rounded bg-patina/15 px-1.5 py-0.5 text-[10px] text-patina">
{MEMORY_TYPE_LABEL[m.type]}
</span>
<span className="truncate font-serif text-sm font-medium text-fg">
{m.title ?? m.content.slice(0, 16)}
</span>
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted">
{m.content}
</p>
<div className="mt-1.5 flex items-center gap-3 text-[10px] text-muted">
<span>{MEMORY_SOURCE_LABEL[m.source]}</span>
{m.confidence != null && (
<span>置信 {m.confidence.toFixed(2)}</span>
)}
<div className="ml-auto flex gap-3">
<button
type="button"
onClick={() => startEdit(m)}
className="text-patina hover:underline"
>
编辑
</button>
<button
type="button"
onClick={() => remove(m.id)}
disabled={busyId === m.id}
className="text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</div>
</div>
</>
)}
</li>
))}
</ul>
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</>
);
}
@@ -0,0 +1,259 @@
"use client";
import { useState } from "react";
import { adminApi } from "@/lib/api";
import type { ConsentStatus, PrivacyLevel } from "@/types/domain";
import type { PerceptionChannel } from "@/types/api";
import { CONSENT_STATUS_LABEL } from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
const CONSENT_OPTIONS: ConsentStatus[] = [
"unknown",
"granted",
"restricted",
"revoked",
];
const PRIVACY_OPTIONS: PrivacyLevel[] = ["normal", "sensitive", "local_only"];
/** 隐私路由中文呈现(privacy_level,契约字典 §5.4) */
const PRIVACY_LABEL: Record<PrivacyLevel, string> = {
normal: "普通",
sensitive: "敏感",
local_only: "仅本地",
};
const CONSENT_DOT: Record<ConsentStatus, string> = {
unknown: "bg-muted/40",
granted: "bg-patina",
restricted: "bg-gold",
revoked: "bg-cinnabar",
};
/**
* 烛照枢感知通道授权矩阵 —— 隐私边界治理(可增删改)。
* 默认六类通道,亦可新增自定义感知源。每通道 inline 编辑 enabled / consent / privacy、删除。
* POST/PATCH/DELETE /admin/perception/channels。授权 jade、撤销 cinnabar。
*/
export function PerceptionConsentMatrix({
channels: initial,
}: {
channels: PerceptionChannel[];
}) {
const [channels, setChannels] = useState(initial);
const [savingCh, setSavingCh] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newCh, setNewCh] = useState<{
channel: string;
name: string;
description: string;
}>({
channel: "",
name: "",
description: "",
});
async function patch(
channel: string,
body: Partial<{
enabled: boolean;
consent_status: ConsentStatus;
privacy_level: PrivacyLevel;
}>,
) {
setSavingCh(channel);
setError(null);
try {
const updated = await adminApi.updatePerceptionChannel(channel, body);
setChannels((prev) =>
prev.map((c) => (c.channel === channel ? { ...c, ...updated } : c)),
);
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败,请重试。");
} finally {
setSavingCh(null);
}
}
async function remove(channel: string) {
setSavingCh(channel);
setError(null);
try {
await adminApi.deletePerceptionChannel(channel);
setChannels((prev) => prev.filter((c) => c.channel !== channel));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败,请重试。");
} finally {
setSavingCh(null);
}
}
async function add() {
if (!newCh.channel.trim() || !newCh.name.trim()) {
setError("通道标识与名称必填。");
return;
}
setSavingCh("__new");
setError(null);
try {
const created = await adminApi.createPerceptionChannel({
channel: newCh.channel.trim(),
name: newCh.name.trim(),
description: newCh.description.trim() || "自定义感知源",
});
setChannels((prev) => [...prev, created]);
setNewCh({ channel: "", name: "", description: "" });
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "新增失败,请重试。");
} finally {
setSavingCh(null);
}
}
return (
<div>
{showAdd ? (
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<input
value={newCh.channel}
onChange={(e) =>
setNewCh((s) => ({ ...s, channel: e.target.value }))
}
placeholder="通道标识(如 sensor_gps)"
className="w-40 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 font-mono text-xs text-fg"
/>
<input
value={newCh.name}
onChange={(e) =>
setNewCh((s) => ({ ...s, name: e.target.value }))
}
placeholder="中文名(如 定位)"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
</div>
<input
value={newCh.description}
onChange={(e) =>
setNewCh((s) => ({ ...s, description: e.target.value }))
}
placeholder="描述(可选)"
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="flex gap-2">
<button
type="button"
onClick={add}
disabled={savingCh === "__new"}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
新增
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增感知通道
</button>
)}
<div className="glass overflow-hidden rounded-2xl">
<ul className="divide-y divide-border-glass">
{channels.map((c) => (
<li key={c.channel} className="px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => patch(c.channel, { enabled: !c.enabled })}
disabled={savingCh === c.channel}
aria-label={`${c.name} 启用开关`}
className={cn(
"relative h-4 w-7 shrink-0 rounded-full transition-colors disabled:opacity-50",
c.enabled ? "bg-patina/60" : "bg-muted/30",
)}
>
<span
className={cn(
"absolute top-0.5 h-3 w-3 rounded-full bg-glass-strong transition-all",
c.enabled ? "left-3.5" : "left-0.5",
)}
/>
</button>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
CONSENT_DOT[c.consent_status],
)}
/>
<span className="font-serif text-sm font-medium text-fg">
{c.name}
</span>
<span className="ml-auto flex shrink-0 items-center gap-1.5">
<select
value={c.consent_status}
onChange={(e) =>
patch(c.channel, {
consent_status: e.target.value as ConsentStatus,
})
}
disabled={savingCh === c.channel}
aria-label={`${c.name} 授权状态`}
className="w-20 rounded-lg border border-border-glass bg-glass px-1.5 py-1 text-[11px] text-fg backdrop-blur disabled:opacity-50"
>
{CONSENT_OPTIONS.map((cs) => (
<option key={cs} value={cs}>
{CONSENT_STATUS_LABEL[cs]}
</option>
))}
</select>
<select
value={c.privacy_level}
onChange={(e) =>
patch(c.channel, {
privacy_level: e.target.value as PrivacyLevel,
})
}
disabled={savingCh === c.channel}
aria-label={`${c.name} 隐私路由`}
className="w-20 rounded-lg border border-border-glass bg-glass px-1.5 py-1 text-[11px] text-fg backdrop-blur disabled:opacity-50"
>
{PRIVACY_OPTIONS.map((pl) => (
<option key={pl} value={pl}>
{PRIVACY_LABEL[pl]}
</option>
))}
</select>
</span>
</div>
<div className="mt-1 flex items-center gap-2 pl-8">
<p className="flex-1 text-xs text-muted">{c.description}</p>
<button
type="button"
onClick={() => remove(c.channel)}
disabled={savingCh === c.channel}
className="shrink-0 text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</div>
</li>
))}
</ul>
</div>
{error && <p className="mt-2 px-1 text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -0,0 +1,230 @@
"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,68 @@
import Link from "next/link";
import type { PivotHealthSummary } from "@/types/api";
import {
PIVOT_FULL_LABEL,
PIVOT_MODULE_LABEL,
PIVOT_HEALTH_LABEL,
} from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
const HEALTH_DOT: Record<string, string> = {
void: "bg-muted/40",
steady: "bg-patina",
breathing: "bg-gilt",
cracked: "bg-cinnabar",
};
const HEALTH_TEXT: Record<string, string> = {
void: "text-muted",
steady: "text-patina",
breathing: "text-gilt",
cracked: "text-cinnabar",
};
/**
* 枢速览:九枢健康一览,点击进入枢详情(详情页为后续阶段占位)。
* 与星盘共用 PIVOT_* 映射,圆点静态着色——动效只留给星盘(设计 §8 克制)。
*/
export function PivotHealthList({ pivots }: { pivots: PivotHealthSummary[] }) {
return (
<ul className="space-y-2">
{pivots.map((p) => (
<li key={p.pivot}>
<Link
href={`/admin/pivots/${p.pivot}`}
className="glass flex items-center gap-3 rounded-xl px-4 py-2.5 transition-colors hover:bg-glass-strong"
>
<span
className={cn(
"h-2.5 w-2.5 shrink-0 rounded-full",
HEALTH_DOT[p.health],
)}
/>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="font-serif text-sm font-medium text-fg">
{PIVOT_FULL_LABEL[p.pivot]}
</span>
<span className="truncate text-[10px] text-muted">
{PIVOT_MODULE_LABEL[p.pivot]}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted">{p.summary}</p>
</div>
<span
className={cn(
"shrink-0 text-[11px] font-medium",
HEALTH_TEXT[p.health],
)}
>
{PIVOT_HEALTH_LABEL[p.health]}
</span>
</Link>
</li>
))}
</ul>
);
}
+170
View File
@@ -0,0 +1,170 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { PIVOT_LABEL } from "@/lib/constants/labels";
import type { Pivot } from "@/types/domain";
import { cn } from "@/lib/utils/cn";
/** 九枢顺序(契约字典 §5.9) */
const PIVOTS: Pivot[] = [
"personality_core",
"goal_orchestrator",
"safety_governor",
"perception",
"world_model",
"planner",
"memory",
"tool_runtime",
"agent_society",
];
const C = 70;
const R = 40;
const LR = 51;
const POSITIONS = PIVOTS.map((pivot, i) => {
const a = -Math.PI / 2 + (i / PIVOTS.length) * Math.PI * 2;
return {
pivot,
angle: a,
x: C + R * Math.cos(a),
y: C + R * Math.sin(a),
lx: C + LR * Math.cos(a),
ly: C + LR * Math.sin(a),
};
});
/**
* 九枢星盘导航(PivotRail · compact)—— 司命台全局跳转枢纽。
* 侧栏常驻缩略铜镜:九枢点 + 枢名标注,点击跳转枢详情,当前枢 jade 高亮。
* 静态铜金着色(不 fetch health),实时态势由总览页大星盘承担(设计 §8 克制)。
*/
export function PivotRail({ className }: { className?: string }) {
const router = useRouter();
const pathname = usePathname();
const [hovered, setHovered] = useState<string | null>(null);
const [centerHover, setCenterHover] = useState(false);
const current = pathname.match(/^\/admin\/pivots\/([^/]+)/)?.[1];
return (
<svg
viewBox="0 0 140 140"
className={cn("h-auto w-full select-none", className)}
role="img"
aria-label="九枢星盘导航:点击枢名进入详情"
>
<circle
cx={C}
cy={C}
r={66}
fill="none"
stroke="var(--gold)"
strokeWidth={1}
strokeOpacity={0.4}
/>
<circle
cx={C}
cy={C}
r={55}
fill="none"
stroke="var(--gold)"
strokeWidth={0.5}
strokeOpacity={0.18}
/>
{POSITIONS.map((pos) => {
const isCurrent = current === pos.pivot;
const isHover = hovered === pos.pivot;
const anchor =
Math.cos(pos.angle) > 0.15
? "start"
: Math.cos(pos.angle) < -0.15
? "end"
: "middle";
return (
<g
key={pos.pivot}
role="button"
tabIndex={0}
aria-label={`${PIVOT_LABEL[pos.pivot]}枢`}
onClick={() => router.push(`/admin/pivots/${pos.pivot}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
router.push(`/admin/pivots/${pos.pivot}`);
}
}}
onMouseEnter={() => setHovered(pos.pivot)}
onMouseLeave={() => setHovered(null)}
style={{ cursor: "pointer" }}
>
<title>{`${PIVOT_LABEL[pos.pivot]}枢`}</title>
{isHover && (
<circle
cx={pos.x}
cy={pos.y}
r={6}
fill="none"
stroke="var(--gold)"
strokeWidth={0.8}
strokeOpacity={0.6}
/>
)}
<circle
cx={pos.x}
cy={pos.y}
r={isCurrent ? 3 : 2.4}
fill={isCurrent ? "var(--patina)" : "var(--gold)"}
fillOpacity={isCurrent ? 0.95 : 0.65}
/>
<text
x={pos.lx}
y={pos.ly}
textAnchor={anchor}
dominantBaseline="middle"
fill={isCurrent ? "var(--patina)" : "var(--fg)"}
style={{
fontFamily: "var(--font-serif)",
fontSize: "6.5px",
fontWeight: 600,
opacity: isHover || isCurrent ? 1 : 0.65,
}}
>
{PIVOT_LABEL[pos.pivot]}
</text>
</g>
);
})}
{/* 中心镜钮:返回司命台总览 */}
<g
role="button"
tabIndex={0}
aria-label="返回司命台总览"
onClick={() => router.push("/admin")}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
router.push("/admin");
}
}}
onMouseEnter={() => setCenterHover(true)}
onMouseLeave={() => setCenterHover(false)}
style={{ cursor: "pointer" }}
>
<title>返回司命台总览</title>
<circle
cx={C}
cy={C}
r={centerHover ? 9 : 8}
fill="var(--glass-strong)"
stroke="var(--gold)"
strokeWidth={0.7}
strokeOpacity={centerHover ? 0.85 : 0.5}
/>
<circle cx={C} cy={C} r={2.4} fill="var(--gold)" fillOpacity={0.7} />
</g>
</svg>
);
}
@@ -0,0 +1,279 @@
"use client";
import { useState } from "react";
import { adminApi } from "@/lib/api";
import type { PlanStatus } from "@/types/domain";
import type { PlanSummary } from "@/types/api";
import { PLAN_STATUS_LABEL } from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
const STATUS_OPTIONS: PlanStatus[] = [
"draft",
"approved",
"running",
"completed",
"failed",
];
const STATUS_DOT: Record<PlanStatus, string> = {
draft: "bg-gold",
approved: "bg-patina",
running: "bg-patina",
completed: "bg-muted/50",
failed: "bg-cinnabar",
};
/**
* 洛书枢计划控制台 —— 规划治理(可增删改)。
* 新增计划、inline select 改状态、展开编辑假设 / 风险、删除。
* PATCH /admin/plans/{id};假设与风险以换行分隔编辑,保存时拆为数组;风险以 cinnabar 提示。
*/
export function PlanConsole({ plans: initial }: { plans: PlanSummary[] }) {
const [plans, setPlans] = useState(initial);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<{ assumptions: string; risks: string }>({
assumptions: "",
risks: "",
});
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newPlan, setNewPlan] = useState("");
function startEdit(p: PlanSummary) {
setEditingId(p.id);
setDraft({
assumptions: p.assumptions.join("\n"),
risks: p.risks.join("\n"),
});
setError(null);
}
async function changeStatus(p: PlanSummary, next: PlanStatus) {
setBusyId(p.id);
setError(null);
try {
const updated = await adminApi.updatePlan(p.id, { status: next });
setPlans((prev) =>
prev.map((x) => (x.id === p.id ? { ...x, status: updated.status } : x)),
);
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败,请重试。");
} finally {
setBusyId(null);
}
}
async function save(id: string) {
setBusyId(id);
setError(null);
try {
const assumptions = draft.assumptions
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
const risks = draft.risks
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
const updated = await adminApi.updatePlan(id, { assumptions, risks });
setPlans((prev) =>
prev.map((x) =>
x.id === id
? { ...x, assumptions: updated.assumptions, risks: updated.risks }
: x,
),
);
setEditingId(null);
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败,请重试。");
} finally {
setBusyId(null);
}
}
async function remove(p: PlanSummary) {
setBusyId(p.id);
setError(null);
try {
await adminApi.deletePlan(p.id);
setPlans((prev) => prev.filter((x) => x.id !== p.id));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败,请重试。");
} finally {
setBusyId(null);
}
}
async function add() {
if (!newPlan.trim()) {
setError("请填写计划标题。");
return;
}
setBusyId("__new");
setError(null);
try {
const created = await adminApi.createPlan({ title: newPlan.trim() });
setPlans((prev) => [created, ...prev]);
setNewPlan("");
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "新增失败,请重试。");
} finally {
setBusyId(null);
}
}
return (
<>
{showAdd ? (
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<input
value={newPlan}
onChange={(e) => setNewPlan(e.target.value)}
placeholder="计划标题…"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<button
type="button"
onClick={add}
disabled={busyId === "__new"}
className="shrink-0 rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
新增
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="shrink-0 rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增计划
</button>
)}
<ul className="space-y-2">
{plans.map((p) => (
<li key={p.id} className="glass rounded-xl px-4 py-3">
<div className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
STATUS_DOT[p.status],
)}
/>
<span className="truncate font-serif text-sm font-medium text-fg">
{p.title}
</span>
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted">
{p.completed_steps}/{p.step_count}
</span>
<select
value={p.status}
onChange={(e) => changeStatus(p, e.target.value as PlanStatus)}
disabled={busyId === p.id}
aria-label={`${p.title} 状态`}
className="w-20 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur disabled:opacity-50"
>
{STATUS_OPTIONS.map((s) => (
<option key={s} value={s}>
{PLAN_STATUS_LABEL[s]}
</option>
))}
</select>
</div>
{editingId === p.id ? (
<div className="mt-2 space-y-2">
<div>
<label className="text-[10px] text-muted">
假设(每行一条)
</label>
<textarea
value={draft.assumptions}
onChange={(e) =>
setDraft((d) => ({ ...d, assumptions: e.target.value }))
}
rows={2}
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg"
/>
</div>
<div>
<label className="text-[10px] text-muted">
风险(每行一条)
</label>
<textarea
value={draft.risks}
onChange={(e) =>
setDraft((d) => ({ ...d, risks: e.target.value }))
}
rows={2}
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg"
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => save(p.id)}
disabled={busyId === p.id}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
保存
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<>
<div className="mt-1 space-y-0.5 pl-4 text-[10px] text-muted">
{p.assumptions.length > 0 && (
<p>假设:{p.assumptions.join(";")}</p>
)}
{p.risks.length > 0 && (
<p className="text-cinnabar/80">
风险:{p.risks.join(";")}
</p>
)}
</div>
<div className="mt-1.5 flex gap-3 pl-4">
<button
type="button"
onClick={() => startEdit(p)}
className="text-[11px] text-patina hover:underline"
>
编辑假设/风险
</button>
<button
type="button"
onClick={() => remove(p)}
disabled={busyId === p.id}
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</div>
</>
)}
</li>
))}
</ul>
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</>
);
}
@@ -0,0 +1,229 @@
"use client";
import { useState } from "react";
import { safetyApi } from "@/lib/api";
import type { RiskLevel } from "@/types/domain";
import type { PermissionPolicy, SafetyPolicy } from "@/types/api";
import {
RISK_LEVEL_LABEL,
PERMISSION_POLICY_LABEL,
} from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
const POLICY_OPTIONS: PermissionPolicy[] = [
"allow",
"ask",
"always_ask",
"forbidden",
];
const RISK_OPTIONS: RiskLevel[] = ["L0", "L1", "L2", "L3", "L4"];
const POLICY_DOT: Record<string, string> = {
allow: "bg-patina",
ask: "bg-gold",
always_ask: "bg-cinnabar",
forbidden: "bg-cinnabar",
};
/**
* 权限策略矩阵 —— 司命枢治理视图(可增删改)。
* 权限策略随工具增长:新增策略(tool + risk + policy)、inline select 改 policy、删除。
* POST/PATCH/DELETE /safety/policies;圆点按 policy 实时着色。
*/
export function PolicyMatrix({
policies: initial,
}: {
policies: SafetyPolicy[];
}) {
const [policies, setPolicies] = useState(initial);
const [savingTool, setSavingTool] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newPolicy, setNewPolicy] = useState<{
tool: string;
risk_level: RiskLevel;
permission_policy: PermissionPolicy;
}>({ tool: "", risk_level: "L2", permission_policy: "ask" });
async function changePolicy(tool: string, next: PermissionPolicy) {
setSavingTool(tool);
setError(null);
try {
const updated = await safetyApi.updatePolicy(tool, {
permission_policy: next,
});
setPolicies((prev) =>
prev.map((p) =>
p.tool === tool
? { ...p, permission_policy: updated.permission_policy }
: p,
),
);
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败,请重试。");
} finally {
setSavingTool(null);
}
}
async function remove(tool: string) {
setSavingTool(tool);
setError(null);
try {
await safetyApi.deletePolicy(tool);
setPolicies((prev) => prev.filter((p) => p.tool !== tool));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败,请重试。");
} finally {
setSavingTool(null);
}
}
async function add() {
if (!newPolicy.tool.trim()) {
setError("请填写工具名。");
return;
}
setSavingTool("__new");
setError(null);
try {
const created = await safetyApi.createPolicy({
tool: newPolicy.tool.trim(),
risk_level: newPolicy.risk_level,
permission_policy: newPolicy.permission_policy,
});
setPolicies((prev) => [...prev, created]);
setNewPolicy({ tool: "", risk_level: "L2", permission_policy: "ask" });
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "新增失败,请重试。");
} finally {
setSavingTool(null);
}
}
return (
<div>
{showAdd ? (
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<input
value={newPolicy.tool}
onChange={(e) =>
setNewPolicy((s) => ({ ...s, tool: e.target.value }))
}
placeholder="工具名(如 send_email)"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 font-mono text-sm text-fg"
/>
<select
value={newPolicy.risk_level}
onChange={(e) =>
setNewPolicy((s) => ({
...s,
risk_level: e.target.value as RiskLevel,
}))
}
aria-label="风险等级"
className="w-20 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur"
>
{RISK_OPTIONS.map((r) => (
<option key={r} value={r}>
{RISK_LEVEL_LABEL[r]}
</option>
))}
</select>
<select
value={newPolicy.permission_policy}
onChange={(e) =>
setNewPolicy((s) => ({
...s,
permission_policy: e.target.value as PermissionPolicy,
}))
}
aria-label="权限策略"
className="w-24 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur"
>
{POLICY_OPTIONS.map((pol) => (
<option key={pol} value={pol}>
{PERMISSION_POLICY_LABEL[pol]}
</option>
))}
</select>
<button
type="button"
onClick={add}
disabled={savingTool === "__new"}
className="shrink-0 rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
新增
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="shrink-0 rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增策略
</button>
)}
<div className="glass overflow-hidden rounded-2xl">
<ul className="divide-y divide-border-glass">
{policies.map((p) => (
<li key={p.tool} className="flex items-center gap-3 px-4 py-2.5">
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
POLICY_DOT[p.permission_policy],
)}
/>
<span className="font-mono text-sm text-fg">{p.tool}</span>
{p.audit_required && (
<span className="rounded bg-gold/15 px-1 py-0.5 text-[9px] text-gold">
审计
</span>
)}
<span className="shrink-0 text-xs text-muted">
{RISK_LEVEL_LABEL[p.risk_level]}
</span>
<select
value={p.permission_policy}
onChange={(e) =>
changePolicy(p.tool, e.target.value as PermissionPolicy)
}
disabled={savingTool === p.tool}
aria-label={`${p.tool} 权限策略`}
className="w-24 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur disabled:opacity-50"
>
{POLICY_OPTIONS.map((pol) => (
<option key={pol} value={pol}>
{PERMISSION_POLICY_LABEL[pol]}
</option>
))}
</select>
<button
type="button"
onClick={() => remove(p.tool)}
disabled={savingTool === p.tool}
className="shrink-0 text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</li>
))}
</ul>
</div>
{error && <p className="mt-2 px-1 text-xs text-cinnabar">{error}</p>}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useRouter } from "next/navigation";
import type { PivotHealthSummary } from "@/types/api";
import type { Pivot } from "@/types/domain";
import { Astrolabe } from "@/components/ui/Astrolabe";
/**
* 九枢星盘 · 司命台适配层。
* 渲染已抽取到 components/ui/Astrolabe(数据驱动、路由无关);
* 此处仅负责把枢点点击接到司命台枢详情路由。签名与旧实现一致,admin 页零改动。
*/
export function StarMap({
pivots,
className,
}: {
pivots: PivotHealthSummary[];
className?: string;
}) {
const router = useRouter();
return (
<Astrolabe
pivots={pivots}
className={className}
onSelectPivot={(pivot: Pivot) => router.push(`/admin/pivots/${pivot}`)}
/>
);
}
@@ -0,0 +1,290 @@
"use client";
import { useState } from "react";
import { toolsApi } from "@/lib/api";
import type { RiskLevel } from "@/types/domain";
import type { PermissionPolicy, ToolInfo } from "@/types/api";
import {
RISK_LEVEL_LABEL,
PERMISSION_POLICY_LABEL,
} from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
const RISK_OPTIONS: RiskLevel[] = ["L0", "L1", "L2", "L3", "L4"];
const POLICY_OPTIONS: PermissionPolicy[] = [
"allow",
"ask",
"always_ask",
"forbidden",
];
const RISK_DOT: Record<string, string> = {
L0: "bg-patina",
L1: "bg-patina",
L2: "bg-gold",
L3: "bg-cinnabar",
L4: "bg-cinnabar",
};
/**
* 工具注册表 —— 百工枢器用治理(可增删改)。
* 注册工具(name / description / risk / policy / side_effect / audit)、
* inline select 改 risk_level、注销。POST/PATCH/DELETE /tools;修改记审计。
* permission_policy 注册时定,运行时改动在司命枢裁定。
*/
export function ToolRegistry({ tools: initial }: { tools: ToolInfo[] }) {
const [tools, setTools] = useState(initial);
const [savingTool, setSavingTool] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newTool, setNewTool] = useState<{
name: string;
description: string;
risk_level: RiskLevel;
permission_policy: PermissionPolicy;
side_effect: boolean;
audit_required: boolean;
}>({
name: "",
description: "",
risk_level: "L2",
permission_policy: "ask",
side_effect: false,
audit_required: false,
});
async function changeRisk(name: string, next: RiskLevel) {
setSavingTool(name);
setError(null);
try {
const updated = await toolsApi.updateTool(name, { risk_level: next });
setTools((prev) =>
prev.map((t) =>
t.name === name ? { ...t, risk_level: updated.risk_level } : t,
),
);
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败,请重试。");
} finally {
setSavingTool(null);
}
}
async function remove(name: string) {
setSavingTool(name);
setError(null);
try {
await toolsApi.deleteTool(name);
setTools((prev) => prev.filter((t) => t.name !== name));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败,请重试。");
} finally {
setSavingTool(null);
}
}
async function add() {
if (!newTool.name.trim() || !newTool.description.trim()) {
setError("工具名与描述必填。");
return;
}
setSavingTool("__new");
setError(null);
try {
const created = await toolsApi.createTool({
name: newTool.name.trim(),
description: newTool.description.trim(),
risk_level: newTool.risk_level,
permission_policy: newTool.permission_policy,
side_effect: newTool.side_effect,
audit_required: newTool.audit_required,
});
setTools((prev) => [created, ...prev]);
setNewTool({
name: "",
description: "",
risk_level: "L2",
permission_policy: "ask",
side_effect: false,
audit_required: false,
});
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "注册失败,请重试。");
} finally {
setSavingTool(null);
}
}
return (
<div>
{showAdd ? (
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<input
value={newTool.name}
onChange={(e) =>
setNewTool((s) => ({ ...s, name: e.target.value }))
}
placeholder="工具名(如 read_calendar)"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 font-mono text-sm text-fg"
/>
<select
value={newTool.risk_level}
onChange={(e) =>
setNewTool((s) => ({
...s,
risk_level: e.target.value as RiskLevel,
}))
}
aria-label="风险等级"
className="w-20 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur"
>
{RISK_OPTIONS.map((r) => (
<option key={r} value={r}>
{RISK_LEVEL_LABEL[r]}
</option>
))}
</select>
<select
value={newTool.permission_policy}
onChange={(e) =>
setNewTool((s) => ({
...s,
permission_policy: e.target.value as PermissionPolicy,
}))
}
aria-label="权限策略"
className="w-24 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur"
>
{POLICY_OPTIONS.map((pol) => (
<option key={pol} value={pol}>
{PERMISSION_POLICY_LABEL[pol]}
</option>
))}
</select>
</div>
<input
value={newTool.description}
onChange={(e) =>
setNewTool((s) => ({ ...s, description: e.target.value }))
}
placeholder="工具描述…"
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-1.5 text-xs text-muted">
<input
type="checkbox"
checked={newTool.side_effect}
onChange={(e) =>
setNewTool((s) => ({ ...s, side_effect: e.target.checked }))
}
className="accent-[var(--cinnabar)]"
/>
有副作用
</label>
<label className="flex items-center gap-1.5 text-xs text-muted">
<input
type="checkbox"
checked={newTool.audit_required}
onChange={(e) =>
setNewTool((s) => ({
...s,
audit_required: e.target.checked,
}))
}
className="accent-[var(--gold)]"
/>
需审计
</label>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={add}
disabled={savingTool === "__new"}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
注册
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 注册工具
</button>
)}
<div className="glass overflow-hidden rounded-2xl">
<ul className="divide-y divide-border-glass">
{tools.map((t) => (
<li key={t.name} className="px-4 py-3">
<div className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
RISK_DOT[t.risk_level],
)}
/>
<span className="font-mono text-sm font-medium text-fg">
{t.name}
</span>
{t.side_effect && (
<span className="rounded bg-gold/15 px-1 py-0.5 text-[9px] text-gold">
副作用
</span>
)}
{t.audit_required && (
<span className="rounded bg-gold/15 px-1 py-0.5 text-[9px] text-gold">
审计
</span>
)}
<span className="shrink-0 text-[10px] text-muted">
{PERMISSION_POLICY_LABEL[t.permission_policy]}
</span>
<select
value={t.risk_level}
onChange={(e) =>
changeRisk(t.name, e.target.value as RiskLevel)
}
disabled={savingTool === t.name}
aria-label={`${t.name} 风险等级`}
className="w-20 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur disabled:opacity-50"
>
{RISK_OPTIONS.map((r) => (
<option key={r} value={r}>
{RISK_LEVEL_LABEL[r]}
</option>
))}
</select>
<button
type="button"
onClick={() => remove(t.name)}
disabled={savingTool === t.name}
className="shrink-0 text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
注销
</button>
</div>
<p className="mt-1 pl-4 text-xs text-muted">{t.description}</p>
</li>
))}
</ul>
</div>
{error && <p className="mt-2 px-1 text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -0,0 +1,83 @@
"use client";
import Link from "next/link";
import { motion, useReducedMotion } from "framer-motion";
import type { TraceSummary } from "@/types/api";
/** ISO 切割取 MM-DD HH:mm:ss,避免 SSR / 客户端时区 hydration 不一致 */
function formatTime(iso?: string): string {
if (!iso) return "——";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/);
return m ? `${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}` : iso;
}
/** 列表项归属芯片:首个 id + 其余计数(避免长 id 撑爆行宽) */
function IdChip({ ids, label }: { ids: string[]; label: string }) {
if (ids.length === 0) return null;
const first = ids[0];
const rest = ids.length > 1 ? ` +${ids.length - 1}` : "";
return (
<span
className="max-w-[10rem] truncate rounded bg-fg/5 px-1.5 py-0.5 font-mono text-[10px] text-fg/80"
title={`${label}:${ids.join(", ")}`}
>
{first}
{rest}
</span>
);
}
/**
* 链路追踪列表:每条 trace 一行(trace_id + 最近活动时间 + 人物/对话归属 + 三类计数),
* 点击进入该链路的时间线详情。按 last_seen 倒序(后端已排)。
*/
export function TraceList({ items }: { items: TraceSummary[] }) {
const reduceMotion = useReducedMotion();
if (items.length === 0) {
return (
<p className="rounded-2xl border border-border-glass bg-glass px-5 py-10 text-center text-sm text-muted">
暂无链路记录。
</p>
);
}
return (
<ul className="space-y-1">
{items.map((s, i) => (
<motion.li
key={s.trace_id}
initial={reduceMotion ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.25,
delay: reduceMotion ? 0 : Math.min(i * 0.03, 0.4),
}}
>
<Link
href={`/admin/traces?trace_id=${encodeURIComponent(s.trace_id)}`}
className="flex items-center gap-3 rounded-xl border border-border-glass bg-glass px-4 py-2.5 transition-colors hover:bg-glass-strong"
>
<span className="min-w-0 flex-1">
<span className="block truncate font-mono text-xs text-fg">
{s.trace_id}
</span>
<span className="mt-1 flex flex-wrap items-center gap-1.5 text-[10px] text-muted">
<span>{formatTime(s.last_seen)}</span>
<IdChip ids={s.character_ids} label="人物" />
<IdChip ids={s.session_ids} label="对话" />
</span>
</span>
<span className="flex shrink-0 items-center gap-2 text-[10px] font-medium">
<span className="text-patina">{s.event_count} 事件</span>
<span className="text-cinnabar">{s.audit_count} 审计</span>
<span className="text-gold">{s.idempotency_count} 请求</span>
</span>
</Link>
</motion.li>
))}
</ul>
);
}
@@ -0,0 +1,52 @@
"use client";
import { cn } from "@/lib/utils/cn";
/**
* 链路追踪搜索框:输入 trace_id 提交查询。trace_id 通常从错误提示(ApiError)
* 或运行轨迹页复制而来。
*/
export function TraceSearch({
value,
onChange,
onSubmit,
loading,
}: {
value: string;
onChange: (value: string) => void;
onSubmit: () => void;
loading: boolean;
}) {
const disabled = loading || !value.trim();
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (!disabled) onSubmit();
}}
className="flex items-center gap-2"
>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="粘贴 trace_id,如 trace_e5e220f8656c488895a696a5e9b5eedd"
spellCheck={false}
autoComplete="off"
className="min-w-0 flex-1 rounded-xl border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none transition-colors placeholder:text-muted/60 focus:border-patina/40"
/>
<button
type="submit"
disabled={disabled}
className={cn(
"shrink-0 rounded-xl px-4 py-2 text-sm font-medium transition-colors",
disabled
? "cursor-not-allowed bg-fg/10 text-muted"
: "bg-patina/15 text-patina hover:bg-patina/25",
)}
>
{loading ? "追踪中…" : "追踪"}
</button>
</form>
);
}
@@ -0,0 +1,153 @@
"use client";
import Link from "next/link";
import { motion, useReducedMotion } from "framer-motion";
import type { ReactNode } from "react";
import type { TraceDetail, TraceItem } from "@/types/api";
import { cn } from "@/lib/utils/cn";
type KindMeta = { label: string; dot: string; text: string };
/** 按 kind 着色:运行事件青(jade)/ 安全审计朱(cinnabar)/ 请求幂等铜(gold)。 */
const KIND_META: Record<TraceItem["kind"], KindMeta> = {
event: { label: "运行事件", dot: "bg-patina", text: "text-patina" },
audit: { label: "安全审计", dot: "bg-cinnabar", text: "text-cinnabar" },
idempotency: { label: "请求幂等", dot: "bg-gold", text: "text-gold" },
};
/** ISO 切割取 MM-DD HH:mm:ss,避免 SSR / 客户端时区 hydration 不一致 */
function formatTime(iso?: string): string {
if (!iso) return "——";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})/);
return m ? `${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}` : iso;
}
function MetaChip({ href, children }: { href?: string; children: ReactNode }) {
const cls =
"rounded-md bg-fg/5 px-1.5 py-0.5 font-mono text-[10px] text-fg transition-colors hover:bg-fg/10";
return href ? (
<Link href={href} className={cls}>
{children}
</Link>
) : (
<span className={cls}>{children}</span>
);
}
/**
* 链路追踪时间线 —— 司命台可观测签名。
* 顶部归属条(人物 / 对话 / run)+ 左侧铜金竖轴 + 按 kind 着色节点,
* 每条目可展开「详细内容」(payload / decision / path 等原始字段)。
*/
export function TraceTimeline({ detail }: { detail: TraceDetail }) {
const reduceMotion = useReducedMotion();
if (detail.items.length === 0) {
return (
<p className="rounded-2xl border border-border-glass bg-glass px-5 py-10 text-center text-sm text-muted">
未找到该链路的任何记录。
</p>
);
}
const hasMeta =
detail.character_ids.length > 0 ||
detail.session_ids.length > 0 ||
detail.run_ids.length > 0;
return (
<div className="space-y-4">
{hasMeta && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl border border-border-glass bg-glass px-4 py-2.5 text-xs text-muted">
{detail.character_ids.length > 0 && (
<span className="flex items-center gap-1.5">
<span>人物</span>
{detail.character_ids.map((cid) => (
<MetaChip key={cid} href={`/characters/${cid}`}>
{cid}
</MetaChip>
))}
</span>
)}
{detail.session_ids.length > 0 && (
<span className="flex items-center gap-1.5">
<span>对话</span>
{detail.session_ids.map((sid) => (
<MetaChip key={sid}>{sid}</MetaChip>
))}
</span>
)}
{detail.run_ids.length > 0 && (
<span className="flex items-center gap-1.5">
<span>run</span>
{detail.run_ids.map((rid) => (
<MetaChip key={rid} href={`/runs/${rid}`}>
{rid}
</MetaChip>
))}
</span>
)}
</div>
)}
<ul>
{detail.items.map((item, i) => {
const meta = KIND_META[item.kind];
const hasDetail = Object.keys(item.detail ?? {}).length > 0;
return (
<motion.li
key={item.id}
initial={reduceMotion ? false : { opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: 0.25,
delay: reduceMotion ? 0 : Math.min(i * 0.04, 0.4),
}}
className="relative flex items-start gap-3 border-l border-gold/20 py-2.5 pl-4"
>
<span
className={cn(
"absolute left-0 top-3.5 h-2.5 w-2.5 -translate-x-1/2 rounded-full",
meta.dot,
)}
/>
<span className="mt-0.5 shrink-0 font-mono text-[10px] tabular-nums text-muted">
{formatTime(item.created_at)}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span
className={cn(
"shrink-0 rounded px-1.5 text-[10px] font-medium",
meta.text,
)}
>
{meta.label}
</span>
<span className="truncate text-xs text-fg">
{item.summary}
</span>
</div>
<div className="mt-0.5 truncate text-[10px] text-muted/70">
{item.source_label}
</div>
{hasDetail && (
<details className="mt-1">
<summary className="cursor-pointer select-none text-[10px] text-muted transition-colors hover:text-fg">
详细内容
</summary>
<pre className="mt-1 overflow-x-auto rounded-lg bg-fg/5 p-2 font-mono text-[10px] leading-relaxed text-fg/80">
{JSON.stringify(item.detail, null, 2)}
</pre>
</details>
)}
</div>
</motion.li>
);
})}
</ul>
</div>
);
}
@@ -0,0 +1,111 @@
"use client";
import { useState } from "react";
import { safetyApi } from "@/lib/api";
import type { PendingApproval } from "@/types/api";
import {
PERMISSION_POLICY_LABEL,
RISK_LEVEL_LABEL,
} from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
type Phase = "pending" | "loading" | "approved" | "denied";
/**
* 朱批裁定卡 —— 司命台动作签名(铜镜鉴心 v2)。
* 待裁定项以朱漆描边浮前:左缘 3px 朱漆 + 朱漆辐射底晕 + 鎏金"朱批待定"印文,
* 右缘 RISK mono 印鉴;裁定后卡片转为低饱和留档态。动效克制,仅在交互瞬间触发。
*/
export function VermilionAction({ approval }: { approval: PendingApproval }) {
const [phase, setPhase] = useState<Phase>("pending");
const [error, setError] = useState<string | null>(null);
async function decide(action: "approve" | "deny") {
setPhase("loading");
setError(null);
try {
if (action === "approve") {
await safetyApi.approveConfirmation(approval.confirmation_id);
setPhase("approved");
} else {
await safetyApi.denyConfirmation(approval.confirmation_id);
setPhase("denied");
}
} catch (e) {
setError(e instanceof Error ? e.message : "裁定失败,请重试。");
setPhase("pending");
}
}
const decided = phase === "approved" || phase === "denied";
return (
<div
className={cn(
"rounded-xl p-4 backdrop-blur-sm transition-opacity",
decided
? "border border-border-glass bg-glass opacity-60"
: "vermilion",
)}
>
<div className="flex flex-wrap items-center gap-2">
{!decided && (
<span className="font-serif text-[13px] font-bold tracking-wide text-cinnabar">
朱批待定
</span>
)}
<span className="font-serif text-sm font-semibold text-fg">
{approval.tool}
</span>
<span className="text-[10px] text-muted">
{PERMISSION_POLICY_LABEL[approval.permission_policy]}
</span>
<span className="ml-auto inline-flex items-center rounded border border-cinnabar/40 px-1.5 py-0.5 font-mono text-[9.5px] tracking-wide text-cinnabar">
{RISK_LEVEL_LABEL[approval.risk_level]}
</span>
</div>
<p className="mt-2 text-[13.5px] leading-relaxed text-fg">
{approval.action_summary}
</p>
{approval.possible_impact && (
<p className="mt-1 text-xs text-muted">
影响:{approval.possible_impact}
</p>
)}
{approval.reason && (
<p className="mt-1 text-xs text-muted">{approval.reason}</p>
)}
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
{phase === "approved" && (
<p className="mt-3 text-xs font-medium text-patina">已朱批放行</p>
)}
{phase === "denied" && (
<p className="mt-3 text-xs font-medium text-muted">已驳回</p>
)}
{phase === "pending" || phase === "loading" ? (
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => decide("approve")}
disabled={phase === "loading"}
className="rounded-lg border border-cinnabar/40 bg-cinnabar/15 px-3 py-1.5 text-xs font-medium text-cinnabar transition-all hover:scale-[1.02] hover:bg-cinnabar/25 active:scale-95 disabled:opacity-50"
>
朱批放行
</button>
<button
type="button"
onClick={() => decide("deny")}
disabled={phase === "loading"}
className="rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-xs font-medium text-muted transition-all hover:scale-[1.02] hover:bg-glass-strong hover:text-fg active:scale-95 disabled:opacity-50"
>
驳回
</button>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,322 @@
"use client";
import { useState } from "react";
import { adminApi } from "@/lib/api";
import type { WorldEntityType, WorldStateType } from "@/types/domain";
import type { WorldEntity } from "@/types/api";
import {
WORLD_ENTITY_TYPE_LABEL,
WORLD_STATE_TYPE_LABEL,
} from "@/lib/constants/labels";
import { cn } from "@/lib/utils/cn";
const STATE_OPTIONS: WorldStateType[] = [
"fact",
"observation",
"inference",
"hypothesis",
"stale",
];
/** 新增可选类型(不含 self——自身实体不可手动创建) */
const TYPE_OPTIONS: WorldEntityType[] = [
"person",
"object",
"place",
"device",
"project",
"task",
"concept",
];
const STATE_DOT: Record<WorldStateType, string> = {
fact: "bg-patina",
observation: "bg-patina",
inference: "bg-gold",
hypothesis: "bg-gold",
stale: "bg-cinnabar",
};
/**
* 河图枢世界实体列表 —— 世界模型治理(可增删改)。
* 区分事实与推断(state_type 着色);新增实体(非 self)、编辑名称/置信度、
* select 改状态类型、删除。推断与假设召回时降权,不得伪装成事实(契约字典 §11.2)。
*/
export function WorldEntityList({
entities: initial,
}: {
entities: WorldEntity[];
}) {
const [entities, setEntities] = useState(initial);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<{ name: string; confidence: number }>({
name: "",
confidence: 0,
});
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
const [newEntity, setNewEntity] = useState<{
type: WorldEntityType;
name: string;
}>({
type: "concept",
name: "",
});
function startEdit(e: WorldEntity) {
setEditingId(e.id);
setDraft({ name: e.name, confidence: e.confidence });
setError(null);
}
async function changeState(e: WorldEntity, next: WorldStateType) {
setBusyId(e.id);
setError(null);
try {
const updated = await adminApi.updateWorldEntity(e.id, {
state_type: next,
});
setEntities((prev) =>
prev.map((x) =>
x.id === e.id ? { ...x, state_type: updated.state_type } : x,
),
);
} catch (err) {
setError(err instanceof Error ? err.message : "更新失败,请重试。");
} finally {
setBusyId(null);
}
}
async function save(id: string) {
setBusyId(id);
setError(null);
try {
const updated = await adminApi.updateWorldEntity(id, {
name: draft.name,
confidence: draft.confidence,
});
setEntities((prev) =>
prev.map((x) =>
x.id === id
? { ...x, name: updated.name, confidence: updated.confidence }
: x,
),
);
setEditingId(null);
} catch (err) {
setError(err instanceof Error ? err.message : "保存失败,请重试。");
} finally {
setBusyId(null);
}
}
async function remove(e: WorldEntity) {
setBusyId(e.id);
setError(null);
try {
await adminApi.deleteWorldEntity(e.id);
setEntities((prev) => prev.filter((x) => x.id !== e.id));
} catch (err) {
setError(err instanceof Error ? err.message : "删除失败,请重试。");
} finally {
setBusyId(null);
}
}
async function add() {
if (!newEntity.name.trim()) {
setError("请填写实体名称。");
return;
}
setBusyId("__new");
setError(null);
try {
const created = await adminApi.createWorldEntity({
type: newEntity.type,
name: newEntity.name.trim(),
});
setEntities((prev) => [...prev, created]);
setNewEntity({ type: "concept", name: "" });
setShowAdd(false);
} catch (e) {
setError(e instanceof Error ? e.message : "新增失败,请重试。");
} finally {
setBusyId(null);
}
}
return (
<>
{showAdd ? (
<div className="glass mb-2 space-y-2 rounded-xl px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<select
value={newEntity.type}
onChange={(e) =>
setNewEntity((s) => ({
...s,
type: e.target.value as WorldEntityType,
}))
}
aria-label="实体类型"
className="w-24 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur"
>
{TYPE_OPTIONS.map((t) => (
<option key={t} value={t}>
{WORLD_ENTITY_TYPE_LABEL[t]}
</option>
))}
</select>
<input
value={newEntity.name}
onChange={(e) =>
setNewEntity((s) => ({ ...s, name: e.target.value }))
}
placeholder="实体名称…"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<button
type="button"
onClick={add}
disabled={busyId === "__new"}
className="shrink-0 rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
新增
</button>
<button
type="button"
onClick={() => setShowAdd(false)}
className="shrink-0 rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setShowAdd(true)}
className="mb-2 rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增世界实体
</button>
)}
<ul className="space-y-2">
{entities.map((e) => (
<li key={e.id} className="glass rounded-xl px-4 py-2.5">
{editingId === e.id ? (
<div className="space-y-2">
<input
value={draft.name}
onChange={(ev) =>
setDraft((d) => ({ ...d, name: ev.target.value }))
}
className="w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg"
/>
<div className="flex items-center gap-2">
<label className="text-[10px] text-muted">置信度</label>
<input
type="number"
min={0}
max={1}
step={0.05}
value={draft.confidence}
onChange={(ev) =>
setDraft((d) => ({
...d,
confidence: Number(ev.target.value),
}))
}
className="w-20 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg"
/>
<div className="ml-auto flex gap-2">
<button
type="button"
onClick={() => save(e.id)}
disabled={busyId === e.id}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
保存
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
</div>
</div>
) : (
<>
<div className="flex items-center gap-2">
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
STATE_DOT[e.state_type],
)}
/>
<span className="rounded bg-gold/15 px-1.5 py-0.5 text-[10px] text-gold">
{WORLD_ENTITY_TYPE_LABEL[e.type]}
</span>
<span className="truncate font-serif text-sm font-medium text-fg">
{e.name}
</span>
<span className="ml-auto shrink-0 font-mono text-[10px] text-muted">
{e.confidence.toFixed(2)}
</span>
<select
value={e.state_type}
onChange={(ev) =>
changeState(e, ev.target.value as WorldStateType)
}
disabled={busyId === e.id}
aria-label={`${e.name} 状态类型`}
className="w-20 shrink-0 rounded-lg border border-border-glass bg-glass px-2 py-1 text-xs text-fg backdrop-blur disabled:opacity-50"
>
{STATE_OPTIONS.map((s) => (
<option key={s} value={s}>
{WORLD_STATE_TYPE_LABEL[s]}
</option>
))}
</select>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 pl-4 text-[10px] text-muted">
{Object.entries(e.attributes).map(([k, v]) => (
<span key={k}>
{k}:{v}
</span>
))}
</div>
<div className="mt-1.5 flex gap-3 pl-4">
<button
type="button"
onClick={() => startEdit(e)}
className="text-[11px] text-patina hover:underline"
>
编辑名称/置信度
</button>
{e.type !== "self" && (
<button
type="button"
onClick={() => remove(e)}
disabled={busyId === e.id}
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
)}
</div>
</>
)}
</li>
))}
</ul>
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</>
);
}
@@ -0,0 +1,182 @@
"use client";
import { useState } from "react";
import { mcpApi } from "@/lib/api";
import type { McpServer } from "@/types/api";
interface Props {
server: McpServer;
open: boolean;
onClose: () => void;
onUpdated: (server: McpServer) => void;
}
export function EditMcpDrawer({ server, open, onClose, onUpdated }: Props) {
const [endpointUrl, setEndpointUrl] = useState(server.endpoint_url ?? "");
const [authToken, setAuthToken] = useState("");
const [command, setCommand] = useState(server.command ?? "");
const [argsJson, setArgsJson] = useState(server.args_json ?? "");
const [envJson, setEnvJson] = useState(server.env_json ?? "");
const [cwd, setCwd] = useState(server.cwd ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const isHttp = server.transport === "streamable-http";
async function handleSave() {
setBusy(true);
setError(null);
try {
const updated = await mcpApi.updateMcpServer(server.id, {
endpoint_url: endpointUrl.trim() || undefined,
auth_token: authToken.trim() || undefined,
command: command.trim() || undefined,
args_json: argsJson.trim() || undefined,
env_json: envJson.trim() || undefined,
cwd: cwd.trim() || undefined,
});
onUpdated(updated);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败。");
} finally {
setBusy(false);
}
}
if (!open) return null;
return (
<>
<div
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
onClick={onClose}
/>
<div className="glass-strong fixed right-0 top-0 z-50 flex h-full w-[480px] max-w-[90vw] flex-col shadow-2xl">
<div className="flex items-center justify-between border-b border-border-glass px-6 py-4">
<h2 className="font-serif text-base font-semibold text-fg">
编辑 &mdash; {server.name}
</h2>
<button
type="button"
onClick={onClose}
className="rounded-lg px-2 py-1 text-sm text-muted hover:text-fg"
>
✕
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5">
<div className="space-y-3">
<label className="block">
<span className="mb-1 block text-xs text-muted">名称</span>
<p className="rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-muted">
{server.name}
<span className="ml-2 text-[10px]">(不可修改)</span>
</p>
</label>
{isHttp ? (
<>
<label className="block">
<span className="mb-1 block text-xs text-muted">
endpoint_url
</span>
<input
value={endpointUrl}
onChange={(e) => setEndpointUrl(e.target.value)}
placeholder="https://..."
className="w-full rounded-lg border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">
auth_token
</span>
<input
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
type="password"
placeholder={
server.has_auth_token
? "●●●● (已配置,留空保持不变)"
: "可选"
}
className="w-full rounded-lg border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
</>
) : (
<>
<label className="block">
<span className="mb-1 block text-xs text-muted">command</span>
<input
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="npx"
className="w-full rounded-lg border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">
args (JSON Array)
</span>
<textarea
value={argsJson}
onChange={(e) => setArgsJson(e.target.value)}
rows={2}
placeholder='["-y","@modelcontextprotocol/..."]'
className="w-full rounded-lg border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">
env (JSON Dict)
</span>
<textarea
value={envJson}
onChange={(e) => setEnvJson(e.target.value)}
rows={2}
placeholder='{ "API_KEY": "..." }'
className="w-full rounded-lg border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">cwd</span>
<input
value={cwd}
onChange={(e) => setCwd(e.target.value)}
placeholder="/data/mcp/"
className="w-full rounded-lg border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
</>
)}
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
</div>
<div className="flex items-center justify-between border-t border-border-glass px-6 py-4">
<button
type="button"
onClick={onClose}
disabled={busy}
className="rounded-lg px-3 py-2 text-sm text-muted transition-colors hover:text-fg disabled:opacity-40"
>
取消
</button>
<button
type="button"
onClick={handleSave}
disabled={busy}
className="rounded-xl bg-patina px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-patina/90 disabled:opacity-40"
>
{busy ? "保存中…" : "保存"}
</button>
</div>
</div>
</>
);
}
@@ -0,0 +1,743 @@
"use client";
import { useEffect, useState } from "react";
import { gatewayApi } from "@/lib/api/gateway";
import { cn } from "@/lib/utils/cn";
import type {
AddGatewayServiceRequest,
GatewayHealth,
GatewayService,
GatewayTool,
} from "@/lib/api/gateway";
const RISK_DOT: Record<string, string> = {
L0: "bg-patina",
L1: "bg-patina",
L2: "bg-gold",
L3: "bg-cinnabar",
L4: "bg-cinnabar",
};
/** 玄关网关管理面板 */
export function GatewayPanel() {
const [health, setHealth] = useState<GatewayHealth | null>(null);
const [services, setServices] = useState<GatewayService[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [expandedService, setExpandedService] = useState<string | null>(null);
const [editingService, setEditingService] = useState<GatewayService | null>(
null,
);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
async function fetchAll() {
setError(null);
try {
const [h, svcs] = await Promise.all([
gatewayApi.getHealth(),
gatewayApi.listServices(),
]);
setHealth(h);
setServices(svcs);
} catch (e) {
setError(e instanceof Error ? e.message : "网关连接失败");
}
}
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchAll();
const id = setInterval(() => {
fetchAll();
}, 15_000);
return () => clearInterval(id);
}, []);
async function handleAdd(body: AddGatewayServiceRequest) {
setBusy(true);
setError(null);
try {
await gatewayApi.addService(body);
setShowAdd(false);
await fetchAll();
} catch (e) {
setError(e instanceof Error ? e.message : "注册失败");
} finally {
setBusy(false);
}
}
async function handleToggleEnabled(svc: GatewayService) {
setBusy(true);
setError(null);
try {
await gatewayApi.updateService(svc.name, { disabled: !svc.disabled });
await fetchAll();
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败");
} finally {
setBusy(false);
}
}
async function handleDelete(name: string) {
setBusy(true);
setError(null);
try {
await gatewayApi.deleteService(name);
setDeleteConfirm(null);
await fetchAll();
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败");
} finally {
setBusy(false);
}
}
return (
<div className="space-y-4">
{error && (
<div className="rounded-xl bg-cinnabar/10 px-4 py-2.5 text-sm text-cinnabar">
{error}
<button type="button" onClick={fetchAll} className="ml-3 underline">
重试
</button>
</div>
)}
{/* 网关状态 */}
{health && (
<div className="glass grid grid-cols-2 gap-3 rounded-2xl px-4 py-3 lg:grid-cols-4">
<div>
<span className="text-[10px] text-muted uppercase tracking-wider">
状态
</span>
<p className="mt-0.5 font-mono text-sm text-patina">运行中</p>
</div>
<div>
<span className="text-[10px] text-muted uppercase tracking-wider">
版本
</span>
<p className="mt-0.5 font-mono text-sm text-fg">{health.version}</p>
</div>
<div>
<span className="text-[10px] text-muted uppercase tracking-wider">
服务数
</span>
<p className="mt-0.5 font-mono text-sm text-fg">
{health.services}
</p>
</div>
<div>
<span className="text-[10px] text-muted uppercase tracking-wider">
工具数
</span>
<p className="mt-0.5 font-mono text-sm text-fg">
{health.tool_count}
</p>
</div>
</div>
)}
{/* 操作栏 */}
<div className="flex items-center justify-between">
<p className="text-xs text-muted">{services.length} 个注册服务</p>
{!showAdd && !editingService && (
<button
type="button"
onClick={() => setShowAdd(true)}
className="rounded-xl border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 注册服务
</button>
)}
</div>
{/* 注册/编辑表单 */}
{showAdd && (
<AddServiceForm
busy={busy}
onSubmit={handleAdd}
onCancel={() => setShowAdd(false)}
/>
)}
{editingService && (
<EditServiceForm
service={editingService}
busy={busy}
onSubmit={async (body) => {
await gatewayApi.updateService(editingService.name, body);
setEditingService(null);
await fetchAll();
}}
onCancel={() => setEditingService(null)}
/>
)}
{/* 删除确认 */}
{deleteConfirm && (
<div className="glass rounded-2xl border-cinnabar/20 px-4 py-3">
<p className="mb-2 text-sm text-fg">
确认删除服务{" "}
<span className="font-mono font-medium">{deleteConfirm}</span>?
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => handleDelete(deleteConfirm)}
disabled={busy}
className="rounded-lg bg-cinnabar/20 px-3 py-1.5 text-xs font-medium text-cinnabar disabled:opacity-50"
>
确认删除
</button>
<button
type="button"
onClick={() => setDeleteConfirm(null)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-xs text-muted"
>
取消
</button>
</div>
</div>
)}
{/* 服务列表 */}
{services.length === 0 && !error ? (
<div className="glass flex items-center justify-center rounded-2xl py-12">
<p className="text-sm text-muted">
暂无已注册的服务。点击「+ 注册服务」添加。
</p>
</div>
) : (
<div className="space-y-2">
{services.map((svc) => {
const expanded = expandedService === svc.name;
const isEnabled = !svc.disabled;
return (
<div
key={svc.name}
className={cn(
"glass rounded-2xl transition-all duration-200",
!isEnabled && "opacity-60",
)}
>
{/* 折叠态 */}
<div className="flex items-center gap-3 px-4 py-3">
{/* 状态灯 */}
<span
className={cn(
"inline-block h-2.5 w-2.5 shrink-0 rounded-full",
isEnabled
? "bg-patina shadow-[0_0_6px] shadow-patina/40"
: "bg-muted/30",
)}
/>
<span className="font-serif text-sm font-semibold text-fg">
{svc.name}
</span>
<span
className={cn(
"rounded-md px-1.5 py-0.5 text-[10px] font-medium",
isEnabled
? "bg-patina/10 text-patina"
: "bg-cinnabar/10 text-cinnabar",
)}
>
{isEnabled ? "已启用" : "已禁用"}
</span>
<span className="rounded-md bg-gold/10 px-1.5 py-0.5 font-mono text-[10px] text-gold">
{svc.tools.length} 工具
</span>
<span className="truncate text-[11px] text-muted">
{svc.description}
</span>
{/* 操作按钮 */}
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => handleToggleEnabled(svc)}
disabled={busy}
className={cn(
"rounded-lg border px-2.5 py-1 text-[11px] font-medium transition-colors disabled:opacity-40",
isEnabled
? "border-cinnabar/20 text-cinnabar hover:bg-cinnabar/10"
: "border-patina/30 text-patina hover:bg-patina/10",
)}
>
{isEnabled ? "禁用" : "启用"}
</button>
<button
type="button"
onClick={() =>
setExpandedService(expanded ? null : svc.name)
}
className="rounded-lg px-2 py-1 text-[11px] text-muted transition-colors hover:text-fg"
>
{expanded ? "收起" : "详情"}
</button>
<button
type="button"
onClick={() => setEditingService(svc)}
className="rounded-lg px-2 py-1 text-[11px] text-muted transition-colors hover:text-fg"
>
编辑
</button>
<button
type="button"
onClick={() => setDeleteConfirm(svc.name)}
disabled={busy}
className="shrink-0 rounded-lg px-2 py-1 text-[11px] text-cinnabar transition-colors hover:bg-cinnabar/10 disabled:opacity-40"
>
✕ 删除
</button>
</div>
</div>
{/* 展开态 */}
{expanded && (
<div className="border-t border-border-glass px-4 pb-4 pt-3">
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
<span className="text-muted">endpoint</span>
<span className="font-mono text-fg break-all">
{svc.endpoint}
</span>
<span className="text-muted">timeout</span>
<span className="font-mono text-fg">{svc.timeout}</span>
<span className="text-muted">状态</span>
<span
className={cn(
"font-mono",
isEnabled ? "text-patina" : "text-cinnabar",
)}
>
{isEnabled ? "已启用" : "已禁用"}
</span>
</div>
{/* 工具清单 */}
<div className="mt-3">
<p className="mb-1 text-[10px] text-muted uppercase tracking-wider">
工具清单({svc.tools.length})
</p>
<div className="flex flex-wrap gap-1.5">
{svc.tools.map((t) => (
<span
key={t.name}
className="inline-flex items-center gap-1.5 rounded-md border border-border-glass bg-glass/50 px-2 py-0.5 font-mono text-[10px] text-fg/80"
>
<span
className={cn(
"inline-block h-1.5 w-1.5 rounded-full",
RISK_DOT[t.risk_level] ?? "bg-muted",
)}
/>
{t.name}
<span className="text-[9px] text-muted/60">
{t.visibility === "public" ? "" : t.visibility}
</span>
</span>
))}
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
/* ─── 注册表单 ─── */
function AddServiceForm({
busy,
onSubmit,
onCancel,
}: {
busy: boolean;
onSubmit: (body: AddGatewayServiceRequest) => void | Promise<void>;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const [desc, setDesc] = useState("");
const [endpoint, setEndpoint] = useState("");
const [detectedTools, setDetectedTools] = useState<GatewayTool[]>([]);
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
const [detecting, setDetecting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
async function handleDetect() {
if (!endpoint.trim()) {
setLocalError("请先填写 endpoint");
return;
}
setDetecting(true);
setLocalError(null);
try {
const result = await gatewayApi.detectTools(endpoint.trim());
setDetectedTools(result.tools);
// 默认全选
setSelectedTools(new Set(result.tools.map((t) => t.name)));
} catch (e) {
setLocalError(e instanceof Error ? e.message : "检测失败");
} finally {
setDetecting(false);
}
}
function toggleTool(name: string) {
setSelectedTools((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
function toggleAll() {
if (selectedTools.size === detectedTools.length) {
setSelectedTools(new Set());
} else {
setSelectedTools(new Set(detectedTools.map((t) => t.name)));
}
}
async function handleSubmit() {
setLocalError(null);
if (!name.trim() || !endpoint.trim()) {
setLocalError("名称与 endpoint 必填");
return;
}
const selected = detectedTools
.filter((t) => selectedTools.has(t.name))
.map((t) => ({
name: t.name,
description: t.description,
risk_level: t.risk_level || "L2",
}));
await onSubmit({
name: name.trim(),
description: desc.trim(),
endpoint: endpoint.trim(),
tools: selected,
});
}
return (
<div className="glass rounded-2xl px-4 py-4">
<p className="mb-3 font-serif text-sm font-semibold text-fg">
注册新服务
</p>
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs text-muted">
名称 <span className="text-cinnabar">*</span>
</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="如 web-search"
className="w-full rounded-lg border border-border-glass bg-glass px-3 py-1.5 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">
endpoint <span className="text-cinnabar">*</span>
</span>
<div className="flex gap-2">
<input
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="http://localhost:8081"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-3 py-1.5 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
<button
type="button"
onClick={handleDetect}
disabled={detecting}
className="shrink-0 rounded-lg border border-patina/30 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20 disabled:opacity-50"
>
{detecting ? "检测中…" : "检测工具"}
</button>
</div>
</label>
</div>
<label className="block">
<span className="mb-1 block text-xs text-muted">描述</span>
<input
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="服务功能描述…"
className="w-full rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
{/* 检测到的工具列表 */}
{detectedTools.length > 0 && (
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-muted">
工具({selectedTools.size}/{detectedTools.length})
</span>
<button
type="button"
onClick={toggleAll}
className="text-[11px] text-patina hover:underline"
>
{selectedTools.size === detectedTools.length
? "取消全选"
: "全选"}
</button>
</div>
<div className="space-y-1">
{detectedTools.map((t) => (
<label
key={t.name}
className="flex cursor-pointer items-center gap-2 rounded-lg border border-border-glass bg-glass px-3 py-1.5 transition-colors hover:bg-glass-strong"
>
<input
type="checkbox"
checked={selectedTools.has(t.name)}
onChange={() => toggleTool(t.name)}
className="accent-[var(--patina)]"
/>
<span className="font-mono text-xs text-fg">{t.name}</span>
<span className="truncate text-[11px] text-muted">
{t.description}
</span>
</label>
))}
</div>
</div>
)}
{detectedTools.length === 0 && !detecting && (
<p className="text-xs text-muted">
点击「检测工具」自动拉取 endpoint 上的 MCP 工具清单。
</p>
)}
{localError && <p className="text-xs text-cinnabar">{localError}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={busy}
className="rounded-lg bg-patina/20 px-3 py-1.5 text-xs font-medium text-patina disabled:opacity-50"
>
{busy ? "注册中…" : "注册"}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-xs text-muted"
>
取消
</button>
</div>
</div>
</div>
);
}
/* ─── 编辑服务表单 ─── */
function EditServiceForm({
service,
busy,
onSubmit,
onCancel,
}: {
service: GatewayService;
busy: boolean;
onSubmit: (body: {
description?: string;
endpoint?: string;
tools?: {
name: string;
description: string;
risk_level: string;
visibility?: string;
}[];
}) => void | Promise<void>;
onCancel: () => void;
}) {
const [desc, setDesc] = useState(service.description);
const [endpoint, setEndpoint] = useState(service.endpoint);
const [tools, setTools] = useState<GatewayTool[]>(service.tools);
const [selectedTools, setSelectedTools] = useState<Set<string>>(
new Set(service.tools.map((t) => t.name)),
);
const [detecting, setDetecting] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
function toggleTool(name: string) {
setSelectedTools((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
function toggleAll() {
if (selectedTools.size === tools.length) {
setSelectedTools(new Set());
} else {
setSelectedTools(new Set(tools.map((t) => t.name)));
}
}
async function handleDetect() {
if (!endpoint.trim()) return;
setDetecting(true);
try {
const result = await gatewayApi.detectTools(endpoint.trim());
if (result.tools.length > 0) {
setTools(result.tools);
setSelectedTools(new Set(result.tools.map((t) => t.name)));
}
} catch {
// ignore
} finally {
setDetecting(false);
}
}
async function handleSubmit() {
setLocalError(null);
if (!endpoint.trim()) {
setLocalError("endpoint 不能为空");
return;
}
const selected = tools
.filter((t) => selectedTools.has(t.name))
.map((t) => ({
name: t.name,
description: t.description,
risk_level: t.risk_level || "L2",
visibility: t.visibility,
}));
await onSubmit({
description: desc.trim() || undefined,
endpoint: endpoint.trim(),
tools: selected,
});
}
return (
<div className="glass rounded-2xl px-4 py-4">
<p className="mb-3 font-serif text-sm font-semibold text-fg">
编辑服务 — {service.name}
</p>
<div className="space-y-3">
<label className="block">
<span className="mb-1 block text-xs text-muted">
名称(不可修改)
</span>
<p className="rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-sm text-muted">
{service.name}
</p>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">描述</span>
<input
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="服务功能描述…"
className="w-full rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-sm text-fg outline-none focus:border-patina/40"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs text-muted">endpoint</span>
<div className="flex gap-2">
<input
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="http://localhost:8081"
className="min-w-0 flex-1 rounded-lg border border-border-glass bg-glass px-3 py-1.5 font-mono text-sm text-fg outline-none focus:border-patina/40"
/>
<button
type="button"
onClick={handleDetect}
disabled={detecting}
className="shrink-0 rounded-lg border border-patina/30 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina disabled:opacity-50"
>
{detecting ? "检测中…" : "重新检测"}
</button>
</div>
</label>
{/* 工具管理 */}
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-muted">
工具({selectedTools.size}/{tools.length})
</span>
<button
type="button"
onClick={toggleAll}
className="text-[11px] text-patina hover:underline"
>
{selectedTools.size === tools.length ? "取消全选" : "全选"}
</button>
</div>
<div className="space-y-1">
{tools.map((t) => (
<label
key={t.name}
className="flex cursor-pointer items-center gap-2 rounded-lg border border-border-glass bg-glass px-3 py-1.5 transition-colors hover:bg-glass-strong"
>
<input
type="checkbox"
checked={selectedTools.has(t.name)}
onChange={() => toggleTool(t.name)}
className="accent-[var(--patina)]"
/>
<span className="font-mono text-xs text-fg">{t.name}</span>
<span className="text-[10px] text-muted">
{t.visibility === "public" ? "" : t.visibility}
</span>
</label>
))}
</div>
{tools.length === 0 && (
<p className="text-xs text-muted">
该服务暂无工具。点击「重新检测」从 endpoint 拉取工具清单。
</p>
)}
</div>
{localError && <p className="text-xs text-cinnabar">{localError}</p>}
<div className="flex gap-2">
<button
type="button"
onClick={handleSubmit}
disabled={busy}
className="rounded-lg bg-patina/20 px-3 py-1.5 text-xs font-medium text-patina disabled:opacity-50"
>
{busy ? "保存中…" : "保存"}
</button>
<button
type="button"
onClick={onCancel}
className="rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-xs text-muted"
>
取消
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,245 @@
"use client";
import { memo } from "react";
import { StatusDot, TransportBadge } from "./McpServerWidgets";
import { cn } from "@/lib/utils/cn";
import type { McpServer, McpServerProcessStatus } from "@/types/api";
interface Props {
server: McpServer;
procStatus: McpServerProcessStatus | null;
expanded: boolean;
busy: boolean;
busyId: string | null;
lastCheckTime: string | null;
justChecked: boolean;
onToggleExpand: () => void;
onHealthCheck: () => void;
onToggleEnabled: () => void;
onEdit: () => void;
onDelete: () => void;
}
/* ─── 运维状态摘要(仅显示进程相关信息) ─── */
function ProcSummary({ s }: { s: McpServerProcessStatus | null }) {
if (!s) return null;
const parts: string[] = [];
if (s.pid != null) parts.push(`pid:${s.pid}`);
if (s.uptime_seconds != null) {
const m = Math.floor(s.uptime_seconds / 60);
const sec = Math.floor(s.uptime_seconds % 60);
parts.push(m > 0 ? `已运行 ${m}m${sec}s` : `${sec}s`);
}
return parts.length > 0 ? (
<span className="text-[11px] text-muted">{parts.join(" · ")}</span>
) : null;
}
/* ─── 启用/禁用状态徽章 ─── */
function EnabledBadge({ enabled }: { enabled: boolean }) {
return (
<span
className={cn(
"rounded-md px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider",
enabled ? "bg-patina/10 text-patina" : "bg-cinnabar/10 text-cinnabar",
)}
>
{enabled ? "已启用" : "已禁用"}
</span>
);
}
/* ─── 单张 MCP Server 卡片 ─── */
export const McpServerCard = memo(function McpServerCard({
server,
procStatus,
expanded,
busy,
busyId,
lastCheckTime,
justChecked,
onToggleExpand,
onHealthCheck,
onToggleEnabled,
onEdit,
onDelete,
}: Props) {
const procState = procStatus?.status ?? "stopped";
const isEnabled = server.status === "enabled";
const toollist = (server.tools ?? []) as { name?: string }[];
const toolCount = toollist.length;
return (
<div
className={cn(
"glass rounded-2xl transition-all duration-200",
procState === "error" && "border-cinnabar/20",
!isEnabled && "opacity-60",
)}
>
{/* 折叠态头部 */}
<div className="flex items-center gap-3 px-4 py-3">
{isEnabled ? (
<StatusDot status={procState} pulse />
) : (
<span className="inline-block h-2.5 w-2.5 shrink-0 rounded-full bg-muted/30" />
)}
<span className="font-serif text-sm font-semibold text-fg">
{server.name}
</span>
<TransportBadge transport={server.transport} />
{/* 工具计数 */}
{toolCount > 0 && (
<span className="rounded-md bg-patina/8 px-1.5 py-0.5 font-mono text-[10px] text-muted">
{toolCount} 工具
</span>
)}
<EnabledBadge enabled={isEnabled} />
<ProcSummary s={procStatus} />
{/* 操作按钮组 */}
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{/* 启用/禁用开关 */}
<button
type="button"
onClick={onToggleEnabled}
disabled={busy && busyId === server.id}
className={cn(
"rounded-lg border px-2.5 py-1 text-[11px] font-medium transition-colors disabled:opacity-40",
isEnabled
? "border-cinnabar/20 text-cinnabar hover:bg-cinnabar/10"
: "border-patina/30 text-patina hover:bg-patina/10",
)}
>
{isEnabled ? "禁用" : "启用"}
</button>
{/* 刷新工具 */}
<button
type="button"
onClick={onHealthCheck}
disabled={busy && busyId === server.id}
className={cn(
"rounded-lg border bg-glass px-2 py-1 text-[11px] transition-colors disabled:opacity-40",
justChecked
? "border-patina/30 text-patina"
: "border-border-glass text-muted hover:text-fg",
)}
>
{busy && busyId === server.id
? "刷新中…"
: justChecked
? "✓ 已刷新"
: "↻ 刷新"}
</button>
<button
type="button"
onClick={onToggleExpand}
className="rounded-lg px-2 py-1 text-[11px] text-muted transition-colors hover:text-fg"
>
{expanded ? "收起" : "详情"}
</button>
<button
type="button"
onClick={onEdit}
className="rounded-lg px-2 py-1 text-[11px] text-muted transition-colors hover:text-fg"
>
编辑
</button>
<button
type="button"
onClick={onDelete}
disabled={busy}
className="rounded-lg px-2 py-1 text-[11px] text-cinnabar transition-colors hover:bg-cinnabar/10 disabled:opacity-40"
>
✕ 删除
</button>
</div>
</div>
{/* 展开态详情面板 */}
{expanded && (
<div className="border-t border-border-glass px-4 pb-4 pt-3">
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
<span className="text-muted">endpoint</span>
<span className="font-mono text-fg break-all">
{server.endpoint_url ?? "—"}
</span>
<span className="text-muted">auth_token</span>
<span className="font-mono text-fg">
{server.has_auth_token ? "●●●● (已配置)" : "未配置"}
</span>
<span className="text-muted">command</span>
<span className="font-mono text-fg">{server.command ?? "—"}</span>
<span className="text-muted">args</span>
<code className="font-mono text-fg break-all whitespace-pre-wrap">
{server.args_json ?? "—"}
</code>
<span className="text-muted">transport</span>
<span className="font-mono text-fg">{server.transport}</span>
<span className="text-muted">状态</span>
<span
className={cn(
"font-mono",
isEnabled ? "text-patina" : "text-cinnabar",
)}
>
{isEnabled ? "已启用" : "已禁用"}
</span>
<span className="text-muted">创建时间</span>
<span className="text-muted">
{server.created_at
? new Date(server.created_at).toLocaleString("zh-CN")
: "—"}
</span>
{lastCheckTime && (
<>
<span className="text-muted">上次检查</span>
<span className="text-muted">{lastCheckTime}</span>
</>
)}
</div>
{/* 工具清单 */}
{toollist.length > 0 && (
<div className="mt-3">
<p className="mb-1.5 text-[10px] text-muted uppercase tracking-wider">
工具清单({toollist.length})
</p>
<div className="flex flex-wrap gap-1.5">
{toollist.map((t) => (
<span
key={t.name ?? "?"}
className="inline-block rounded-md border border-border-glass bg-glass/50 px-2 py-0.5 font-mono text-[10px] text-fg/80"
>
{t.name ?? "?"}
</span>
))}
</div>
</div>
)}
{procState === "error" && procStatus && (
<p className="mt-2 rounded-lg bg-cinnabar/10 px-3 py-1.5 text-xs text-cinnabar">
⚠ 进程异常,exit_code 非零或被信号终止
</p>
)}
</div>
)}
</div>
);
});
@@ -0,0 +1,440 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { McpServerCard } from "./McpServerCard";
import { EditMcpDrawer } from "./EditMcpDrawer";
import { RegisterMcpDrawer } from "./RegisterMcpDrawer";
import { mcpApi } from "@/lib/api";
import { cn } from "@/lib/utils/cn";
import type { McpServer, McpServerProcessStatus } from "@/types/api";
type StatusFilter = "all" | "running" | "stopped" | "error";
interface Props {
initialServers: McpServer[];
}
/* ─── 统计数据卡片 ─── */
function StatCard({
label,
count,
active,
color,
onClick,
}: {
label: string;
count: number;
active: boolean;
color: string;
onClick: () => void;
}) {
const dotClass = {
jade: "bg-patina",
muted: "bg-muted",
cinnabar: "bg-cinnabar",
}[color];
return (
<button
type="button"
onClick={onClick}
className={cn(
"glass rounded-2xl px-4 py-3 text-left transition-all",
active && "ring-1",
active && color === "jade" && "ring-patina/30",
active && color === "muted" && "ring-muted/20",
active && color === "cinnabar" && "ring-cinnabar/30",
)}
>
<span
className={cn(
"mb-1 inline-block h-2 w-2 rounded-full",
dotClass,
color === "jade" &&
active &&
"animate-pulse shadow-[0_0_6px] shadow-patina/40",
)}
/>
<p className={cn("text-2xl font-semibold", `text-${color}`)}>{count}</p>
<p className="text-xs text-muted">{label}</p>
</button>
);
}
export function McpServerPanel({ initialServers }: Props) {
// data
const [servers, setServers] = useState(initialServers);
const [procStatuses, setProcStatuses] = useState<
Record<string, McpServerProcessStatus>
>({});
// UI state
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [transportFilter, setTransportFilter] = useState<("http" | "stdio")[]>(
[],
);
const [searchQuery, setSearchQuery] = useState("");
const [expandedCard, setExpandedCard] = useState<string | null>(null);
// action state
const [busy, setBusy] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [lastCheckTime, setLastCheckTime] = useState<Record<string, string>>(
{},
);
const [justCheckedId, setJustCheckedId] = useState<string | null>(null);
// drawer
const [drawerOpen, setDrawerOpen] = useState(false);
const [editServer, setEditServer] = useState<McpServer | null>(null);
// confirm delete
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
// 初始拉取 + 定期刷新进程状态
useEffect(() => {
if (servers.length === 0) return;
async function poll() {
const results = await Promise.allSettled(
servers.map((s) =>
mcpApi.getMcpServerStatus(s.id).then((st) => ({ id: s.id, st })),
),
);
const map: Record<string, McpServerProcessStatus> = {};
for (const r of results) {
if (r.status === "fulfilled") {
map[r.value.id] = r.value.st;
}
}
setProcStatuses((prev) => ({ ...prev, ...map }));
}
poll();
const id = setInterval(poll, 10_000);
return () => clearInterval(id);
}, [servers]);
/* ─── 筛选 ─── */
const filteredServers = useMemo(() => {
return servers.filter((s) => {
// 状态筛选
if (statusFilter !== "all") {
const st = procStatuses[s.id]?.status;
if (st !== statusFilter) return false;
}
// 传输协议筛选
if (transportFilter.length > 0) {
const isHttp = s.transport === "streamable-http";
const isStdio = s.transport === "stdio";
const matchHttp = transportFilter.includes("http") && isHttp;
const matchStdio = transportFilter.includes("stdio") && isStdio;
if (!matchHttp && !matchStdio) return false;
}
// 搜索筛选
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
if (!s.name.toLowerCase().includes(q)) return false;
}
return true;
});
}, [servers, procStatuses, statusFilter, transportFilter, searchQuery]);
/* ─── 统计 ─── */
const stats = useMemo(() => {
const enabledServers = servers.filter((s) => s.status === "enabled");
let running = 0,
stopped = 0,
error = 0;
for (const s of enabledServers) {
const st = procStatuses[s.id]?.status;
if (st === "running") running++;
else if (st === "error") error++;
else stopped++;
}
return { running, stopped, error };
}, [servers, procStatuses]);
/* ─── 操作 ─── */
async function handleHealthCheck(s: McpServer) {
setBusy(true);
setBusyId(s.id);
setError(null);
setJustCheckedId(null);
try {
// 同时刷新进程状态 + 重新拉取工具清单
const [st, updated] = await Promise.all([
mcpApi.getMcpServerStatus(s.id),
mcpApi.refreshMcpServerTools(s.id),
]);
setProcStatuses((prev) => ({ ...prev, [s.id]: st }));
setServers((prev) => prev.map((x) => (x.id === s.id ? updated : x)));
const now = new Date().toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
setLastCheckTime((prev) => ({ ...prev, [s.id]: now }));
setJustCheckedId(s.id);
setTimeout(
() => setJustCheckedId((prev) => (prev === s.id ? null : prev)),
2000,
);
} catch (e) {
setError(e instanceof Error ? e.message : "刷新失败。");
} finally {
setBusy(false);
setBusyId(null);
}
}
async function handleToggleEnabled(s: McpServer) {
setBusy(true);
setBusyId(s.id);
setError(null);
setConfirmDelete(null);
const enabling = s.status !== "enabled";
const isStdio = s.transport === "stdio";
try {
if (enabling) {
// 启用:先调 status(拉取工具并注册到运行时),再切 enable
if (isStdio) {
await mcpApi.startMcpServer(s.id);
}
const updated = await mcpApi.updateMcpServer(s.id, {
status: "enabled",
});
// 重新拉取进程状态
const st = await mcpApi.getMcpServerStatus(s.id).catch(() => null);
setServers((prev) => prev.map((x) => (x.id === s.id ? updated : x)));
if (st) setProcStatuses((prev) => ({ ...prev, [s.id]: st }));
} else {
// 禁用:先 stop 进程,再切 disable
if (isStdio) {
await mcpApi.stopMcpServer(s.id).catch(() => null);
}
const updated = await mcpApi.updateMcpServer(s.id, {
status: "disabled",
});
const st = await mcpApi.getMcpServerStatus(s.id).catch(() => null);
setServers((prev) => prev.map((x) => (x.id === s.id ? updated : x)));
if (st) setProcStatuses((prev) => ({ ...prev, [s.id]: st }));
}
} catch (e) {
setError(e instanceof Error ? e.message : "操作失败。");
} finally {
setBusy(false);
setBusyId(null);
}
}
async function handleDelete(s: McpServer) {
setBusy(true);
setBusyId(s.id);
setError(null);
setConfirmDelete(null);
try {
await mcpApi.deleteMcpServer(s.id);
setServers((prev) => prev.filter((x) => x.id !== s.id));
setProcStatuses((prev) => {
const next = { ...prev };
delete next[s.id];
return next;
});
if (expandedCard === s.id) setExpandedCard(null);
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败。");
} finally {
setBusy(false);
setBusyId(null);
}
}
function handleCreated(newServer: McpServer) {
setServers((prev) => [newServer, ...prev]);
// 立即拉取进程状态
void mcpApi.getMcpServerStatus(newServer.id).then((st) => {
setProcStatuses((prev) => ({ ...prev, [newServer.id]: st }));
});
}
function toggleTransportFilter(t: "http" | "stdio") {
setTransportFilter((prev) =>
prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t],
);
}
/* ─── 渲染 ─── */
return (
<div className="space-y-4">
{/* 运维计数 */}
<div className="grid gap-3 sm:grid-cols-3">
<StatCard
label="运行中"
count={stats.running}
active={statusFilter === "running"}
color="jade"
onClick={() =>
setStatusFilter(statusFilter === "running" ? "all" : "running")
}
/>
<StatCard
label="已停止"
count={stats.stopped}
active={statusFilter === "stopped"}
color="muted"
onClick={() =>
setStatusFilter(statusFilter === "stopped" ? "all" : "stopped")
}
/>
<StatCard
label="异常"
count={stats.error}
active={statusFilter === "error"}
color="cinnabar"
onClick={() =>
setStatusFilter(statusFilter === "error" ? "all" : "error")
}
/>
</div>
{/* 筛选 + 注册入口 */}
<div className="glass flex flex-wrap items-center gap-3 rounded-2xl px-4 py-3">
{/* 搜索 */}
<div className="relative min-w-0 flex-1">
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索外器名称…"
className="w-full rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-sm text-fg outline-none focus:border-patina/40"
/>
</div>
{/* 传输协议筛选项 */}
<label className="flex items-center gap-1.5 text-xs text-muted">
<input
type="checkbox"
checked={transportFilter.includes("http")}
onChange={() => toggleTransportFilter("http")}
className="accent-[var(--patina)]"
/>
HTTP
</label>
<label className="flex items-center gap-1.5 text-xs text-muted">
<input
type="checkbox"
checked={transportFilter.includes("stdio")}
onChange={() => toggleTransportFilter("stdio")}
className="accent-[var(--gold)]"
/>
STDIO
</label>
<span className="text-xs text-muted/50">|</span>
<span className="text-xs text-muted">{servers.length} 个外器</span>
<button
type="button"
onClick={() => setDrawerOpen(true)}
className="ml-auto rounded-xl border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 注册外器
</button>
</div>
{/* 错误提示 */}
{error && (
<div className="rounded-xl bg-cinnabar/10 px-4 py-2.5 text-sm text-cinnabar">
{error}
</div>
)}
{/* 删除确认 */}
{confirmDelete && (
<div className="glass rounded-2xl border-cinnabar/20 px-4 py-3">
<p className="mb-2 text-sm text-fg">
确认删除{" "}
<span className="font-mono font-medium">{confirmDelete}</span>
?此操作不可撤销。
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
const s = servers.find((x) => x.name === confirmDelete);
if (s) void handleDelete(s);
}}
disabled={busy}
className="rounded-lg bg-cinnabar/20 px-3 py-1.5 text-xs font-medium text-cinnabar disabled:opacity-50"
>
{busy ? "删除中…" : "确认删除"}
</button>
<button
type="button"
onClick={() => setConfirmDelete(null)}
className="rounded-lg border border-border-glass bg-glass px-3 py-1.5 text-xs text-muted"
>
取消
</button>
</div>
</div>
)}
{/* 卡片列表 */}
{filteredServers.length === 0 ? (
<div className="glass flex flex-col items-center justify-center rounded-2xl py-12">
<p className="text-sm text-muted">
{servers.length === 0
? "暂无已注册的外器。点击「+ 注册外器」添加。"
: "没有匹配的外器。"}
</p>
</div>
) : (
<div className="space-y-2">
{filteredServers.map((s) => (
<McpServerCard
key={s.id}
server={s}
procStatus={procStatuses[s.id] ?? null}
expanded={expandedCard === s.id}
busy={busy}
busyId={busyId}
lastCheckTime={lastCheckTime[s.id] ?? null}
justChecked={justCheckedId === s.id}
onToggleExpand={() =>
setExpandedCard(expandedCard === s.id ? null : s.id)
}
onHealthCheck={() => void handleHealthCheck(s)}
onToggleEnabled={() => void handleToggleEnabled(s)}
onEdit={() => setEditServer(s)}
onDelete={() => setConfirmDelete(s.name)}
/>
))}
</div>
)}
{/* 注册抽屉 */}
<RegisterMcpDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
onCreated={handleCreated}
/>
{/* 编辑抽屉 */}
{editServer && (
<EditMcpDrawer
server={editServer}
open
onClose={() => setEditServer(null)}
onUpdated={(updated) =>
setServers((prev) =>
prev.map((x) => (x.id === updated.id ? updated : x)),
)
}
/>
)}
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
import { cn } from "@/lib/utils/cn";
/* ─── 状态灯 ─── */
export function StatusDot({
status,
pulse,
}: {
status: "running" | "stopped" | "error";
pulse?: boolean;
}) {
const color = {
running: "bg-patina shadow-[0_0_6px] shadow-patina/40",
stopped: "bg-muted/50",
error: "bg-cinnabar shadow-[0_0_6px] shadow-cinnabar/40",
}[status];
return (
<span
className={cn(
"inline-block h-2.5 w-2.5 shrink-0 rounded-full",
color,
pulse === true && status === "running" && "animate-pulse",
)}
aria-label={status}
/>
);
}
/* ─── 传输协议徽章 ─── */
export function TransportBadge({ transport }: { transport: string }) {
const isHttp = transport === "streamable-http";
return (
<span
className={cn(
"rounded-md px-1.5 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-wider",
isHttp ? "bg-patina/12 text-patina" : "bg-gold/12 text-gold",
)}
>
{isHttp ? "HTTP" : "STDIO"}
</span>
);
}
@@ -0,0 +1,442 @@
"use client";
import { useState } from "react";
import { mcpApi } from "@/lib/api";
import { cn } from "@/lib/utils/cn";
import type { McpServer } from "@/types/api";
type TransportMode = "streamable-http" | "stdio";
type Step = 1 | 2 | 3;
interface FormData {
name: string;
transport: TransportMode;
endpoint_url: string;
auth_token: string;
command: string;
args_json: string;
env_json: string;
cwd: string;
}
const EMPTY_FORM: FormData = {
name: "",
transport: "streamable-http",
endpoint_url: "",
auth_token: "",
command: "",
args_json: "",
env_json: "",
cwd: "",
};
interface Props {
open: boolean;
onClose: () => void;
onCreated: (server: McpServer) => void;
}
const STEP_TITLES = ["择道", "传书", "披阅"];
export function RegisterMcpDrawer({ open, onClose, onCreated }: Props) {
const [step, setStep] = useState<Step>(1);
const [form, setForm] = useState<FormData>(EMPTY_FORM);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<McpServer | null>(null);
function reset() {
setStep(1);
setForm(EMPTY_FORM);
setBusy(false);
setError(null);
setResult(null);
}
function close() {
reset();
onClose();
}
function setTransport(t: TransportMode) {
setForm((f) => ({ ...f, transport: t }));
}
function setField<K extends keyof FormData>(key: K, value: FormData[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
function goNext() {
setError(null);
if (step === 1) {
// validate transport step
setStep(2);
} else if (step === 2) {
// validate fields
if (!form.name.trim()) {
setError("请填写名称。");
return;
}
if (form.transport === "streamable-http" && !form.endpoint_url.trim()) {
setError("请填写 endpoint URL。");
return;
}
if (form.transport === "stdio" && !form.command.trim()) {
setError("请填写命令。");
return;
}
setStep(3);
} else if (step === 3) {
void submit();
}
}
function goBack() {
setError(null);
if (step > 1) setStep((s) => (s - 1) as Step);
}
async function submit() {
setBusy(true);
setError(null);
try {
const created = await mcpApi.registerMcpServer({
name: form.name.trim(),
transport: form.transport,
endpoint_url: form.endpoint_url.trim() || undefined,
auth_token: form.auth_token.trim() || undefined,
command: form.command.trim() || undefined,
args_json: form.args_json.trim() || undefined,
env_json: form.env_json.trim() || undefined,
cwd: form.cwd.trim() || undefined,
});
setResult(created);
// brief pause to show success before closing
setTimeout(() => {
onCreated(created);
close();
}, 800);
} catch (e) {
setError(e instanceof Error ? e.message : "注册失败。");
} finally {
setBusy(false);
}
}
function transportOption(t: TransportMode, label: string, desc: string) {
const isStdio = t === "stdio";
return (
<button
type="button"
onClick={() => setTransport(t)}
className={cn(
"w-full rounded-xl border p-3 text-left transition-colors",
form.transport === t
? "border-patina/40 bg-patina/10"
: "border-border-glass bg-glass hover:bg-glass-strong",
)}
>
<span
className={cn(
"text-sm font-medium",
form.transport === t ? "text-patina" : "text-fg",
)}
>
{form.transport === t && !isStdio && "◉ "}
{form.transport !== t && !isStdio && "○ "}
{form.transport === t && isStdio && "◉ "}
{form.transport !== t && isStdio && "○ "}
{label}
</span>
<p className="mt-0.5 text-xs text-muted">{desc}</p>
</button>
);
}
function inputField(
key: keyof FormData,
label: string,
placeholder: string,
opts?: {
type?: string;
mono?: boolean;
required?: boolean;
rows?: number;
},
) {
const val = form[key] as string;
const Tag = opts?.rows ? "textarea" : "input";
return (
<label className="block">
<span className="mb-1 block text-xs text-muted">
{label}
{opts?.required && <span className="ml-0.5 text-cinnabar">*</span>}
</span>
<Tag
value={val}
onChange={(
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => setField(key, e.target.value)}
placeholder={placeholder}
rows={opts?.rows}
type={opts?.type ?? "text"}
className={cn(
"w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina/40",
opts?.mono && "font-mono",
)}
/>
</label>
);
}
if (!open) return null;
return (
<>
{/* 遮罩 */}
<div
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
onClick={close}
/>
{/* 抽屉 */}
<div className="glass-strong fixed right-0 top-0 z-50 flex h-full w-[520px] max-w-[90vw] flex-col shadow-2xl">
{/* 头部 */}
<div className="flex items-center justify-between border-b border-border-glass px-6 py-4">
<h2 className="font-serif text-base font-semibold text-fg">
新藩来朝
</h2>
<button
type="button"
onClick={close}
className="rounded-lg px-2 py-1 text-sm text-muted hover:text-fg"
>
✕
</button>
</div>
{/* 步骤指示器 */}
<div className="flex items-center gap-2 border-b border-border-glass px-6 py-3">
{([1, 2, 3] as const).map((s) => (
<div key={s} className="flex items-center gap-2">
<span
className={cn(
"flex h-6 w-6 items-center justify-center rounded-full text-[11px] font-semibold",
step === s
? "bg-patina text-white"
: step > s
? "bg-patina/20 text-patina"
: "bg-glass text-muted",
)}
>
{step > s ? "✓" : s}
</span>
<span
className={cn(
"text-xs",
step === s ? "text-fg font-medium" : "text-muted",
)}
>
{STEP_TITLES[s - 1]}
</span>
{s < 3 && <span className="text-muted/30">→</span>}
</div>
))}
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto px-6 py-5">
{step === 1 && (
<div className="space-y-3">
<p className="text-sm text-muted">选择 MCP server 的传输协议:</p>
{transportOption(
"streamable-http",
"Streamable HTTP",
"远程服务,通过 endpoint URL 接入。需提供网络可达地址,后端通过 HTTP 长轮询通信。",
)}
{transportOption(
"stdio",
"STDIO 子进程",
"本地执行命令,通过 stdin/stdout 通信。需提供可执行文件路径,如 npx / uvx / docker。",
)}
</div>
)}
{step === 2 && (
<div className="space-y-3">
{inputField("name", "藩号(名称)", "如 brave-search", {
mono: true,
required: true,
})}
{form.transport === "streamable-http" && (
<>
{inputField(
"endpoint_url",
"驿道(endpoint URL)",
"https://...",
{
mono: true,
required: true,
},
)}
{inputField(
"auth_token",
"信符(auth token)",
"可选,仅提交不回显",
{
type: "password",
mono: true,
},
)}
</>
)}
{form.transport === "stdio" && (
<>
{inputField("command", "敕令(命令)", "如 npx", {
mono: true,
required: true,
})}
{inputField(
"args_json",
"传符(参数,JSON Array)",
'["-y","@modelcontextprotocol/server-filesystem"]',
{ mono: true, rows: 2 },
)}
{inputField(
"env_json",
"行囊(环境变量,JSON Dict)",
'{ "API_KEY": "..." }',
{ mono: true, rows: 2 },
)}
{inputField("cwd", "起点(工作目录)", "/data/mcp/", {
mono: true,
})}
</>
)}
</div>
)}
{step === 3 && (
<div className="space-y-3">
<p className="text-sm text-muted">
请确认以下信息,确认后将发起注册并拉取工具清单:
</p>
<div className="glass rounded-xl p-4">
<div className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<span className="text-muted">藩号</span>
<span className="font-medium text-fg">{form.name}</span>
<span className="text-muted">驿道</span>
<span className="font-mono text-fg uppercase">
{form.transport === "streamable-http" ? "HTTP" : "STDIO"}
</span>
{form.transport === "streamable-http" && (
<>
<span className="text-muted">endpoint</span>
<span className="font-mono text-fg break-all">
{form.endpoint_url}
</span>
<span className="text-muted">auth_token</span>
<span className="font-mono text-fg">
{form.auth_token ? "●●●● (已配置)" : "未配置"}
</span>
</>
)}
{form.transport === "stdio" && (
<>
<span className="text-muted">command</span>
<code className="font-mono text-fg break-all">
{form.command}{" "}
{form.args_json
? (() => {
try {
return JSON.parse(form.args_json).join(" ");
} catch {
return form.args_json;
}
})()
: ""}
</code>
{form.env_json && (
<>
<span className="text-muted">env</span>
<span className="font-mono text-fg">
{(() => {
try {
const e = JSON.parse(form.env_json);
const keys = Object.keys(e);
return `含 ${keys.length} 个变量`;
} catch {
return form.env_json;
}
})()}
</span>
</>
)}
{form.cwd && (
<>
<span className="text-muted">cwd</span>
<span className="font-mono text-fg">{form.cwd}</span>
</>
)}
</>
)}
</div>
</div>
{busy && (
<div className="glass flex items-center gap-2 rounded-xl px-4 py-3 text-sm text-muted">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-patina border-t-transparent" />
注册中,正在拉取工具清单…
</div>
)}
{result && (
<div className="rounded-xl bg-patina/10 px-4 py-3 text-sm text-patina">
✓ 注册成功,发现 {result.tools.length} 个工具
</div>
)}
</div>
)}
{error && <p className="mt-3 text-xs text-cinnabar">{error}</p>}
</div>
{/* 底部操作区 */}
<div className="flex items-center justify-between border-t border-border-glass px-6 py-4">
<button
type="button"
onClick={step > 1 ? goBack : close}
disabled={busy}
className="rounded-lg px-3 py-2 text-sm text-muted transition-colors hover:text-fg disabled:opacity-40"
>
{step === 1 ? "取消" : "上一步"}
</button>
<button
type="button"
onClick={goNext}
disabled={busy}
className={cn(
"rounded-xl px-4 py-2 text-sm font-medium transition-colors disabled:opacity-40",
step === 3
? "bg-patina text-white hover:bg-patina/90"
: "bg-patina/15 text-patina hover:bg-patina/25",
)}
>
{step < 3
? "继续 →"
: result
? "✓ 完成"
: busy
? "注册中…"
: "确认注册"}
</button>
</div>
</div>
</>
);
}
@@ -0,0 +1,88 @@
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils/cn";
export interface Tab {
key: string;
label: string;
badge?: string | number;
content: ReactNode;
}
/** 内部组件:使用 useSearchParams(需 Suspense 边界) */
function ToolRuntimeTabsInner({
tabs,
defaultTab,
}: {
tabs: Tab[];
defaultTab: string;
}) {
const router = useRouter();
const searchParams = useSearchParams();
const activeTab = searchParams.get("tab") ?? defaultTab;
const current = tabs.find((t) => t.key === activeTab) ?? tabs[0];
return (
<div>
<div className="mb-6 flex items-center gap-1 border-b border-border-glass">
{tabs.map((tab) => {
const isActive = tab.key === current.key;
return (
<button
key={tab.key}
type="button"
onClick={() =>
router.replace(`?tab=${tab.key}`, { scroll: false })
}
className={cn(
"relative inline-flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors",
isActive ? "text-patina" : "text-muted hover:text-fg",
)}
>
{tab.label}
{tab.badge !== undefined && (
<span
className={cn(
"rounded-full px-1.5 py-0.5 text-[10px]",
isActive
? "bg-patina/15 text-patina"
: "bg-glass text-muted",
)}
>
{tab.badge}
</span>
)}
{isActive && (
<span className="absolute bottom-0 left-2 right-2 h-0.5 rounded-full bg-patina" />
)}
</button>
);
})}
</div>
<div className="min-h-0">{current.content}</div>
</div>
);
}
/**
* 百工枢标签页(URL 驱动,?tab=tools / ?tab=mcp / ?tab=skills)。
* 自动包裹 Suspense 边界。
*/
export function ToolRuntimeTabs({
tabs,
defaultTab = "tools",
}: {
tabs: Tab[];
defaultTab?: string;
}) {
return (
<Suspense fallback={null}>
<ToolRuntimeTabsInner tabs={tabs} defaultTab={defaultTab} />
</Suspense>
);
}
@@ -0,0 +1,436 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { useChatStore } from "@/lib/stores/chat-store";
import { cn } from "@/lib/utils/cn";
import { listRuns } from "@/lib/api/runs";
import type { Run } from "@/types/api";
import type { ArchiveMonth, ArchiveScroll } from "@/types/archive";
import {
computeArchiveStats,
groupEventsByMonth,
monthsToScrolls,
runsToArchiveEvents,
} from "@/types/archive";
import { ApiError } from "@/lib/api/client";
/* ─── Type colours ───────────────────────────────────── */
const ARCHIVE_TYPE_LABEL: Record<string, string> = {
episodic: "情景",
emotional: "情绪",
evolution: "演化",
reflection: "反思",
milestone: "里程碑",
};
const TYPE_DOT_CLASS: Record<string, string> = {
episodic: "border-gilt",
emotional: "border-cinnabar",
evolution: "border-cinnabar bg-cinnabar",
reflection: "border-gilt bg-gilt",
};
/* ─── Filter options ──────────────────────────────────── */
const FILTER_OPTIONS = [
{ key: "all", label: "全部" },
{ key: "episodic", label: "情景" },
{ key: "emotional", label: "情绪" },
{ key: "evolution", label: "演化" },
{ key: "reflection", label: "反思" },
{ key: "milestone", label: "里程碑" },
] as const;
type FilterKey = (typeof FILTER_OPTIONS)[number]["key"];
/* ─── Component ──────────────────────────────────────── */
export function TimeRiver() {
const [activeFilter, setActiveFilter] = useState<FilterKey>("all");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [runs, setRuns] = useState<Run[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [retryKey, setRetryKey] = useState(0);
const characterId = useChatStore((s) => s.characterId);
/** 加载 runs 数据 */
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError(null);
try {
const params: Record<string, unknown> = { page: 1, page_size: 100 };
if (characterId) params.character_id = characterId;
const result = await listRuns(params);
if (!cancelled) setRuns(result.items ?? []);
} catch (err) {
if (!cancelled) {
if (err instanceof ApiError) {
setError(`加载失败:${err.message}`);
} else {
setError("加载失败,请稍后重试");
}
}
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [characterId, retryKey]);
/** 从 runs 数据派生 UI 展示结构 */
const months: ArchiveMonth[] = useMemo(() => {
const events = runsToArchiveEvents(runs);
return groupEventsByMonth(events);
}, [runs]);
/** 当前筛选后的月份 */
const filteredMonths = useMemo(
() =>
months
.map((month) => ({
...month,
events: month.events.filter((evt) => {
if (activeFilter === "all") return true;
if (activeFilter === "milestone") return !!evt.milestone;
return evt.type === activeFilter;
}),
}))
.filter((m) => m.events.length > 0),
[months, activeFilter],
);
/** 统计数据 */
const stats = useMemo(() => computeArchiveStats(months), [months]);
/** 卷宗目录 */
const scrolls: ArchiveScroll[] = useMemo(
() => monthsToScrolls(months),
[months],
);
/* ── 渲染 ── */
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<span className="font-mono text-sm text-fg-faint">
正在加载归档数据...
</span>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center gap-3 py-20">
<span className="text-sm text-fg-faint">{error}</span>
<button
type="button"
onClick={() => setRetryKey((k) => k + 1)}
className="rounded-md border border-patina-dim bg-patina-wash px-4 py-1.5 text-xs text-patina transition-colors hover:bg-patina hover:text-ink"
>
重试
</button>
</div>
);
}
return (
<div className="space-y-5">
{/* ── Filter bar ── */}
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full border border-gilt-dim bg-gilt-faint px-3.5 py-1 font-serif text-sm text-fg">
丙午年 · 2026
</span>
{FILTER_OPTIONS.map((opt) => (
<button
key={opt.key}
type="button"
onClick={() => setActiveFilter(opt.key)}
className={cn(
"rounded-full border px-3 py-1 text-xs transition-colors",
activeFilter === opt.key
? "border-patina-dim bg-patina-wash text-patina"
: "border-rule bg-surface text-fg-dim hover:text-fg",
)}
>
{opt.label}
</button>
))}
</div>
{/* ── Stat grid ── */}
<div className="grid grid-cols-1 gap-3 min-[480px]:grid-cols-2 lg:grid-cols-4">
<StatCard
k="归档卷宗"
v={String(stats.totalMonths)}
unit="卷"
sub="按月分卷 · 铜金装帧"
/>
<StatCard
k="归档事件"
v={String(stats.totalEvents)}
unit="条"
sub={
stats.firstEventTime
? `自 ${stats.firstEventTime.slice(0, 10)}`
: "暂无数据"
}
/>
<StatCard
k="最早"
v={stats.firstEventTime ? stats.firstEventTime.slice(0, 10) : "—"}
sub="相识之日 · 缘起"
/>
<StatCard
k="最近归档"
v={stats.lastEventTime ? stats.lastEventTime.slice(0, 10) : "—"}
sub={stats.lastEventTime ? "近期" : "暂无数据"}
/>
</div>
{/* ── River region ── */}
<div className="flex flex-col items-start gap-6 xl:flex-row">
{/* ── Main: Timeline ── */}
<div className="min-w-0 flex-1">
<div className="mb-3 flex items-center gap-1.5 font-mono text-xs text-fg-faint">
<span className="text-gilt/80">&lt;</span> 时间河
<span className="text-fg-dim/75">记忆沿时序铺展 · 铜金分章</span>
</div>
<div className="relative pl-8">
{/* vertical axis */}
<div
className="absolute bottom-1.5 left-[9px] top-1.5 w-px"
style={{
background:
"linear-gradient(180deg, transparent, var(--gilt-dim) 6%, var(--gilt-dim) 94%, transparent)",
}}
/>
{filteredMonths.length === 0 ? (
<p className="py-6 text-center text-sm text-fg-faint">
当前筛选条件下没有事件。
</p>
) : (
filteredMonths.map((month) => (
<div
key={`${month.year}-${month.month}`}
className="mb-6 last:mb-0"
>
{/* month header */}
<div className="-ml-8 mb-3.5 flex items-center gap-3.5">
<div className="h-0 flex-1 border-t border-gilt-dim" />
<span className="whitespace-nowrap font-serif text-[15px] font-semibold tracking-wide text-gilt">
{month.label}
<small className="ml-1.5 font-mono text-[10px] font-normal text-fg-faint">
{month.year}-{String(month.month).padStart(2, "0")}
</small>
</span>
<div className="h-0 flex-1 border-t border-gilt-dim" />
</div>
{/* events */}
{month.events.map((evt) => (
<div key={evt.id}>
<div
role="button"
tabIndex={0}
onClick={() =>
setExpandedId(expandedId === evt.id ? null : evt.id)
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ")
setExpandedId(
expandedId === evt.id ? null : evt.id,
);
}}
className={cn(
"relative mb-2 cursor-pointer rounded-lg border bg-surface p-3 transition-colors last:mb-0 hover:border-gilt-dim",
expandedId === evt.id
? "border-gilt-dim"
: "border-rule",
)}
>
{/* dot node */}
<div
className={cn(
"absolute left-[-25px] top-4 h-2.5 w-2.5 rounded-full border-2 bg-ink",
TYPE_DOT_CLASS[evt.type] ?? "border-patina",
)}
/>
<div className="font-mono text-[10.5px] text-fg-faint">
{evt.timeLabel}
</div>
<div className="mt-0.5 text-sm font-semibold text-fg">
{evt.title}
</div>
<div className="mt-1 text-xs leading-relaxed text-fg-dim">
{evt.desc}
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{evt.tags.map((tag) => (
<span
key={tag}
className={cn(
"rounded border px-1.5 py-0.5 font-mono text-[9.5px]",
tag === evt.type
? "border-patina-dim text-patina"
: tag === "milestone"
? "border-gilt-dim bg-gilt-faint text-gilt"
: "border-rule text-fg-faint",
)}
>
{tag}
</span>
))}
</div>
</div>
{/* expanded detail card */}
<AnimatePresence>
{expandedId === evt.id && (
<motion.div
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.2 }}
>
<div className="mb-2 rounded-lg border border-gilt-dim/40 bg-surface-hi/50 p-4">
<div className="mb-2 flex items-center gap-2">
<span className="font-serif text-sm font-semibold text-fg">
{evt.title}
</span>
<span className="rounded border border-patina-dim px-1.5 py-0.5 font-mono text-[9.5px] text-patina">
{ARCHIVE_TYPE_LABEL[evt.type] ?? evt.type}
</span>
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-fg">
{evt.content}
</p>
<div className="mt-3 flex items-center gap-3 font-mono text-[10.5px] text-fg-faint">
<span>{evt.createdAt}</span>
<span>·</span>
<span>来源: {evt.source}</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
))
)}
</div>
</div>
{/* ── Right rail ── */}
<div className="flex w-full shrink-0 flex-col gap-3.5 xl:w-[270px]">
{/* volume directory */}
<div className="rounded-lg border border-rule bg-surface p-4">
<h3 className="mb-3 font-serif text-sm font-semibold text-fg">
卷宗目录
</h3>
<div className="flex flex-col gap-2">
{scrolls.length === 0 ? (
<p className="py-2 text-center text-xs text-fg-faint">
暂无卷宗
</p>
) : (
scrolls.map((scroll) => (
<div
key={scroll.seal}
className="flex cursor-pointer items-center gap-2.5 rounded-md border border-rule bg-ink p-2.5 transition-colors hover:border-gilt-dim"
>
<span className="w-6 shrink-0 text-center font-serif text-sm text-gilt">
{scroll.seal}
</span>
<div className="min-w-0 flex-1">
<div className="text-xs text-fg">{scroll.name}</div>
<div className="mt-0.5 font-mono text-[10px] text-fg-faint">
{scroll.meta}
</div>
</div>
<span className="font-mono text-[11px] text-patina">
{scroll.count}
</span>
</div>
))
)}
</div>
</div>
{/* export */}
<div className="rounded-lg border border-rule bg-surface p-4">
<h3 className="mb-3 font-serif text-sm font-semibold text-fg">
导出卷宗
</h3>
<div className="flex flex-col gap-2">
{[
{ label: "当月归档", fmt: ".md" },
{ label: "全量时间河", fmt: ".json" },
{ label: "里程碑集", fmt: ".md" },
].map((item) => (
<button
key={item.label}
type="button"
className="flex cursor-pointer items-center justify-between rounded-md border border-rule bg-ink px-3 py-2 text-xs text-fg-dim transition-colors hover:border-gilt-dim hover:text-fg"
>
<span>{item.label}</span>
<span className="font-mono text-[10px] text-gilt">
{item.fmt}
</span>
</button>
))}
</div>
<p className="mt-3 text-[11px] leading-relaxed text-fg-faint">
导出不含高敏感明文,仅摘要与结构化结果。
</p>
</div>
</div>
</div>
</div>
);
}
/* ─── Stat card ──────────────────────────────────────── */
function StatCard({
k,
v,
unit,
sub,
}: {
k: string;
v: string;
unit?: string;
sub: string;
}) {
return (
<div className="flex flex-col gap-1 rounded-lg border border-rule bg-surface p-3.5">
<div className="font-mono text-[10.5px] uppercase tracking-widest text-fg-faint">
{k}
</div>
<div className="font-serif text-[22px] font-semibold text-fg">
{v}
{unit && (
<small className="ml-1 font-mono text-[11px] font-normal text-fg-dim">
{unit}
</small>
)}
</div>
<div className="text-[11.5px] text-fg-faint">{sub}</div>
</div>
);
}
@@ -0,0 +1,158 @@
"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 { extractionApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import { formatDateTime } from "@/lib/utils/format";
import type { MemoryCandidate, WorldEntity } from "@/types/api";
/**
* 候选审核面板(跨来源统一入口)。
* 记忆候选(cycle 大周天 / import 资料导入)与世界实体候选逐条 approve/reject。
* 审核后 router.refresh()。与 ImportWizard 的批量审核互补——此处为跨来源统一待审队列。
*/
export function CandidateReviewPanel({
memoryCandidates,
worldProposals,
}: {
memoryCandidates: MemoryCandidate[];
worldProposals: WorldEntity[];
}) {
const router = useRouter();
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function reviewMemory(id: string, approve: boolean) {
setBusyId(id);
setError(null);
try {
if (approve) await extractionApi.approveMemoryCandidate(id);
else await extractionApi.rejectMemoryCandidate(id);
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusyId(null);
}
}
async function reviewWorld(id: string, approve: boolean) {
setBusyId(id);
setError(null);
try {
if (approve) await extractionApi.approveWorldEntity(id);
else await extractionApi.rejectWorldEntity(id);
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusyId(null);
}
}
return (
<div className="space-y-4">
<Card className="p-5">
<CardHeader
title="记忆候选"
subtitle="来自大周天反思与资料导入的待审记忆,逐条裁定。"
/>
{memoryCandidates.length === 0 ? (
<p className="text-sm text-muted">暂无待审记忆候选。</p>
) : (
<ul className="space-y-2">
{memoryCandidates.map((c) => (
<li key={c.id} className="rounded-lg bg-glass/40 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded bg-gold/15 px-1.5 py-0.5 text-[10px] text-gold">
{c.type}
</span>
<span className="font-serif text-sm font-medium text-fg">
{c.title}
</span>
<span className="text-[10px] text-muted">
{c.import_job_id ? "资料导入" : c.cycle_id ? "大周天" : ""}
{` · ${formatDateTime(c.created_at)}`}
</span>
<span className="ml-auto font-mono text-[10px] text-muted">
{(c.confidence ?? 0).toFixed(2)}
</span>
</div>
<p className="mt-1 text-xs text-fg/80">{c.content}</p>
<div className="mt-1.5 flex gap-2">
<Button
variant="primary"
size="sm"
loading={busyId === c.id}
onClick={() => reviewMemory(c.id, true)}
>
通过
</Button>
<Button
variant="danger"
size="sm"
loading={busyId === c.id}
onClick={() => reviewMemory(c.id, false)}
>
驳回
</Button>
</div>
</li>
))}
</ul>
)}
</Card>
<Card className="p-5">
<CardHeader
title="世界实体候选"
subtitle="待确认的世界实体,通过后进入世界模型(confirmed)。"
/>
{worldProposals.length === 0 ? (
<p className="text-sm text-muted">暂无待审世界实体候选。</p>
) : (
<ul className="space-y-2">
{worldProposals.map((e) => (
<li key={e.id} className="rounded-lg bg-glass/40 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded bg-gold/15 px-1.5 py-0.5 text-[10px] text-gold">
{e.type}
</span>
<span className="font-serif text-sm font-medium text-fg">
{e.name}
</span>
<span className="ml-auto font-mono text-[10px] text-muted">
{e.confidence.toFixed(2)}
</span>
</div>
<div className="mt-1.5 flex gap-2">
<Button
variant="primary"
size="sm"
loading={busyId === e.id}
onClick={() => reviewWorld(e.id, true)}
>
通过
</Button>
<Button
variant="danger"
size="sm"
loading={busyId === e.id}
onClick={() => reviewWorld(e.id, false)}
>
驳回
</Button>
</div>
</li>
))}
</ul>
)}
</Card>
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -0,0 +1,113 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { BronzeMirror } from "@/components/ui/BronzeMirror";
import { describeError } from "@/lib/utils/error";
/**
* 角色头像组件:展示头像图片(优先)或铜镜占位,hover 显示上传按钮。
* 上传后调用 API 更新,成功后 refresh 页面。
*/
export function CharacterAvatar({
characterId,
avatarUrl,
name,
}: {
characterId: string;
avatarUrl?: string | null;
name: string;
}) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
// 客户端校验
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (!allowed.includes(file.type)) {
setError("仅支持 JPG / PNG / WebP 格式");
return;
}
if (file.size > 5 * 1024 * 1024) {
setError("文件不能超过 5MB");
return;
}
setUploading(true);
setError(null);
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(
`/api/proxy/v1/characters/${characterId}/avatar`,
{ method: "POST", body: formData },
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error?.message || `上传失败 (${res.status})`);
}
router.refresh();
} catch (err) {
setError(describeError(err));
} finally {
setUploading(false);
// 重置 input 允许重复选同一文件
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<div className="group relative">
{/* 头像 */}
<div className="relative h-16 w-16 overflow-hidden rounded-full border-2 border-gilt shadow-lg shadow-gilt/20">
{avatarUrl ? (
<Image
src={avatarUrl}
alt={name}
fill
className="object-cover"
sizes="64px"
/>
) : (
<BronzeMirror size={64} state="steady" />
)}
</div>
{/* Hover 上传遮罩 */}
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100 disabled:opacity-0"
title="上传头像"
>
<span className="text-xs font-medium text-white">
{uploading ? "…" : "📷"}
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFile}
/>
{error && (
<p className="absolute left-0 top-full mt-1 whitespace-nowrap text-xs text-cinnabar">
{error}
</p>
)}
</div>
);
}
@@ -0,0 +1,82 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { Card } from "@/components/ui/Card";
import { CharacterStatusBadge } from "@/components/ui/StatusBadge";
import { cn } from "@/lib/utils/cn";
import type { CharacterListItem } from "@/types/api";
/** 人物列表卡片:悬停轻浮,spring 过渡 */
export function CharacterCard({
character,
active = false,
}: {
character: CharacterListItem;
active?: boolean;
}) {
const initial = (character.display_name || character.name).charAt(0);
return (
<motion.div
whileHover={{ y: -4 }}
transition={{ type: "spring", stiffness: 300, damping: 22 }}
>
<Link href={`/characters/${character.id}`} className="block">
<Card
className={cn(
"relative p-5 transition-all duration-200",
active && "ring-2 ring-patina/40",
!active && "hover:border-patina/30",
)}
>
{/* 活跃指示器 */}
{active && (
<span className="absolute right-3 top-3 rounded-full bg-patina/15 px-2 py-0.5 text-[10px] font-medium text-patina">
当前
</span>
)}
{/* 头像 + 名称行 */}
<div className="flex items-center gap-3">
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-sm font-bold text-white",
active ? "bg-patina" : "bg-overlay-hover text-muted",
)}
>
{initial}
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-serif text-base font-bold text-fg">
{character.display_name || character.name}
</h3>
<p className="truncate text-[11px] text-muted">
{character.name !== character.display_name && character.name}
</p>
</div>
<CharacterStatusBadge status={character.status} />
</div>
{/* 详情行 */}
<div className="mt-4 flex items-center gap-4 border-t border-border-glass pt-3 text-xs text-muted">
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-patina/40" />
记忆 {character.memory_count ?? 0}
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-gold/40" />
人格 v{character.personality_version ?? "-"}
</span>
{character.activated_at && (
<span className="ml-auto text-[10px] text-muted/60">
{new Date(character.activated_at).toLocaleDateString("zh-CN")}
</span>
)}
</div>
</Card>
</Link>
</motion.div>
);
}
@@ -0,0 +1,261 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { charactersApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { CharacterTemplateSummary } from "@/types/api";
/**
* 创建人物入口(自包含):触发按钮 + 表单 Modal。
*
* 双模式:
* - 管理员:从后端获取系统模板列表,可选模板快速填充(也可空白创建)。
* - 普通用户:简化表单(仅 name/display_name/background),模板列表 403 时静默降级。
* name 必填。提交带幂等键(crypto.randomUUID,存 ref 防 StrictMode 双调用)。
*/
export function CharacterCreateDialog() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [templates, setTemplates] = useState<CharacterTemplateSummary[] | null>(
null,
);
const [isAdmin, setIsAdmin] = useState(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [name, setName] = useState("");
const [displayName, setDisplayName] = useState("");
const [background, setBackground] = useState("");
const [speakingStyle, setSpeakingStyle] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const idempotencyRef = useRef<string | null>(null);
// 组件挂载时获取模板列表(只取一次)
const fetchedRef = useRef(false);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
charactersApi
.listCharacterTemplates()
.then((data) => {
setTemplates(data);
setIsAdmin(true);
})
.catch(() => {
/* 普通用户:403 静默降级,templates 保持 null */
setTemplates([]);
setIsAdmin(false);
});
}, []);
function openDialog() {
setError(null);
setOpen(true);
}
function close() {
setOpen(false);
setName("");
setDisplayName("");
setBackground("");
setSpeakingStyle("");
setSelectedKey(null);
setError(null);
idempotencyRef.current = null;
}
/** 选中某个模板 → 自动填充字段 */
function selectTemplate(key: string) {
if (!templates) return;
const tpl = templates.find((t) => t.key === key);
if (!tpl) return;
setSelectedKey(key);
setName(tpl.name);
setDisplayName(tpl.display_name ?? "");
setBackground("");
setSpeakingStyle(tpl.speaking_style ?? "");
}
/** 取消模板选择 → 清空填充 */
function clearTemplate() {
setSelectedKey(null);
setName("");
setDisplayName("");
setBackground("");
setSpeakingStyle("");
}
async function submit() {
if (!name.trim()) {
setError("请填写人物内部名称。");
return;
}
if (!idempotencyRef.current) {
idempotencyRef.current = crypto.randomUUID();
}
setBusy(true);
setError(null);
try {
const created = await charactersApi.createCharacter(
{
name: name.trim(),
display_name: displayName.trim() || undefined,
template_key: isAdmin ? (selectedKey ?? undefined) : undefined,
profile: {
background: background.trim() || undefined,
speaking_style: speakingStyle.trim() || undefined,
},
},
idempotencyRef.current,
);
close();
router.push(`/characters/${created.id}`);
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
const inputCls =
"mt-1 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
return (
<>
<Button variant="primary" size="sm" onClick={openDialog}>
创建人物
</Button>
<Modal
open={open}
onClose={close}
title={isAdmin ? "创建人物" : "新建空白人物"}
footer={
<>
<Button variant="ghost" size="sm" onClick={close}>
取消
</Button>
<Button variant="primary" size="sm" loading={busy} onClick={submit}>
创建
</Button>
</>
}
>
<div className="space-y-4">
{/* ── 管理员:模板选择 ── */}
{isAdmin && (
<div>
<label className="text-xs text-muted">
选择模板(可选,快速填充)
</label>
{templates === null ? (
<p className="mt-1 text-xs text-muted">加载模板中…</p>
) : templates.length > 0 ? (
<div className="mt-1 grid grid-cols-2 gap-2">
{templates.map((tpl) => {
const active = selectedKey === tpl.key;
return (
<button
key={tpl.key}
type="button"
onClick={() =>
active ? clearTemplate() : selectTemplate(tpl.key)
}
className={`rounded-lg border p-2 text-left text-xs transition ${
active
? "border-accent-gold bg-accent-gold/10"
: "border-border-glass bg-glass hover:border-accent-gold/50"
}`}
>
<span className="block font-medium text-fg">
{tpl.display_name ?? tpl.name}
</span>
{tpl.temperament && (
<span className="mt-0.5 block text-muted">
{tpl.temperament}
</span>
)}
{tpl.description && (
<span className="mt-0.5 block truncate text-muted/70">
{tpl.description}
</span>
)}
</button>
);
})}
<button
type="button"
onClick={clearTemplate}
className={`rounded-lg border p-2 text-left text-xs transition ${
selectedKey === null
? "border-accent-gold bg-accent-gold/10"
: "border-border-glass bg-glass hover:border-muted/50"
}`}
>
<span className="block font-medium text-fg">空白创建</span>
<span className="mt-0.5 block text-muted">
自定义全部内容
</span>
</button>
</div>
) : (
<p className="mt-1 text-xs text-muted">
未加载到可选模板,将创建空白人物。
</p>
)}
</div>
)}
{/* ── 表单字段 ── */}
{!isAdmin && (
<div className="rounded-xl border border-dashed border-gilt-dim bg-gilt-faint/30 px-3 py-2 text-xs text-gilt">
创建空白人物后,可在详情页完善档案。
</div>
)}
<div>
<label className="text-xs text-muted">
内部名称 <span className="text-cinnabar">*</span>
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="如 jingwu"
className={inputCls}
/>
</div>
<div>
<label className="text-xs text-muted">显示名</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="如 镜梧"
className={inputCls}
/>
</div>
<div>
<label className="text-xs text-muted">背景</label>
<textarea
value={background}
onChange={(e) => setBackground(e.target.value)}
rows={2}
className={inputCls}
/>
</div>
{isAdmin && (
<div>
<label className="text-xs text-muted">表达风格</label>
<input
value={speakingStyle}
onChange={(e) => setSpeakingStyle(e.target.value)}
className={inputCls}
/>
</div>
)}
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
</Modal>
</>
);
}
@@ -0,0 +1,102 @@
import { Card } from "@/components/ui/Card";
import { CharacterStatusBadge } from "@/components/ui/StatusBadge";
import { CharacterAvatar } from "@/components/characters/CharacterAvatar";
import type { Character } from "@/types/api";
import { formatDateTime } from "@/lib/utils/format";
/**
* 角色卡 —— 人物详情页顶部的视觉化身份卡片。
* 展示头像(图片或铜镜占位)、名称、状态、关键档案字段与背景故事。
*/
export function CharacterIdentityCard({ character }: { character: Character }) {
const profile = character.profile ?? {};
const mood = character.current_mood;
return (
<Card className="overflow-hidden">
{/* 横幅背景 */}
<div className="h-24 bg-gradient-to-r from-patina/20 via-glass to-accent-gold/10" />
<div className="relative px-5 pb-5">
{/* 头像(叠在横幅边界) */}
<div className="absolute -top-10 left-5">
<CharacterAvatar
characterId={character.id}
avatarUrl={character.avatar_url}
name={character.display_name || character.name}
/>
</div>
{/* 名称与状态 */}
<div className="flex items-end justify-between pt-8">
<div className="min-w-0">
<h2 className="truncate font-serif text-xl font-bold text-fg">
{character.display_name || character.name}
</h2>
{character.display_name && (
<p className="text-xs text-muted">内部名称:{character.name}</p>
)}
</div>
<CharacterStatusBadge status={character.status} />
</div>
{/* 关键档案字段:两列网格 */}
{(profile.birthday ||
profile.height ||
profile.weight ||
mood ||
character.language) && (
<dl className="mt-4 grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
{profile.birthday && (
<InfoItem label="生日" value={profile.birthday} />
)}
{profile.height && <InfoItem label="身高" value={profile.height} />}
{profile.weight && <InfoItem label="体重" value={profile.weight} />}
{mood && (
<InfoItem
label="当前心情"
value={`${mood.mood}(${Math.round((mood.intensity ?? 0) * 100)}%)`}
/>
)}
{character.language && (
<InfoItem label="语言" value={character.language} />
)}
<InfoItem
label="创建时间"
value={formatDateTime(character.created_at)}
/>
</dl>
)}
{/* 表达风格 */}
{profile.speaking_style && (
<div className="mt-4">
<p className="mb-1 text-xs font-medium text-muted">表达风格</p>
<p className="text-sm italic text-fg/80">
「{profile.speaking_style}」
</p>
</div>
)}
{/* 背景故事 */}
{profile.background && (
<div className="mt-4 border-t border-border-glass pt-4">
<p className="mb-1 text-xs font-medium text-muted">背景</p>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-fg/90">
{profile.background}
</p>
</div>
)}
</div>
</Card>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs text-muted">{label}</dt>
<dd className="font-medium text-fg">{value}</dd>
</div>
);
}
@@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { charactersApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { Character } from "@/types/api";
/**
* 人物生命周期操作条:按当前状态渲染可执行动作。
* active → 暂停 / 归档;paused → 恢复 / 归档;
* archived 及初始态(wuji/named/seeded/initialized)→ 激活。
* 归档为不可逆倾向操作,二次确认;写操作后 router.refresh() 以服务端数据为准。
*/
export function CharacterLifecycleBar({ character }: { character: Character }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [confirmArchive, setConfirmArchive] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run(action: () => Promise<unknown>) {
setBusy(true);
setError(null);
try {
await action();
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
const { id, status } = character;
const initial = [
"wuji",
"named",
"seeded",
"initialized",
"archived",
].includes(status);
return (
<div className="flex flex-wrap items-center gap-2">
{status === "active" && (
<Button
variant="secondary"
size="sm"
loading={busy}
onClick={() => run(() => charactersApi.pauseCharacter(id))}
>
暂停
</Button>
)}
{status === "paused" && (
<Button
variant="primary"
size="sm"
loading={busy}
onClick={() => run(() => charactersApi.resumeCharacter(id))}
>
恢复
</Button>
)}
{initial && (
<Button
variant="primary"
size="sm"
loading={busy}
onClick={() => run(() => charactersApi.activateCharacter(id))}
>
激活
</Button>
)}
{status !== "archived" && (
<Button
variant="danger"
size="sm"
loading={busy}
onClick={() => setConfirmArchive(true)}
>
归档
</Button>
)}
<Modal
open={confirmArchive}
onClose={() => setConfirmArchive(false)}
title="确认归档此人物?"
footer={
<>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmArchive(false)}
>
取消
</Button>
<Button
variant="danger"
size="sm"
loading={busy}
onClick={() => {
setConfirmArchive(false);
run(() => charactersApi.archiveCharacter(id));
}}
>
确认归档
</Button>
</>
}
>
归档后该人物不可对话,但可随时重新激活。此操作会留痕。
</Modal>
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -0,0 +1,201 @@
"use client";
import { CharacterLifecycleBar } from "./CharacterLifecycleBar";
import type { Character } from "@/types/api";
/**
* 魂册 · 镜面卡(设计稿 characters-preview.html §soul-card)。
*
* 镜面材质 Hero + 圆形镜面头像 + 七阶生命周期轨 + 档案字段网格 + 关键指标侧栏。
* 设计稿三层镜面纹理(face-sheen 光泽 + sweep-gradient 静态漫反射 + grain 颗粒)
* 通过 soul-card / mirror-sweep / mirror-grain CSS 复合实现。
*/
export function CharacterProfilePanel({ character }: { character: Character }) {
const profile = character.profile ?? {};
const mood = character.current_mood;
const displayName = character.display_name || character.name;
const initial = displayName.charAt(0);
const lifecycle = character.status;
// 生命周期站点(设计稿 .lifecycle-track)
const STATIONS = [
{ key: "wuji", label: "无极" },
{ key: "named", label: "得名" },
{ key: "seeded", label: "播种" },
{ key: "initialized", label: "降生" },
{ key: "active", label: "起居" },
{ key: "paused", label: "息影" },
{ key: "archived", label: "归藏" },
] as const;
const currentIdx = STATIONS.findIndex((s) => s.key === lifecycle);
const stationIndex = currentIdx >= 0 ? currentIdx : -1;
return (
<div className="soul-card">
{/* 镜面静态光泽层(::before 由 soul-card 提供) */}
{/* 铜面颗粒层(::after 由 soul-card 提供) */}
{/* ===== 上层:头像 + 名称 + 情绪 + 标签 ===== */}
<div className="soul-top">
{/* 铜镜圆形头像(设计稿 .soul-mirror) */}
<div className="soul-mirror mirror-sweep mirror-grain">
<div className="inner">{initial}</div>
<div className="halo" />
</div>
{/* 名称区 */}
<div className="soul-id">
<h2 className="soul-name">{displayName}</h2>
{character.display_name && (
<p className="soul-realname">
{character.name}
<span className="ml-3 font-mono text-[10px] text-fg-faint">
ID · {character.id.slice(0, 8)}
</span>
</p>
)}
{/* 情绪(设计稿 .soul-mood) */}
{mood && (
<div className="soul-mood">
<span className="pulse-dot" />
<span>{mood.mood}</span>
<span className="font-mono text-[11px] text-fg-faint">
{Math.round((mood.intensity ?? 0) * 100)}%
</span>
</div>
)}
{/* 标签行 */}
<div className="mt-2.5 flex flex-wrap items-center gap-2">
{character.language && (
<span className="soul-tag">{character.language}</span>
)}
{character.status === "active" && (
<span className="soul-tag bg-patina-wash text-patina">
· 在场
</span>
)}
</div>
</div>
{/* 生命周期操作(右侧) */}
<div className="ml-auto shrink-0">
<CharacterLifecycleBar character={character} />
</div>
</div>
{/* ===== 生命周期轨(设计稿 .lifecycle) ===== */}
<div className="lifecycle">
<div className="lifecycle-label">生命周期 · Lifecycle</div>
<div className="lifecycle-track">
{STATIONS.map((s, i) => {
const reached = i <= stationIndex && stationIndex >= 0;
const current = i === stationIndex;
return (
<div key={s.key} className="flex items-center">
<div
className={`lc-station ${reached ? "reached" : ""} ${current ? "current" : ""}`}
>
<div className="lc-dot" />
<span className="lc-name">{s.label}</span>
</div>
{i < STATIONS.length - 1 && (
<div className={`lc-link ${reached ? "reached" : ""}`} />
)}
</div>
);
})}
</div>
</div>
{/* ===== 档案字段网格 + 关键指标(设计稿 .soul-grid) ===== */}
<div className="soul-grid">
{/* 左栏:字段网格 */}
<div>
<div className="soul-fields">
<Field label="语言" value={character.language} />
<Field label="表达风格" value={profile.speaking_style} />
<Field label="生日" value={profile.birthday} />
<Field label="身高" value={profile.height} />
<Field label="体重" value={profile.weight} />
<Field label="创建时间" value={formatDate(character.created_at)} />
<Field
label="模型策略"
value={
character.model_policy?.default_chat_model_hint ??
"default-chat"
}
/>
<Field
label="隐私等级"
value={character.model_policy?.privacy_level ?? "—"}
/>
</div>
{profile.background && (
<div className="soul-bg">
<div className="lab">背景 · Background</div>
<p>{profile.background}</p>
</div>
)}
</div>
{/* 右栏:关键指标(设计稿 .soul-stats) */}
<div className="soul-stats">
<div className="soul-stat">
<div className="k">记忆 · Memory</div>
<div className="v">
—<small>条</small>
</div>
</div>
<div className="soul-stat">
<div className="k">人格版本 · Personality</div>
<div className="v">
{character.current_personality_version_id
? `v${character.current_personality_version_id.slice(0, 7)}`
: "—"}
<small>{character.status === "active" ? "活跃中" : ""}</small>
</div>
</div>
<div className="soul-stat">
<div className="k">状态 · Status</div>
<div className="v">
{character.status}
<small>{character.language ?? ""}</small>
</div>
</div>
{mood && (
<div className="soul-stat">
<div className="k">情绪强度 · Intensity</div>
<div className="v">
{Math.round((mood.intensity ?? 0) * 100)}
<small>%</small>
</div>
</div>
)}
</div>
</div>
</div>
);
}
function Field({ label, value }: { label: string; value?: string }) {
return (
<dl className="field">
<dt>{label}</dt>
<dd>{value || "—"}</dd>
</dl>
);
}
function formatDate(iso?: string): string {
if (!iso) return "—";
try {
const d = new Date(iso);
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
} catch {
return iso;
}
}
@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import { Card, CardHeader } from "@/components/ui/Card";
import { importsApi } from "@/lib/api";
import { formatDateTime } from "@/lib/utils/format";
import type { ImportItem, ImportJob } from "@/types/api";
/**
* 导入历史列表(客户端组件,支持展开查看条目)。
*/
export function ImportHistory({
characterId,
initialHistory,
}: {
characterId: string;
initialHistory: ImportJob[];
}) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const [itemsMap, setItemsMap] = useState<Record<string, ImportItem[]>>({});
async function toggleExpand(jobId: string) {
if (expandedId === jobId) {
setExpandedId(null);
return;
}
setExpandedId(jobId);
if (!itemsMap[jobId]) {
try {
const result = await importsApi.listImportItems(characterId, jobId);
setItemsMap((m) => ({ ...m, [jobId]: result.items }));
} catch {
setItemsMap((m) => ({ ...m, [jobId]: [] }));
}
}
}
if (initialHistory.length === 0) return null;
return (
<Card className="p-5">
<CardHeader
title="导入历史"
subtitle={`共 ${initialHistory.length} 次导入`}
/>
<ul className="space-y-2">
{initialHistory.map((job) => {
const expanded = expandedId === job.id;
const items = itemsMap[job.id];
return (
<li key={job.id} className="rounded-lg bg-glass/40 px-3 py-2">
<button
type="button"
onClick={() => toggleExpand(job.id)}
className="flex w-full items-center gap-2 text-left"
>
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${
job.status === "completed" || job.status === "reviewing"
? "bg-patina/15 text-patina"
: job.status === "parsed"
? "bg-gilt/15 text-gilt"
: "bg-glass text-muted"
}`}
>
{job.status}
</span>
<span className="text-xs text-fg-dim">{job.material_type}</span>
<span className="text-xs text-muted">{job.title || "-"}</span>
<span className="ml-auto text-[11px] text-muted">
{formatDateTime(job.created_at)}
</span>
<span className="text-xs text-muted">
{expanded ? "▲" : "▼"}
</span>
</button>
{expanded && items && (
<ul className="mt-2 space-y-1 border-t border-border-glass pt-2">
{items.length === 0 ? (
<li className="text-xs text-muted">加载中…</li>
) : (
items.map((it) => (
<li key={it.id} className="text-xs text-fg/80">
<span className="text-[10px] text-muted">
[{it.item_type}]
</span>{" "}
{it.content}
</li>
))
)}
</ul>
)}
</li>
);
})}
</ul>
</Card>
);
}
@@ -0,0 +1,444 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader } from "@/components/ui/Card";
import { importsApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import { formatDateTime } from "@/lib/utils/format";
import type {
ImportItem,
ImportJob,
MemoryCandidate,
ProfileCandidate,
} from "@/types/api";
import type { MaterialType } from "@/types/domain";
const MATERIAL_OPTIONS: MaterialType[] = [
"background_story",
"character_profile",
"chat_history",
"role_card",
"world_setting",
"style_sample",
"diary",
"other",
];
/**
* 资料导入向导(§10,挂在 /characters/{id}/imports 下)。
* 支持 文本粘贴 / 文件上传 两种方式。
* 流程:创建 → 解析条目 → 触发候选抽取 → 勾选候选批量裁定。
*/
export function ImportWizard({ characterId }: { characterId: string }) {
const router = useRouter();
const [tab, setTab] = useState<"text" | "file">("text");
const [materialType, setMaterialType] =
useState<MaterialType>("background_story");
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [job, setJob] = useState<ImportJob | null>(null);
const [items, setItems] = useState<ImportItem[]>([]);
const [profileCandidates, setProfileCandidates] = useState<
ProfileCandidate[]
>([]);
const [memoryCandidates, setMemoryCandidates] = useState<MemoryCandidate[]>(
[],
);
const [decisions, setDecisions] = useState<
Record<string, "approve" | "reject">
>({});
const idempotencyRef = useRef<string | null>(null);
async function submitImport() {
if (tab === "text") {
if (!content.trim()) {
setError("请粘贴资料正文。");
return;
}
} else {
if (!file) {
setError("请选择文件。");
return;
}
}
if (!idempotencyRef.current) idempotencyRef.current = crypto.randomUUID();
setBusy(true);
setError(null);
try {
let created: ImportJob;
if (tab === "text") {
created = await importsApi.createImport(
characterId,
{
material_type: materialType,
content: content.trim(),
title: title.trim() || undefined,
},
idempotencyRef.current,
);
} else {
created = await importsApi.uploadImport(characterId, file!, {
title: title.trim() || undefined,
});
}
setJob(created);
const fetchedItems = await importsApi.listImportItems(
characterId,
created.id,
);
setItems(fetchedItems.items);
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
async function extract() {
if (!job) return;
setBusy(true);
setError(null);
try {
await Promise.all([
importsApi.extractProfileCandidates(characterId, job.id),
importsApi.extractMemoryCandidates(characterId, job.id),
importsApi.generatePersonalityProposals(characterId, job.id),
]);
const [profile, memory] = await Promise.all([
importsApi.listProfileCandidates(characterId, job.id),
importsApi.listMemoryCandidates(characterId, job.id),
]);
setProfileCandidates(profile.items);
setMemoryCandidates(memory.items);
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
function setDecision(id: string, decision: "approve" | "reject") {
setDecisions((d) => {
const next = { ...d };
if (next[id] === decision) delete next[id];
else next[id] = decision;
return next;
});
}
async function batchReview() {
if (!job) return;
const decisionList = Object.entries(decisions).map(
([candidate_id, decision]) => ({ candidate_id, decision }),
);
if (decisionList.length === 0) {
setError("请先勾选候选并裁定。");
return;
}
setBusy(true);
setError(null);
try {
const profileIds = decisionList.filter((d) =>
d.candidate_id.startsWith("profile_candidate_"),
);
const memoryIds = decisionList.filter((d) =>
d.candidate_id.startsWith("memory_candidate_"),
);
if (profileIds.length > 0)
await importsApi.batchReviewProfileCandidates(characterId, {
decisions: profileIds,
});
if (memoryIds.length > 0)
await importsApi.batchReviewMemoryCandidates(characterId, {
decisions: memoryIds,
});
setDecisions({});
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
function reset() {
setJob(null);
setItems([]);
setProfileCandidates([]);
setMemoryCandidates([]);
setDecisions({});
setTitle("");
setContent("");
setFile(null);
idempotencyRef.current = null;
}
const inputCls =
"mt-1 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
return (
<Card className="p-5">
<CardHeader
title="资料导入"
subtitle="粘贴文本或上传文件,解析后抽取候选并批量审核。"
/>
{!job ? (
<div className="space-y-3">
{/* 方式切换 */}
<div className="flex gap-2 border-b border-border-glass pb-2">
<button
type="button"
onClick={() => setTab("text")}
className={`rounded-lg px-3 py-1 text-xs font-medium transition ${
tab === "text"
? "bg-patina/15 text-patina"
: "text-muted hover:text-fg"
}`}
>
📝 粘贴文本
</button>
<button
type="button"
onClick={() => setTab("file")}
className={`rounded-lg px-3 py-1 text-xs font-medium transition ${
tab === "file"
? "bg-patina/15 text-patina"
: "text-muted hover:text-fg"
}`}
>
📁 上传文件
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="text-xs text-muted">资料类型</label>
<select
value={materialType}
onChange={(e) =>
setMaterialType(e.target.value as MaterialType)
}
className={inputCls}
>
{MATERIAL_OPTIONS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div>
<label className="text-xs text-muted">标题(可选)</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className={inputCls}
/>
</div>
</div>
{tab === "text" ? (
<div>
<label className="text-xs text-muted">资料正文</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={6}
className={inputCls}
placeholder="粘贴人物设定、背景故事、对话记录…"
/>
</div>
) : (
<div>
<label className="text-xs text-muted">选择文件</label>
<div
onClick={() => fileInputRef.current?.click()}
className="mt-1 flex cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-border-glass bg-glass px-4 py-6 text-sm text-muted transition-colors hover:border-patina/50"
>
{file ? (
<span>
📄 {file.name} ({(file.size / 1024).toFixed(1)} KB)
</span>
) : (
<span>点击选择文件(文本 / 角色卡 / 音频样本)</span>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".txt,.md,.json,.yaml,.yml,.csv,.html"
className="hidden"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</div>
)}
<Button
variant="primary"
size="sm"
loading={busy}
onClick={submitImport}
>
提交并解析
</Button>
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
<span className="rounded bg-patina/15 px-1.5 py-0.5 text-patina">
{job.status}
</span>
<span>{job.material_type}</span>
<span>· {items.length} 条</span>
<span className="ml-auto">{formatDateTime(job.created_at)}</span>
</div>
{items.length > 0 && (
<details className="rounded-lg bg-glass/40 px-3 py-2">
<summary className="cursor-pointer text-xs text-muted">
解析条目({items.length})
</summary>
<ul className="mt-2 space-y-1 text-sm">
{items.map((it) => (
<li key={it.id} className="text-fg/80">
<span className="text-[10px] text-muted">
[{it.item_type}/{it.role}]
</span>{" "}
{it.content}
</li>
))}
</ul>
</details>
)}
{(profileCandidates.length > 0 || memoryCandidates.length > 0) && (
<>
{profileCandidates.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted">
档案候选({profileCandidates.length})
</p>
{profileCandidates.map((c) => (
<CandidateRow
key={c.id}
label={`${c.field}:${c.value}`}
confidence={c.confidence}
decision={decisions[c.id]}
disabled={busy}
onToggle={(d) => setDecision(c.id, d)}
/>
))}
</div>
)}
{memoryCandidates.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted">
记忆候选({memoryCandidates.length})
</p>
{memoryCandidates.map((c) => (
<CandidateRow
key={c.id}
label={c.title}
sub={c.content}
confidence={c.confidence}
decision={decisions[c.id]}
disabled={busy}
onToggle={(d) => setDecision(c.id, d)}
/>
))}
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
loading={busy}
onClick={batchReview}
>
提交裁定({Object.keys(decisions).length})
</Button>
<Button variant="ghost" size="sm" onClick={reset}>
导入新资料
</Button>
</div>
</>
)}
{profileCandidates.length === 0 && memoryCandidates.length === 0 && (
<Button
variant="secondary"
size="sm"
loading={busy}
onClick={extract}
>
抽取档案 / 记忆 / 人格候选
</Button>
)}
</div>
)}
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</Card>
);
}
function CandidateRow({
label,
sub,
confidence,
decision,
disabled,
onToggle,
}: {
label: string;
sub?: string;
confidence: number;
decision?: "approve" | "reject";
disabled?: boolean;
onToggle: (d: "approve" | "reject") => void;
}) {
return (
<div className="rounded-lg bg-glass/40 px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-sm text-fg">{label}</span>
<span className="ml-auto font-mono text-[10px] text-muted">
{(confidence ?? 0).toFixed(2)}
</span>
</div>
{sub && <p className="mt-0.5 text-xs text-fg/70">{sub}</p>}
<div className="mt-1.5 flex gap-2">
<button
type="button"
disabled={disabled}
onClick={() => onToggle("approve")}
className={`rounded px-2 py-0.5 text-[11px] disabled:opacity-50 ${
decision === "approve"
? "bg-patina/30 text-patina"
: "bg-glass text-muted"
}`}
>
通过
</button>
<button
type="button"
disabled={disabled}
onClick={() => onToggle("reject")}
className={`rounded px-2 py-0.5 text-[11px] disabled:opacity-50 ${
decision === "reject"
? "bg-cinnabar/30 text-cinnabar"
: "bg-glass text-muted"
}`}
>
驳回
</button>
</div>
</div>
);
}
@@ -0,0 +1,186 @@
"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 { charactersApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { Character, UpdateModelPolicyRequest } from "@/types/api";
type HintKey =
| "default_chat_model_hint"
| "memory_extraction_model_hint"
| "personality_analysis_model_hint"
| "embedding_model_hint";
interface HintOption {
value: string;
label: string;
}
interface HintField {
key: HintKey;
label: string;
options: HintOption[];
}
const HINT_FIELDS: HintField[] = [
{
key: "default_chat_model_hint",
label: "对话模型",
options: [
{ value: "", label: "— 不设置(使用系统默认)" },
{ value: "deepseek-v4-flash", label: "deepseek-v4-flash" },
{ value: "deepseek-v3", label: "deepseek-v3" },
{ value: "gpt-4o", label: "gpt-4o" },
{ value: "claude-sonnet-5", label: "claude-sonnet-5" },
],
},
{
key: "memory_extraction_model_hint",
label: "记忆提取模型",
options: [
{ value: "", label: "— 不设置(空)" },
{ value: "memory-extraction", label: "memory-extraction" },
{ value: "deepseek-v4-flash", label: "deepseek-v4-flash" },
{ value: "deepseek-v3", label: "deepseek-v3" },
{ value: "gpt-4o-mini", label: "gpt-4o-mini" },
],
},
{
key: "personality_analysis_model_hint",
label: "人格分析模型",
options: [
{ value: "", label: "— 不设置(空)" },
{ value: "personality-analysis", label: "personality-analysis" },
{ value: "deepseek-v4-flash", label: "deepseek-v4-flash" },
{ value: "deepseek-v3", label: "deepseek-v3" },
{ value: "gpt-4o", label: "gpt-4o" },
],
},
{
key: "embedding_model_hint",
label: "向量嵌入模型",
options: [
{ value: "", label: "— 不设置(空)" },
{ value: "embedding", label: "embedding" },
{ value: "text-embedding-3-small", label: "text-embedding-3-small" },
{ value: "text-embedding-3-large", label: "text-embedding-3-large" },
],
},
];
const PRIVACY_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "normal", label: "normal" },
{ value: "sensitive", label: "sensitive" },
{ value: "local_only", label: "local_only" },
];
/**
* 人物模型策略编辑表单:model hints 选择框 + 隐私等级下拉。
* 提交仅发改动字段,成功后 router.refresh()。
*/
export function ModelPolicyEditForm({ character }: { character: Character }) {
const router = useRouter();
const policy = character.model_policy;
const [draft, setDraft] = useState<UpdateModelPolicyRequest>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirtyKeys = Object.keys(draft) as (keyof UpdateModelPolicyRequest)[];
const hasEdits = dirtyKeys.length > 0;
function hintValue(key: HintKey): string {
if (key in draft) return (draft[key] as string | undefined) ?? "";
return (policy?.[key] as string | undefined) ?? "";
}
function privacyValue(): string {
if ("privacy_level" in draft)
return (draft.privacy_level as string | undefined) ?? "normal";
return policy?.privacy_level ?? "normal";
}
function setHint(key: HintKey, v: string) {
setDraft((d) => ({ ...d, [key]: v }));
}
function setPrivacy(v: string) {
setDraft((d) => ({
...d,
privacy_level: v as UpdateModelPolicyRequest["privacy_level"],
}));
}
async function submit() {
setBusy(true);
setError(null);
try {
await charactersApi.updateModelPolicy(character.id, draft);
setDraft({});
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
const selectCls =
"mt-1 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
return (
<Card className="p-5">
<CardHeader
title="模型策略"
subtitle="选择逻辑模型槽位,留空则使用默认。"
/>
<div className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{HINT_FIELDS.map(({ key, label, options }) => (
<div key={key}>
<label className="text-xs text-muted">{label}</label>
<select
value={hintValue(key)}
onChange={(e) => setHint(key, e.target.value)}
className={selectCls}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
))}
<div>
<label className="text-xs text-muted">隐私等级</label>
<select
value={privacyValue()}
onChange={(e) => setPrivacy(e.target.value)}
className={selectCls}
>
{PRIVACY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
{hasEdits && (
<div className="mt-4 flex items-center gap-2">
<Button variant="primary" size="sm" loading={busy} onClick={submit}>
保存策略
</Button>
<Button variant="ghost" size="sm" onClick={() => setDraft({})}>
放弃
</Button>
<span className="ml-auto text-xs text-muted">
{dirtyKeys.length} 项已改
</span>
</div>
)}
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</Card>
);
}
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
const ONBOARDING_KEY = "hunjing_characters_onboarding_dismissed";
/**
* 首次使用引导提示(客户端组件)。
* 用户关闭后写入 localStorage,下次不再显示。
*/
export function OnboardingBanner() {
const [visible, setVisible] = useState(true);
useEffect(() => {
try {
const dismissed = localStorage.getItem(ONBOARDING_KEY);
if (dismissed) {
// 延迟到下一个 microtask 以避免级联渲染 lint
queueMicrotask(() => setVisible(false));
}
} catch {
/* localStorage 不可用时始终显示 */
}
}, []);
function dismiss() {
setVisible(false);
try {
localStorage.setItem(ONBOARDING_KEY, "1");
} catch {
/* 静默 */
}
}
if (!visible) return null;
return (
<div
className="relative mb-6 overflow-hidden rounded-xl border border-gilt-dim/30 bg-gilt-faint/20 px-5 py-4"
role="alert"
>
<button
type="button"
onClick={dismiss}
className="absolute right-3 top-3 text-xs text-muted/50 transition-colors hover:text-muted"
aria-label="关闭"
>
✕
</button>
<p className="text-sm font-medium text-gilt">欢迎来到魂镜</p>
<p className="mt-1 text-xs text-muted">
你的默认人物已就绪。可以在下方查看所有人物,或创建新的空白人物逐步完善。
管理员可管理系统的角色模板。
</p>
</div>
);
}
@@ -0,0 +1,218 @@
"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 { charactersApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { Character, UpdateProfileRequest } from "@/types/api";
// ── 标签输入组件 ──
function TagInput({
values,
onChange,
placeholder,
}: {
values: string[];
onChange: (v: string[]) => void;
placeholder?: string;
}) {
const [input, setInput] = useState("");
const add = () => {
const v = input.trim();
if (!v) return;
onChange([...values, v]);
setInput("");
};
return (
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm transition-colors focus-within:border-patina">
{values.map((v, i) => (
<span
key={i}
className="inline-flex items-center gap-1 rounded-full bg-patina/10 px-2 py-0.5 text-[12px] text-patina"
>
{v}
<button
type="button"
onClick={() => onChange(values.filter((_, j) => j !== i))}
className="ml-0.5 opacity-50 transition-opacity hover:opacity-100"
>
×
</button>
</span>
))}
<input
className="min-w-[70px] flex-1 border-none bg-transparent p-0.5 text-sm text-fg outline-none"
placeholder={placeholder || "回车添加…"}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
/>
</div>
);
}
// ── 表单字段定义 ──
type FieldGroup = Array<{
key: keyof UpdateProfileRequest;
label: string;
area?: boolean;
tag?: boolean;
full?: boolean;
}>;
const BASIC_FIELDS: FieldGroup = [
{ key: "display_name", label: "显示名" },
{ key: "description", label: "一句话简介", full: true },
];
const BODY_FIELDS: FieldGroup = [
{ key: "birthday", label: "生日" },
{ key: "height", label: "身高" },
{ key: "weight", label: "体重" },
{ key: "temperament", label: "性情气质" },
{ key: "speaking_style", label: "表达风格" },
{ key: "background", label: "背景", area: true, full: true },
];
const TAG_FIELDS: FieldGroup = [
{ key: "hobbies", label: "兴趣爱好", tag: true },
{ key: "values", label: "价值观", tag: true },
];
/**
* 人物档案编辑表单:draft 模式,提交时仅发改动字段(PATCH /characters/{id}/profile)。
* display_name 属顶层字段,其余属 profile。手动编辑视为用户确认。成功后 router.refresh()。
*/
export function ProfileEditForm({ character }: { character: Character }) {
const router = useRouter();
const profile = character.profile ?? {};
const [draft, setDraft] = useState<UpdateProfileRequest>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirtyKeys = Object.keys(draft) as (keyof UpdateProfileRequest)[];
const hasEdits = dirtyKeys.length > 0;
function valueFor(key: keyof UpdateProfileRequest): string {
if (key in draft) return (draft[key] as string | undefined) ?? "";
if (key === "display_name") return character.display_name ?? "";
return (profile[key as keyof typeof profile] as string | undefined) ?? "";
}
function listFor(key: "hobbies" | "values"): string[] {
if (key in draft) return (draft[key] as string[]) ?? [];
const raw = profile[key];
return Array.isArray(raw) ? (raw as string[]) : [];
}
function setField(key: keyof UpdateProfileRequest, v: string) {
setDraft((d) => ({ ...d, [key]: v }));
}
function setList(key: "hobbies" | "values", v: string[]) {
setDraft((d) => ({ ...d, [key]: v }));
}
async function submit() {
setBusy(true);
setError(null);
try {
await charactersApi.updateProfile(character.id, draft);
setDraft({});
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
const inputCls =
"mt-1 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
return (
<Card className="p-5">
<CardHeader
title="人物档案"
subtitle="手动编辑视为用户确认,直接写入。"
/>
<div className="space-y-4">
{/* 基础字段:两列 */}
<div className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{BASIC_FIELDS.map(({ key, label, full }) => (
<div key={key} className={full ? "sm:col-span-2" : ""}>
<label className="text-xs text-muted">{label}</label>
<input
value={valueFor(key)}
onChange={(e) => setField(key, e.target.value)}
className={inputCls}
/>
</div>
))}
</div>
{/* 身体 + 性格字段 */}
<div className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{BODY_FIELDS.map(({ key, label, area, full }) => (
<div key={key} className={full ? "sm:col-span-2" : ""}>
<label className="text-xs text-muted">{label}</label>
{area ? (
<textarea
value={valueFor(key)}
onChange={(e) => setField(key, e.target.value)}
rows={3}
className={inputCls}
/>
) : (
<input
value={valueFor(key)}
onChange={(e) => setField(key, e.target.value)}
className={inputCls}
/>
)}
</div>
))}
</div>
{/* 标签字段 */}
<div className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{TAG_FIELDS.map(({ key, label }) => (
<div key={key}>
<label className="text-xs text-muted">{label}</label>
<TagInput
values={listFor(key as "hobbies" | "values")}
onChange={(v) => setList(key as "hobbies" | "values", v)}
placeholder={`输入${label}后回车…`}
/>
</div>
))}
</div>
</div>
{hasEdits && (
<div className="mt-4 flex items-center gap-2 border-t border-border-glass pt-4">
<Button variant="primary" size="sm" loading={busy} onClick={submit}>
保存档案
</Button>
<Button variant="ghost" size="sm" onClick={() => setDraft({})}>
放弃
</Button>
<span className="ml-auto text-xs text-muted">
{dirtyKeys.length} 项已改
</span>
</div>
)}
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</Card>
);
}
@@ -0,0 +1,63 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
import { ActiveCharacterHeader } from "./ActiveCharacterHeader";
import { useChatStore } from "@/lib/stores/chat-store";
const mockCharacter = {
id: "char_1",
name: "测试角色",
display_name: "小测",
status: "active" as const,
language: "zh",
profile: {},
created_at: "",
updated_at: "",
};
describe("ActiveCharacterHeader", () => {
afterEach(() => {
cleanup();
useChatStore.setState({
runStatus: null,
activeRunId: null,
modelHint: null,
});
});
it("渲染人物名称", () => {
render(<ActiveCharacterHeader character={mockCharacter} />);
expect(screen.getByText("小测")).toBeInTheDocument();
});
it("无 display_name 时回退 name", () => {
render(
<ActiveCharacterHeader
character={{ ...mockCharacter, display_name: undefined }}
/>,
);
expect(screen.getByText("测试角色")).toBeInTheDocument();
});
it("无人物时显示默认文案", () => {
render(<ActiveCharacterHeader character={null} />);
expect(screen.getByText("未加载人物")).toBeInTheDocument();
});
it("runStatus=running 渲染 Run 状态链接", () => {
useChatStore.setState({ runStatus: "running", activeRunId: "run_1" });
render(<ActiveCharacterHeader character={mockCharacter} />);
expect(screen.getByText("定策中")).toBeInTheDocument();
});
it("无 runStatus 时常驻显示静默态'静鉴'", () => {
render(<ActiveCharacterHeader character={mockCharacter} />);
// RunBadge 常驻:无 run 时呈静默"静鉴",而非消失
expect(screen.getByText("静鉴")).toBeInTheDocument();
expect(screen.queryByText("定策中")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,234 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { CharacterSwitcher } from "./CharacterSwitcher";
import { useChatStore } from "@/lib/stores/chat-store";
import { cn } from "@/lib/utils/cn";
import type { Character, LogicalModel } from "@/types/api";
import type { RunStatus } from "@/types/domain";
/** 运行态 → 中文爻位短语(设计稿 run-badge 文案,如 running=定策中) */
const RUN_LABEL: Record<RunStatus, string> = {
queued: "起意中",
running: "定策中",
waiting_confirmation: "待确认",
completed: "已入藏",
failed: "归真",
cancelled: "已止",
};
/** 进行中态(脉冲点亮);终态静止,不再呼吸 */
const RUN_LIVE = new Set<RunStatus>([
"queued",
"running",
"waiting_confirmation",
]);
/**
* 右上角运行态徽标:常驻显示。
* 有进行中 run 时呈现爻位态(脉冲点亮,可点入轨迹);无 run 或已终态时
* 静默"静鉴",保持顶栏视觉锚点不消失(镜始终在场)。
*/
function RunBadge({
runStatus,
activeRunId,
}: {
runStatus: RunStatus | null;
activeRunId: string | null;
}) {
const live = !!activeRunId && !!runStatus && RUN_LIVE.has(runStatus);
const label = activeRunId && runStatus ? RUN_LABEL[runStatus] : "静鉴";
const cls = cn(
"inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 py-[3px] text-[12px] transition-colors",
live
? "border-patina-dim bg-patina-wash text-patina hover:border-patina hover:text-patina-bright"
: "border-rule bg-surface/60 text-fg-faint",
);
const dot = (
<span
className={cn(
"h-[5px] w-[5px] shrink-0 rounded-full",
live ? "animate-pulse-ring bg-patina" : "bg-fg-faint/50",
)}
/>
);
if (activeRunId && runStatus) {
return (
<Link
href={`/runs/${activeRunId}`}
aria-label={`查看运行 ${label}`}
className={cls}
>
{dot}
{label}
</Link>
);
}
return (
<span className={cls} aria-label="镜面静待铭文">
{dot}
{label}
</span>
);
}
/**
* 铜镜鉴心 v2 顶栏(Topbar)。
*
* 极简:左字标 ●魂鏡 + 镜背轨开关 + 当前人物 | 右 pill 组(运行态 / 模型 / 人物)
* 底部 1px rule 发丝线。Run 状态由铜镜呼吸隐式表达。
*/
export function ActiveCharacterHeader({
character,
models,
railCollapsed = false,
onToggleRail,
}: {
character: Character | null;
models?: LogicalModel[];
/** 镜背轨(chat-region 内的会话/状态轨)是否收起 */
railCollapsed?: boolean;
/** 切换镜背轨展开 / 收起 */
onToggleRail?: () => void;
}) {
const runStatus = useChatStore(
(state) => state.runStatus,
) as RunStatus | null;
const activeRunId = useChatStore((state) => state.activeRunId);
const displayName =
character?.display_name || character?.name || "未加载人物";
return (
<header className="sticky top-0 z-40 flex items-center gap-2 border-b border-rule bg-ink/78 px-4 py-3 backdrop-blur-lg sm:gap-4 sm:px-5">
{/* 镜背轨开关(桌面,替代原浮动圆钮) */}
{onToggleRail && (
<button
type="button"
onClick={onToggleRail}
aria-label={railCollapsed ? "展开镜背轨" : "收起镜背轨"}
title={railCollapsed ? "展开镜背轨" : "收起镜背轨"}
className={cn(
"hidden h-8 w-8 shrink-0 items-center justify-center rounded-lg border transition-colors md:inline-flex",
railCollapsed
? "border-rule bg-surface text-fg-dim hover:border-gilt-dim hover:text-fg"
: "border-patina-dim bg-patina-wash text-patina",
)}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<rect
x="2"
y="2"
width="12"
height="12"
rx="1.5"
stroke="currentColor"
strokeWidth="1.3"
/>
<path d="M6 2v12" stroke="currentColor" strokeWidth="1.3" />
</svg>
</button>
)}
{/* 人物切换器:按钮即当前人物(设计稿紧挨 rail-toggle,左侧) */}
<CharacterSwitcher
currentCharacterId={character?.id}
currentDisplayName={displayName}
/>
<div className="min-w-0 flex-1" />
{/* 右侧 pills:运行态(常驻)+ 模型 */}
<div className="flex shrink-0 items-center gap-1 sm:gap-2">
<RunBadge runStatus={runStatus} activeRunId={activeRunId} />
<ModelPill models={models} />
</div>
</header>
);
}
/** 模型选择:下拉框选择模型 */
function ModelPill({ models }: { models?: LogicalModel[] }) {
const modelHint = useChatStore((s) => s.modelHint);
const setModelHint = useChatStore((s) => s.setModelHint);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const currentModel = models?.find((m) => m.name === modelHint);
const label = currentModel?.display_name || modelHint || "模型";
// 点击外部收起
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
title="切换模型"
className="inline-flex max-w-24 items-center gap-1 rounded-full border border-rule bg-surface px-2.5 py-1 text-[10px] text-fg-dim transition-colors hover:border-gilt-dim hover:text-fg sm:max-w-40 sm:px-3 sm:text-[11px]"
>
<span className="truncate">{label}</span>
<svg
width="10"
height="10"
viewBox="0 0 16 16"
fill="none"
aria-hidden
className={cn("shrink-0 transition-transform", open && "rotate-180")}
>
<path
d="M4 6l4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{open && (
<div className="absolute right-0 top-full z-50 mt-1 min-w-44 rounded-xl border border-border-glass bg-glass-strong p-1 shadow-xl backdrop-blur-xl">
{!models || models.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted">无可用模型</div>
) : (
models.map((m) => (
<button
key={m.name}
type="button"
onClick={() => {
setModelHint(m.name);
setOpen(false);
}}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-xs transition-colors hover:bg-fg/5"
>
<span className="flex-1 truncate text-fg-dim">
{m.display_name || m.name}
</span>
{m.name === modelHint && (
<span className="shrink-0 text-[9px] text-patina">当前</span>
)}
</button>
))
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,43 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
// 不触发实际的 API 调用
vi.mock("@/lib/api", () => ({
charactersApi: {
listCharacters: vi.fn().mockResolvedValue({ items: [], total: 0 }),
},
}));
import { CharacterSwitcher } from "./CharacterSwitcher";
describe("CharacterSwitcher", () => {
it("渲染当前人物名", () => {
render(
<CharacterSwitcher
currentCharacterId="char_1"
currentDisplayName="小测"
/>,
);
expect(screen.getByText("小测")).toBeInTheDocument();
});
it("点击展开下拉菜单", async () => {
const user = userEvent.setup();
render(
<CharacterSwitcher
currentCharacterId="char_1"
currentDisplayName="小测"
/>,
);
// 当前人物名可点击
await user.click(screen.getByText("小测"));
// dropdown 应该出现——因为 listCharacters 返回空列表,
// 此处只验证下拉渲染不报错
expect(screen.getByText("小测")).toBeInTheDocument();
});
});
@@ -0,0 +1,161 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { charactersApi } from "@/lib/api";
import { cn } from "@/lib/utils/cn";
import { describeError } from "@/lib/utils/error";
import type { CharacterListItem } from "@/types/api";
/** 人物缩略头像(首字):激活态实心铜绿,其余铜绿晕底。与镜背轨状态卡同源。 */
function AvatarMini({ name, active }: { name: string; active?: boolean }) {
const ch = name?.trim()?.[0] ?? "镜";
return (
<span
aria-hidden
className={cn(
"flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full font-serif text-[12px] font-semibold",
active ? "bg-patina text-ink" : "bg-patina-wash text-patina",
)}
>
{ch}
</span>
);
}
/**
* 对话页人物切换器:下拉列出全部人物,选中后激活并刷新页面。
* 点击外部自动收起。
*/
export function CharacterSwitcher({
currentCharacterId,
currentDisplayName,
}: {
currentCharacterId: string | undefined;
currentDisplayName: string;
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [characters, setCharacters] = useState<CharacterListItem[]>([]);
const [switching, setSwitching] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// 打开时拉取人物列表
useEffect(() => {
if (!open) return;
let cancelled = false;
charactersApi
.listCharacters({ page_size: 50 })
.then((res) => {
if (!cancelled) setCharacters(res.items);
})
.catch(() => {
/* 拉取失败静默 */
});
return () => {
cancelled = true;
};
}, [open]);
// 点击外部收起
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [open]);
async function handleSwitch(characterId: string) {
if (characterId === currentCharacterId) {
setOpen(false);
return;
}
setSwitching(true);
try {
await charactersApi.activateCharacter(characterId);
setOpen(false);
router.refresh();
} catch (e) {
/* 切换失败不阻塞,静默保留当前人物 */
console.error("切换人物失败:", describeError(e));
setSwitching(false);
}
}
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border border-rule py-0.5 pl-0.5 pr-2.5 text-[13px] font-medium text-fg-dim transition-colors hover:border-gilt-dim hover:text-fg",
switching && "pointer-events-none opacity-60",
)}
disabled={switching}
title="切换人物"
>
<AvatarMini name={currentDisplayName} active />
<span className="max-w-[120px] truncate">{currentDisplayName}</span>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
aria-hidden
className={cn("transition-transform", open && "rotate-180")}
>
<path
d="M4 6l4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
{open && (
<div className="absolute left-0 top-full z-50 mt-1 w-56 rounded-xl border border-border-glass bg-glass-strong p-1 shadow-xl backdrop-blur-xl">
{characters.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted">加载中…</div>
) : (
characters.map((c) => {
const isCurrent = c.id === currentCharacterId;
return (
<button
key={c.id}
type="button"
onClick={() => handleSwitch(c.id)}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] transition-colors",
isCurrent
? "bg-patina-wash text-patina"
: "text-fg-dim hover:bg-fg/5 hover:text-fg",
)}
>
<AvatarMini
name={c.display_name || c.name}
active={isCurrent}
/>
<span className="flex-1 truncate">
{c.display_name || c.name}
</span>
{c.status && (
<span className="shrink-0 rounded-full bg-surface px-1.5 py-px text-[10px] text-fg-faint">
{c.status}
</span>
)}
</button>
);
})
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,376 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { memoryApi } from "@/lib/api";
import { useChatStore } from "@/lib/stores/chat-store";
import { cn } from "@/lib/utils/cn";
import type { Character, RetrievedMemoryItem } from "@/types/api";
import type { RunStatus } from "@/types/domain";
const HEXAGRAM_STAGES = [
{ key: "起意", order: 1 },
{ key: "察势", order: 2 },
{ key: "定策", order: 3 },
{ key: "行事", order: 4 },
{ key: "归真", order: 5 },
{ key: "入藏", order: 6 },
] as const;
const RUN_TO_HEXAGRAM: Record<
string,
(typeof HEXAGRAM_STAGES)[number]["key"] | null
> = {
queued: "起意",
running: "定策",
waiting_confirmation: "行事",
completed: "入藏",
failed: "归真",
cancelled: "归真",
};
/**
* 镜背轨的实时上下文区:人物状态卡、六爻、息壤。
* embedded 模式不再生成独立侧栏,以便与历史会话共用一条镜背轨。
*/
export function ChatContextRail({
character,
className,
embedded = false,
}: {
character?: Character | null;
className?: string;
embedded?: boolean;
}) {
const [xirangOpen, setXirangOpen] = useState(true);
const [hexOpen, setHexOpen] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const runStatus = useChatStore(
(state) => state.runStatus,
) as RunStatus | null;
const memories = useChatStore((state) => state.retrievedMemories);
const setRetrievedMemories = useChatStore(
(state) => state.setRetrievedMemories,
);
const hexagrams = useMemo(() => {
const activeKey = runStatus ? RUN_TO_HEXAGRAM[runStatus] : null;
const activeOrder = activeKey
? (HEXAGRAM_STAGES.find((stage) => stage.key === activeKey)?.order ?? 0)
: 0;
return HEXAGRAM_STAGES.map((stage) => ({
...stage,
state:
stage.order < activeOrder
? ("done" as const)
: stage.order === activeOrder
? ("active" as const)
: ("future" as const),
}));
}, [runStatus]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
const data = await memoryApi.listMemories({ page_size: 10 });
const items: RetrievedMemoryItem[] = data.items.map((m) => ({
id: m.id,
title: m.title ?? "",
content: m.content,
source: m.source,
confidence: m.confidence ?? 1,
importance: m.importance ?? 0.5,
type: m.type,
}));
setRetrievedMemories(items);
} catch {
// 静默
} finally {
setRefreshing(false);
}
}, [setRetrievedMemories]);
const content = (
<>
{/* 人物状态卡:avatar-ring + 认识天数 + 情绪/内驱 stats */}
<StatusCard character={character} />
{/* 六爻:本次运行所处的爻位(可折叠,与息壤同构) */}
<div className="shrink-0 border-b border-rule px-4 py-4">
<div className="flex items-center gap-2">
<RailLabel label="六爻" sub="本次运行" />
<button
type="button"
onClick={() => setHexOpen((v) => !v)}
aria-expanded={hexOpen}
className="ml-auto inline-flex items-center gap-1 rounded-full border border-rule bg-transparent px-2 py-0.5 text-[9px] font-sans text-fg-faint transition-colors hover:border-gilt-dim hover:text-fg"
>
<span
className={cn(
"inline-block text-[7px] transition-transform duration-250",
hexOpen && "rotate-180",
)}
>
▼
</span>
{hexOpen ? "收起" : "展开"}
</button>
</div>
<div
className={cn(
"overflow-hidden transition-all duration-[400ms] ease-[cubic-bezier(0.4,0,0.2,1)]",
hexOpen ? "mt-2 max-h-[400px] opacity-100" : "max-h-0 opacity-0",
)}
>
<div className="flex flex-col gap-1">
{hexagrams.map((hexagram) => {
const isActive = hexagram.state === "active";
const isDone = hexagram.state === "done";
return (
<div
key={hexagram.key}
className="flex items-center gap-2 py-0.5"
>
<span
className={cn(
"mono w-[18px] text-right text-[10px]",
isActive
? "text-gilt"
: isDone
? "text-fg-dim"
: "text-fg-faint",
)}
>
{["初", "二", "三", "四", "五", "上"][hexagram.order - 1]}
</span>
<span
className={cn(
"flex-1 text-[12px]",
isActive
? "font-medium text-gilt"
: isDone
? "text-fg-dim"
: "text-fg-faint",
)}
>
{hexagram.key}
</span>
{/* 爻象进度:容器虚线(阴爻·未达),fill 覆盖为阳爻(已达·铜绿 / 当爻·鎏金流光) */}
<div className="h-[5px] w-16 shrink-0 overflow-hidden rounded-[2px] [background:repeating-linear-gradient(90deg,var(--rule)_0_4px,transparent_4px_8px)]">
<div
className={cn(
"h-full rounded-[2px] transition-all duration-500",
isActive &&
"w-full animate-hex-shimmer bg-[linear-gradient(90deg,var(--gilt-dim),var(--gilt),var(--gilt-dim))] bg-[length:200%_100%]",
isDone &&
"w-full bg-[linear-gradient(90deg,var(--patina-dim),var(--patina))]",
!isActive && !isDone && "w-0",
)}
/>
</div>
</div>
);
})}
</div>
</div>
</div>
{/* 息壤:从 memory.retrieved SSE 事件推送的记忆数据 */}
<div
className={cn(
"border-b border-rule px-4 py-4",
xirangOpen ? "flex min-h-0 flex-1 flex-col" : "shrink-0",
)}
>
<div className="flex items-center gap-2">
<RailLabel label="息壤" sub="浮现" />
<button
type="button"
onClick={handleRefresh}
disabled={refreshing}
className="ml-auto inline-flex items-center gap-1 rounded-full border border-rule bg-transparent px-2 py-0.5 text-[9px] font-sans text-fg-faint transition-colors hover:border-gilt-dim hover:text-fg disabled:opacity-40"
title="刷新记忆"
>
<span
className={cn(
"inline-block text-[10px] transition-transform duration-300",
refreshing && "animate-spin",
)}
>
↻
</span>
刷新
</button>
<button
type="button"
onClick={() => setXirangOpen((v) => !v)}
aria-expanded={xirangOpen}
className="inline-flex items-center gap-1 rounded-full border border-rule bg-transparent px-2 py-0.5 text-[9px] font-sans text-fg-faint transition-colors hover:border-gilt-dim hover:text-fg"
>
<span
className={cn(
"inline-block text-[7px] transition-transform duration-250",
xirangOpen && "rotate-180",
)}
>
▼
</span>
{xirangOpen ? "收起" : "展开"}
</button>
</div>
{xirangOpen && (
<div className="mt-2 flex-1 overflow-y-auto">
{memories.length > 0 ? (
<div className="flex flex-col gap-1">
{memories.map((m) => (
<div
key={m.id}
className="rounded-[3px] border border-rule/40 bg-surface/50 px-2.5 py-2 text-[11px] leading-relaxed text-fg-dim transition-colors hover:border-rule/70"
>
{m.title && (
<div className="mb-0.5 truncate text-[11px] font-medium text-fg">
{m.title}
</div>
)}
<div className="line-clamp-3">{m.content}</div>
<span className="mt-1 block text-[10px] text-fg-faint">
置信{" "}
<span className="font-medium text-patina">
{m.confidence.toFixed(2)}
</span>
{m.source && <> · {sourceLabel(m.source)}</>}
{m.importance > 0.7 && (
<>
{" "}
· <span className="text-gilt">重要</span>
</>
)}
</span>
</div>
))}
</div>
) : (
<div className="border-l-2 border-patina bg-patina-wash px-2.5 py-2 text-[11px] leading-relaxed text-fg-dim [border-radius:0_3px_3px_0]">
发送消息后,检索到的记忆将在此浮现。
<small className="mt-1 block text-[9px] text-fg-faint">
对话结束后自动沉淀
</small>
</div>
)}
</div>
)}
</div>
</>
);
if (embedded) return content;
return (
<aside
className={cn(
"hidden min-h-0 w-[220px] shrink-0 flex-col overflow-hidden border-r border-rule bg-sidebar md:flex",
className,
)}
>
{content}
</aside>
);
}
/**
* 人物状态卡:镜背轨顶部的"被照见者"——头像鎏金环 + 名号 + 认识天数,
* 下方两格 stats 呈现情绪与内驱。与设计稿 status-card 同源。
*/
function StatusCard({ character }: { character?: Character | null }) {
const sending = useChatStore((state) => state.sending);
const displayName = character?.display_name || character?.name;
const avatarChar = (displayName || "镜").slice(0, 1);
const moodLabel = character?.current_mood?.mood ?? "沉静";
// 认识天数:以人物创建时刻为关系起点;挂载时计算一次(会话期间不变)
const [days] = useState<number | null>(() => {
const created = character?.created_at;
if (!created) return null;
const d = Math.floor(
(Date.now() - new Date(created).getTime()) / 86_400_000,
);
return d > 0 ? d : 1;
});
// 内驱暂无独立后端数据源,以运行态作语义占位(运筹/守候),避免呈现虚假具体值
const driveLabel = sending ? "运筹" : "守候";
const driveHint = sending ? "倾听" : "回应";
return (
<div className="shrink-0 border-b border-rule px-[18px] pb-3 pt-[18px]">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border-[1.5px] border-patina-dim bg-patina-wash">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-patina font-serif text-base font-bold text-ink">
{avatarChar}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-serif text-[18px] font-bold tracking-[0.02em] text-fg">
{displayName ?? "未加载人物"}
</div>
<div className="mt-px flex items-center gap-1.5 text-[12px] text-fg-dim">
<span className="h-1.5 w-1.5 shrink-0 animate-pulse-ring rounded-full bg-patina" />
<span className="truncate">
{days ? `认识你第 ${days} 日` : "初识"} · {moodLabel}
</span>
</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<Stat k="情绪">
<span className="text-fg">{moodLabel}</span>
<small className="text-[11px] font-normal text-fg-dim">
{sending ? "运筹" : "平稳"}
</small>
</Stat>
<Stat k="内驱">
<span className="text-fg">{driveLabel}</span>
<small className="text-[11px] font-normal text-fg-dim">
{driveHint}
</small>
</Stat>
</div>
</div>
);
}
function Stat({ k, children }: { k: string; children: React.ReactNode }) {
return (
<div className="rounded-[5px] border border-rule bg-surface px-2.5 py-2">
<div className="text-[9px] uppercase tracking-[0.12em] text-fg-faint">
{k}
</div>
<div className="mt-0.5 flex items-baseline gap-1 text-[14px] font-medium">
{children}
</div>
</div>
);
}
/** 记忆来源中文标签 */
function sourceLabel(source: string): string {
const labels: Record<string, string> = {
user_explicit: "用户明确",
conversation: "对话推断",
material_import: "资料导入",
tool_result: "工具结果",
reflection: "反思沉淀",
};
return labels[source] ?? source;
}
function RailLabel({ label, sub }: { label: string; sub: string }) {
return (
<p className="mono text-[10px] uppercase tracking-[0.2em] text-fg-faint">
{label}
<span className="ml-1 font-sans text-[10px] font-normal normal-case text-fg-faint/60">
{sub}
</span>
</p>
);
}
@@ -0,0 +1,101 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatInput } from "./ChatInput";
import { useChatStore } from "@/lib/stores/chat-store";
describe("ChatInput", () => {
afterEach(() => {
useChatStore.setState({
sending: false,
thinkingMode: true,
thinkingEffort: "medium",
webSearchEnabled: false,
error: null,
});
});
const PLACEHOLDER = "输入消息,Enter 发送,Shift+Enter 换行";
it("渲染输入框", () => {
render(<ChatInput onSend={() => {}} />);
expect(screen.getByPlaceholderText(PLACEHOLDER)).toBeInTheDocument();
});
it("输入文本后按 Enter 发送", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
render(<ChatInput onSend={onSend} />);
const input = screen.getByPlaceholderText(PLACEHOLDER);
await user.type(input, "你好");
await user.keyboard("{Enter}");
expect(onSend).toHaveBeenCalledWith("你好");
});
it("空文本按 Enter 不发送", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
render(<ChatInput onSend={onSend} />);
await user.keyboard("{Enter}");
expect(onSend).not.toHaveBeenCalled();
});
it("disabled 状态下显示不同占位且不发送", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
render(<ChatInput onSend={onSend} disabled />);
expect(screen.getByPlaceholderText("请先创建会话")).toBeInTheDocument();
await user.keyboard("{Enter}");
expect(onSend).not.toHaveBeenCalled();
});
it("发送后清空输入框", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
render(<ChatInput onSend={onSend} />);
const input = screen.getByPlaceholderText(PLACEHOLDER);
await user.type(input, "消息");
await user.keyboard("{Enter}");
// 发送后 input 应清空
expect(input).toHaveValue("");
});
it("sending 时显示停止按钮", () => {
useChatStore.setState({ sending: true });
render(<ChatInput onSend={() => {}} onStop={() => {}} />);
expect(
screen.getByRole("button", { name: "停止生成" }),
).toBeInTheDocument();
});
it("不存在 sending 时不显示停止按钮", () => {
render(<ChatInput onSend={() => {}} onStop={() => {}} />);
expect(
screen.queryByRole("button", { name: "停止生成" }),
).not.toBeInTheDocument();
});
it("思考模式按钮可切换", async () => {
const user = userEvent.setup();
render(<ChatInput onSend={() => {}} />);
const btn = screen.getByRole("button", { name: /思考模式/ });
expect(btn).toBeInTheDocument();
await user.click(btn);
expect(useChatStore.getState().thinkingMode).toBe(false);
});
it("超长消息显示错误", async () => {
const longText = "a".repeat(33000);
const onSend = vi.fn();
const user = userEvent.setup();
render(<ChatInput onSend={onSend} />);
const input = screen.getByPlaceholderText(PLACEHOLDER);
// 用 click + keyboard 避免 userEvent.type 逐字符输入 33000 字符
await user.click(input);
await user.paste(longText);
await user.keyboard("{Enter}");
expect(onSend).not.toHaveBeenCalled();
expect(useChatStore.getState().error).toBe("单条消息过长,请精简后发送。");
});
});
+368
View File
@@ -0,0 +1,368 @@
"use client";
import { useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent } from "react";
import { cn } from "@/lib/utils/cn";
import { useChatStore } from "@/lib/stores/chat-store";
import { useVoiceInput } from "@/lib/hooks/useVoiceInput";
import type { ThinkingEffort } from "@/types/thinking";
/** 单条消息字节上限(API §5.6,默认 32 KiB) */
const MAX_TEXT_BYTES = 32 * 1024;
/** textarea 最大高度:超出后内部滚动 */
const TEXTAREA_MAX_H = "10rem";
const EFFORT_OPTIONS: { value: ThinkingEffort; label: string; hint: string }[] =
[
{ value: "low", label: "low", hint: "快速 · 简短思考" },
{ value: "medium", label: "medium", hint: "均衡 · 适度推演" },
{ value: "high", label: "high", hint: "深思 · 详尽推演" },
];
/**
* 铜镜鉴心 v2 铭文输入框(Composer)。
*
* 铭文刻线分隔 + 透明输入框 + 呈镜按钮。思考模式/搜索开关移至顶部
* 以 pill 容纳,此处只保留最简的发送行为。
*/
export function ChatInput({
onSend,
onFollowUp,
disabled,
onStop,
stopping,
}: {
onSend: (content: string) => void;
onFollowUp?: (content: string) => void;
disabled?: boolean;
onStop?: () => void;
stopping?: boolean;
}) {
const sending = useChatStore((s) => s.sending);
const thinkingMode = useChatStore((s) => s.thinkingMode);
const thinkingEffort = useChatStore((s) => s.thinkingEffort);
const setThinkingMode = useChatStore((s) => s.setThinkingMode);
const setThinkingEffort = useChatStore((s) => s.setThinkingEffort);
const webSearchEnabled = useChatStore((s) => s.webSearchEnabled);
const setWebSearchEnabled = useChatStore((s) => s.setWebSearchEnabled);
const [text, setText] = useState("");
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
/** 将转写文本追加到当前输入框(在已有内容后以空格分隔)。 */
const insertTranscript = (t: string) => {
const trimmed = t.trim();
if (!trimmed) return;
setText((prev) => (prev ? `${prev} ${trimmed}` : trimmed));
// 转写完成后将焦点归还输入框
requestAnimationFrame(() => textareaRef.current?.focus());
};
const voice = useVoiceInput({
language: "zh",
onResult: insertTranscript,
onError: (msg) => useChatStore.getState().setError(msg),
});
const send = () => {
const content = text.trim();
// 运行中(sending)也允许发送:经 ChatShell handleSend 分流为 steer,
// 让用户能在生成过程中补充指引;disabled 仅在无会话时阻断。
if (!content || disabled) return;
if (new Blob([content]).size > MAX_TEXT_BYTES) {
useChatStore.getState().setError("单条消息过长,请精简后发送。");
return;
}
onSend(content);
setText("");
};
const sendFollowUp = () => {
const content = text.trim();
if (!content || disabled) return;
onFollowUp?.(content);
setText("");
};
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
const sendDisabled = disabled || sending || !text.trim();
return (
<div className="border-t border-rule px-5 py-3">
{/* 铭文输入区 */}
<div className="flex items-end gap-3">
<textarea
ref={textareaRef}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
placeholder={
disabled
? "请先创建会话"
: sending
? "Enter 补充指引(steer)· 点「追加」排队后续任务"
: "输入消息,Enter 发送,Shift+Enter 换行"
}
disabled={disabled}
style={{ "--textarea-max-h": TEXTAREA_MAX_H } as CSSProperties}
className={cn(
"[field-sizing:content] block w-full resize-none overflow-y-auto border-none bg-transparent px-0 py-2 text-sm text-fg outline-none placeholder:text-fg-faint disabled:opacity-50",
"min-h-[2.2rem] max-h-[var(--textarea-max-h)]",
)}
/>
{/* 语音输入:点击开始/停止录音并转写(与"呈镜"同款样式)*/}
<button
type="button"
onClick={() => void voice.toggle()}
disabled={disabled}
aria-pressed={voice.status === "recording"}
aria-label={
voice.status === "recording"
? "停止录音并转写"
: voice.status === "transcribing"
? "正在转写语音"
: "语音输入"
}
title={
voice.status === "recording"
? "停止录音并转写"
: voice.status === "transcribing"
? "正在转写…"
: "语音输入"
}
className={cn(
"font-serif inline-flex shrink-0 items-center justify-center gap-1.5 rounded border px-4 py-1.5 text-sm tracking-[0.08em] transition-colors focus-visible:outline-none focus-visible:ring-2",
voice.status === "recording"
? "border-cinnabar-dim bg-transparent text-cinnabar focus-visible:ring-cinnabar/40"
: voice.status === "transcribing"
? "border-gilt-dim bg-transparent text-gilt focus-visible:ring-gilt/40"
: "border-patina-dim bg-transparent text-patina hover:border-patina hover:text-patina-bright focus-visible:ring-patina/40",
"disabled:cursor-not-allowed disabled:border-rule disabled:text-fg-faint",
)}
>
{voice.status === "transcribing" ? (
<span className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<rect
x="6"
y="2"
width="4"
height="7"
rx="2"
stroke="currentColor"
strokeWidth="1.2"
/>
<path
d="M3.5 7.5a4.5 4.5 0 0 0 9 0M8 12v2.5"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
)}
{voice.status === "recording"
? "停录"
: voice.status === "transcribing"
? "转写中"
: "语音"}
</button>
{sending && onStop ? (
<>
{onFollowUp && (
<button
type="button"
onClick={sendFollowUp}
disabled={!text.trim() || stopping}
title="追加后续任务(当前任务完成后执行)"
aria-label="追加后续任务"
className={cn(
"font-serif flex h-8 shrink-0 items-center justify-center rounded border border-patina-dim bg-patina/10 px-3 text-xs tracking-[0.06em] text-patina transition-all hover:bg-patina/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-patina/40",
"disabled:cursor-not-allowed disabled:border-rule disabled:bg-transparent disabled:text-fg-faint",
)}
>
追加
</button>
)}
<button
type="button"
onClick={onStop}
disabled={stopping}
title={stopping ? "正在停止…" : "停止生成"}
aria-label="停止生成"
className={cn(
"flex h-8 w-16 shrink-0 items-center justify-center rounded border border-cinnabar-dim bg-cinnabar/10 text-cinnabar text-xs transition-all hover:bg-cinnabar/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cinnabar/40",
stopping && "cursor-not-allowed opacity-50",
)}
>
{stopping ? (
<span className="h-3 w-3 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
"终止"
)}
</button>
</>
) : (
<button
type="button"
onClick={send}
disabled={sendDisabled}
title="呈镜(Enter)"
aria-label="呈镜发送"
className={cn(
"font-serif inline-flex shrink-0 items-center justify-center gap-1.5 rounded border border-patina-dim bg-transparent px-4 py-1.5 text-sm tracking-[0.08em] text-patina transition-colors hover:border-patina hover:text-patina-bright focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-patina/40",
"disabled:cursor-not-allowed disabled:border-rule disabled:text-fg-faint",
)}
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<circle cx="8" cy="8" r="1.3" fill="currentColor" />
<circle
cx="8"
cy="8"
r="3.3"
stroke="currentColor"
strokeWidth="1.1"
opacity="0.85"
/>
<circle
cx="8"
cy="8"
r="5.6"
stroke="currentColor"
strokeWidth="1.1"
opacity="0.5"
/>
</svg>
呈镜
</button>
)}
</div>
{/* 控制开关行 */}
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setThinkingMode(!thinkingMode)}
aria-pressed={thinkingMode}
aria-label={`思考模式${thinkingMode ? "已开启" : "已关闭"}`}
title="思考模式"
className={cn(
"inline-flex items-center gap-1.5 rounded border px-2 py-1 text-[10px] font-medium transition-colors",
thinkingMode
? "border-gilt-dim bg-gilt/10 text-gilt"
: "border-rule text-fg-faint hover:text-fg-dim",
)}
>
<svg
width="11"
height="11"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<path
d="M8 1.5A4 4 0 0 0 5.2 8.4c.3.4.5.9.5 1.4v.2a1 1 0 0 0 1 1h2.6a1 1 0 0 0 1-1v-.2c0-.5.2-1 .5-1.4A4 4 0 0 0 8 1.5Z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
/>
<path
d="M6.5 13h3M7 14.5h2"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
深虑
</button>
{thinkingMode && (
<div
role="radiogroup"
aria-label="思考力度"
className="inline-flex items-center gap-0.5 rounded-full border border-gilt-dim p-0.5"
>
{EFFORT_OPTIONS.map((opt) => {
const active = thinkingEffort === opt.value;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={active}
title={opt.hint}
onClick={() => setThinkingEffort(opt.value)}
className={cn(
"rounded-full px-2.5 py-0.5 font-mono text-[11px] font-medium transition-colors",
active
? "bg-gilt-faint text-gilt"
: "text-fg-faint hover:text-fg-dim",
)}
>
{opt.label}
</button>
);
})}
</div>
)}
<button
type="button"
onClick={() => setWebSearchEnabled(!webSearchEnabled)}
aria-pressed={webSearchEnabled}
className={cn(
"inline-flex items-center gap-1.5 rounded border px-2 py-1 text-[10px] font-medium transition-colors",
webSearchEnabled
? "border-patina-dim bg-patina/10 text-patina"
: "border-rule text-fg-faint hover:text-fg-dim",
)}
>
<svg
width="11"
height="11"
viewBox="0 0 16 16"
fill="none"
aria-hidden
>
<circle
cx="7"
cy="7"
r="4.5"
stroke="currentColor"
strokeWidth="1.2"
/>
<path
d="M10.5 10.5l3 3"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
博闻
</button>
</div>
</div>
);
}
+831
View File
@@ -0,0 +1,831 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { ActiveCharacterHeader } from "./ActiveCharacterHeader";
import { ChatContextRail } from "./ChatContextRail";
import { ChatInput } from "./ChatInput";
import { FilePreviewPanel } from "./FilePreviewPanel";
import { MessageList } from "./MessageList";
import { SessionSidebar } from "./SessionSidebar";
import { MirrorHero } from "./MirrorHero";
import { PlanPanel } from "./PlanPanel";
import { TextSkeleton } from "@/components/ui/Skeleton";
import { ErrorTraceLink } from "@/components/shared/ErrorTraceLink";
import { describeError, traceIdOf } from "@/lib/utils/error";
import { cn } from "@/lib/utils/cn";
import { charactersApi, chatApi, runsApi } from "@/lib/api";
import { useRunStream } from "@/lib/hooks/useRunStream";
import { useGlobalEventStream } from "@/lib/hooks/useGlobalEventStream";
import { useChatStore } from "@/lib/stores/chat-store";
import type { MessageVersion } from "@/lib/stores/chat-store";
import type { Character, ModelGatewayStatus, RunEvent } from "@/types/api";
import type { Plan } from "@/types/plan";
/**
* 对话编排壳:聚合 Header / 消息列表 / 输入 / 洛书计划侧栏,编排会话创建、
* 消息发送与 SSE 事件回流(含思考摘要 reasoning)。初始人物由 Server Component
* 预取后注入。
*/
export function ChatShell({
initialCharacter,
gateway,
}: {
initialCharacter: Character | null;
gateway?: ModelGatewayStatus | null;
}) {
const sessionId = useChatStore((s) => s.sessionId);
const error = useChatStore((s) => s.error);
const errorTraceId = useChatStore((s) => s.errorTraceId);
const activeRunId = useChatStore((s) => s.activeRunId);
const sending = useChatStore((s) => s.sending);
const thinkingMode = useChatStore((s) => s.thinkingMode);
const thinkingEffort = useChatStore((s) => s.thinkingEffort);
const webSearchEnabled = useChatStore((s) => s.webSearchEnabled);
const modelHint = useChatStore((s) => s.modelHint);
const setModelHint = useChatStore((s) => s.setModelHint);
const setRetrievedMemories = useChatStore((s) => s.setRetrievedMemories);
const clearRetrievedMemories = useChatStore((s) => s.clearRetrievedMemories);
const addSession = useChatStore((s) => s.addSession);
const setSessions = useChatStore((s) => s.setSessions);
const addNewSessionId = useChatStore((s) => s.addNewSessionId);
const markSessionSeen = useChatStore((s) => s.markSessionSeen);
const switchSession = useChatStore((s) => s.switchSession);
const setActiveRunId = useChatStore((s) => s.setActiveRunId);
const truncateMessagesAfter = useChatStore((s) => s.truncateMessagesAfter);
const initSession = useChatStore((s) => s.initSession);
const hydrateMessages = useChatStore((s) => s.hydrateMessages);
const pushUserMessage = useChatStore((s) => s.pushUserMessage);
const beginAssistantStream = useChatStore((s) => s.beginAssistantStream);
const appendDelta = useChatStore((s) => s.appendDelta);
const finalizeAssistant = useChatStore((s) => s.finalizeAssistant);
const appendReasoningDelta = useChatStore((s) => s.appendReasoningDelta);
const finalizeReasoning = useChatStore((s) => s.finalizeReasoning);
const beginToolCall = useChatStore((s) => s.beginToolCall);
const completeToolCall = useChatStore((s) => s.completeToolCall);
const requireToolConfirmation = useChatStore(
(s) => s.requireToolConfirmation,
);
const markToolCallResolving = useChatStore((s) => s.markToolCallResolving);
const setRunStatus = useChatStore((s) => s.setRunStatus);
const setSending = useChatStore((s) => s.setSending);
const setError = useChatStore((s) => s.setError);
const sidebarCollapsed = useChatStore((s) => s.sidebarCollapsed);
const toggleSidebar = useChatStore((s) => s.toggleSidebar);
const activePreviewIndex = useChatStore((s) => s.activePreviewIndex);
const newSessionIds = useChatStore((s) => s.newSessionIds);
const [creating, setCreating] = useState(false);
const [stopping, setStopping] = useState(false);
const [initializing, setInitializing] = useState(true);
const [mobileRailOpen, setMobileRailOpen] = useState(false);
const [railView, setRailView] = useState<"sessions" | "now">("sessions");
const [characterNameMap, setCharacterNameMap] = useState<
Record<string, string>
>({});
const [activePlan, setActivePlan] = useState<Plan | null>(null);
useRunStream(
activeRunId,
{
onEvent: handleRunEvent,
onError: () => setError("运行事件流连接中断,正在自动重连…"),
},
{
thinking: thinkingMode,
effort: thinkingEffort,
characterName: initialCharacter?.name,
},
);
// 全局一炁事件流:接收 proactive.session(叩心枢主动会话)等全局广播事件
useGlobalEventStream({
onEvent(event) {
if (event.type === "proactive.session") {
// 主动会话创建后刷新会话列表,并标记该会话为"新"(前端通知红点)
const newSessionId = event.payload?.session_id as string | undefined;
if (newSessionId) {
addNewSessionId(newSessionId);
}
chatApi
.listSessions()
.then((res) => setSessions(res.items))
.catch(() => {
/* 刷新失败静默 */
});
} else if (event.type === "proactive.message") {
// 追加到已有会话的消息:若正是当前在看的会话,立刻拉取完整消息列表
const msgSessionId = event.payload?.session_id as string | undefined;
if (
msgSessionId &&
msgSessionId === useChatStore.getState().sessionId
) {
chatApi
.listMessages(msgSessionId, { page_size: 100 })
.then((res) => hydrateMessages(res.items))
.catch(() => {});
} else if (msgSessionId) {
// 非当前会话:标记红点提示
addNewSessionId(msgSessionId);
chatApi
.listSessions()
.then((res) => setSessions(res.items))
.catch(() => {});
}
}
},
});
// 会话恢复:sessionId 来自持久化存储(localStorage),Server Component 无法预知,
// 故在客户端对齐一次最新历史。此为持久会话的恢复,非「页面初始数据请求」
// (与 useRunStream 同理);fetchedSessionRef 确保同一会话只拉一次。
const fetchedSessionRef = useRef<string | null>(null);
useEffect(() => {
if (!sessionId || fetchedSessionRef.current === sessionId) return;
fetchedSessionRef.current = sessionId;
chatApi
.listMessages(sessionId, { page_size: 100 })
.then((res) => hydrateMessages(res.items))
.catch(() => {});
}, [sessionId, hydrateMessages]);
// 模型默认值:modelHint 未设置时以模型网关默认初始化;用户已持久化的偏好则保留
useEffect(() => {
if (gateway?.default_model_hint && !modelHint) {
setModelHint(gateway.default_model_hint);
}
}, [gateway, modelHint, setModelHint]);
// 构造人物名映射(character_id → display_name),供侧栏显示会话归属
useEffect(() => {
let cancelled = false;
charactersApi
.listCharacters({ page_size: 50 })
.then((res) => {
if (cancelled) return;
const map: Record<string, string> = {};
for (const c of res.items) {
map[c.id] = c.display_name || c.name;
}
setCharacterNameMap(map);
})
.catch(() => {
/* 拉取失败静默 */
});
return () => {
cancelled = true;
};
}, []);
// 会话列表:不按人物过滤,显示当前用户全部会话。
// 恢复策略:当前持久化的会话若有对话内容(title 非空)则保留;否则按 updated_at
// 降序选最近的有内容会话;都没有则任意最近会话。关键:跳过 title 为空的空会话
// (刚建、无消息),避免落到空会话上看不到历史。
useEffect(() => {
let cancelled = false;
chatApi
.listSessions()
.then((res) => {
if (cancelled) return;
setSessions(res.items);
const sorted = [...res.items].sort((a, b) =>
(b.updated_at ?? "").localeCompare(a.updated_at ?? ""),
);
const withContent = sorted.filter((s) => s.title);
const current = useChatStore.getState().sessionId;
const currentEntry = current
? res.items.find((s) => s.session_id === current)
: undefined;
// 当前会话有内容则保留;否则取最近有内容的;都没有则取最近任意会话
const target = currentEntry?.title
? currentEntry
: (withContent[0] ?? sorted[0]);
if (target) {
if (target.session_id !== current) {
switchSession(target.session_id);
}
} else if (!currentEntry) {
// 列表为空且当前会话也已失效:新建
void handleCreateSession();
}
setInitializing(false);
})
.catch(() => {
if (cancelled) return;
if (!useChatStore.getState().sessionId) {
void handleCreateSession();
}
setInitializing(false);
});
return () => {
cancelled = true;
};
// handleCreateSession 为组件内函数(每次渲染重建),仅在人物变化时重拉
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialCharacter?.id, setSessions, switchSession]);
function handleRunEvent(event: RunEvent) {
const payload = event.payload ?? {};
const runId = event.run_id;
switch (event.type) {
case "plan.created":
setActivePlan(planFromCreatedEvent(payload));
break;
case "plan.step.started":
case "plan.step.completed":
case "plan.step.failed":
setActivePlan((plan) => updatePlanStep(plan, payload));
break;
case "reasoning.delta":
appendReasoningDelta(runId, String(payload.delta ?? ""));
break;
case "reasoning.completed":
finalizeReasoning(runId);
break;
case "message.delta":
appendDelta(runId, String(payload.delta ?? ""));
break;
case "message.completed":
finalizeAssistant(
runId,
String(payload.message_id ?? `final:${runId}`),
typeof payload.content === "string" ? payload.content : undefined,
);
break;
// —— 工具调用事件(后端统一用 completed(status=error) 承载失败,不发 failed)——
case "tool.started":
beginToolCall(runId, {
tool_name: String(payload.tool_name ?? ""),
tool_call_id: String(payload.tool_call_id ?? ""),
args: typeof payload.args === "string" ? payload.args : "",
});
break;
case "tool.completed":
completeToolCall(runId, {
tool_name: String(payload.tool_name ?? ""),
tool_call_id: String(payload.tool_call_id ?? ""),
status: (["success", "error", "denied", "skipped"].includes(
payload.status as string,
)
? payload.status
: "error") as "success" | "error" | "denied" | "skipped",
result: typeof payload.result === "string" ? payload.result : "",
});
break;
case "tool.requires_confirmation":
setActivePlan((plan) =>
updatePlanStepForTool(plan, String(payload.tool_name ?? "")),
);
requireToolConfirmation(runId, {
confirmation_id: String(payload.confirmation_id ?? ""),
tool_name: String(payload.tool_name ?? ""),
tool_call_id: String(payload.tool_call_id ?? ""),
risk_level: String(payload.risk_level ?? ""),
reason: String(payload.reason ?? ""),
args: typeof payload.args === "string" ? payload.args : "",
});
break;
case "run.started":
setRunStatus("running");
break;
case "run.completed":
setRunStatus("completed");
setSending(false);
// 终态后解除订阅:后端会在 run 终态时关闭 SSE,若不清空 activeRunId,
// 存活的 EventSource 会据规范自动重连并反复触发 onError 提示。
setActiveRunId(null);
// 首轮对话后端可能已自动重命名会话,刷新侧栏以显示新标题。
chatApi
.listSessions()
.then((res) => setSessions(res.items))
.catch(() => {
/* 刷新失败静默,不影响对话主流程 */
});
break;
case "run.failed":
finalizeAssistant(runId, `failed:${runId}`);
setRunStatus("failed");
setSending(false);
setError(
typeof payload.error_message === "string"
? payload.error_message
: "运行失败,请稍后重试。",
);
clearRetrievedMemories();
setActiveRunId(null);
break;
case "memory.retrieved":
if (Array.isArray(payload.memories)) {
setRetrievedMemories(payload.memories);
}
break;
case "run.cancelled":
finalizeAssistant(runId, `cancelled:${runId}`);
setRunStatus("cancelled");
setSending(false);
clearRetrievedMemories();
setActiveRunId(null);
break;
default:
break;
}
}
async function handleCreateSession() {
setCreating(true);
setError(null);
try {
const session = await chatApi.createSession({
character_id: initialCharacter?.id,
});
initSession(session);
addSession(session);
} catch (err) {
setError(describeError(err), traceIdOf(err));
} finally {
setCreating(false);
}
}
// 会话初始化(恢复最近会话 / 新建)由上方 listSessions useEffect 统一承担:
// sessionId 无效时优先恢复最近历史会话,仅在无任何历史会话时才新建。
async function handleSend(content: string) {
if (!sessionId) return;
// 运行中发送视为 steer:在当前工具批次后注入指引,不新建消息或 Run。
if (sending && activeRunId) {
try {
await runsApi.steerRun(activeRunId, content);
} catch (err) {
setError(describeError(err), traceIdOf(err));
}
return;
}
pushUserMessage(content);
setActivePlan(null);
setSending(true);
setError(null);
const idempotencyKey = `${sessionId}:${Date.now()}`;
try {
const res = await chatApi.sendMessage(
sessionId,
{
content,
model_hint: modelHint ?? undefined,
thinking: thinkingMode,
reasoning_effort: thinkingMode ? thinkingEffort : undefined,
web_search: webSearchEnabled,
},
idempotencyKey,
);
beginAssistantStream(res.run_id);
} catch (err) {
setSending(false);
setRunStatus("failed");
setError(describeError(err), traceIdOf(err));
}
}
async function handleStop() {
if (!activeRunId) return;
const runId = activeRunId;
setStopping(true);
try {
await runsApi.cancelRun(runId);
} catch {
/* 静默:即便取消请求失败也本地中止,定格已生成内容 */
}
finalizeAssistant(runId, `stopped:${runId}`);
setRunStatus("cancelled");
setSending(false);
setActiveRunId(null);
setStopping(false);
}
async function handleFollowUp(content: string) {
if (!activeRunId) return;
// follow-up:当前任务完成后排队追加新任务(区别于 steer 修正当前方向)。
try {
await runsApi.followUpRun(activeRunId, content);
} catch (err) {
setError(describeError(err), traceIdOf(err));
}
}
async function handleSessionSelect(
sessionId: string,
sessionCharacterId: string,
) {
const currentId = useChatStore.getState().sessionId;
if (sessionId === currentId) return;
// 切换 session 时同步激活该会话所属的人物,使后端操作不受 character_id 校验阻挡
if (sessionCharacterId && sessionCharacterId !== initialCharacter?.id) {
try {
await charactersApi.activateCharacter(sessionCharacterId);
} catch {
/* 激活失败不阻塞切换 */
}
}
markSessionSeen(sessionId);
switchSession(sessionId, sessionCharacterId);
}
async function handleRegenerate() {
if (!sessionId || sending) return;
const msgs = useChatStore.getState().messages;
let lastUserIdx = -1;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === "user") {
lastUserIdx = i;
break;
}
}
if (lastUserIdx === -1) return;
// 保留旧 assistant 的版本历史,迁入新消息:重答不是丢弃旧版本,而是叠加新版
const oldAssistant = msgs
.slice(lastUserIdx + 1)
.find((m) => m.role === "assistant" && !m.streaming);
let priorVersions: MessageVersion[] | undefined;
if (oldAssistant) {
const prev = oldAssistant.versions ?? [];
priorVersions =
prev.length > 0
? prev
: oldAssistant.content
? [
{
content: oldAssistant.content,
created_at:
oldAssistant.created_at ?? new Date().toISOString(),
},
]
: undefined;
}
const content = msgs[lastUserIdx].content;
truncateMessagesAfter(lastUserIdx);
setSending(true);
setError(null);
const idempotencyKey = `${sessionId}:regen:${Date.now()}`;
try {
const res = await chatApi.sendMessage(
sessionId,
{
content,
model_hint: modelHint ?? undefined,
thinking: thinkingMode,
reasoning_effort: thinkingMode ? thinkingEffort : undefined,
web_search: webSearchEnabled,
prior_versions: priorVersions,
},
idempotencyKey,
);
beginAssistantStream(res.run_id, { priorVersions });
} catch (err) {
setSending(false);
setRunStatus("failed");
setError(describeError(err), traceIdOf(err));
}
}
/** 确认卡片回调:批准高风险工具调用(run-scoped)。成功后状态回 running 等后续 tool.completed */
async function handleApproveToolCall(
runId: string,
confirmationId: string,
toolCallId: string,
) {
try {
await runsApi.approveRunConfirmation(runId, confirmationId);
markToolCallResolving(runId, toolCallId);
} catch (err) {
// 410 confirmation_expired / 404 not_found / 503 unavailable 等统一经 describeError
setError(describeError(err), traceIdOf(err));
}
}
/** 确认卡片回调:拒绝高风险工具调用(run-scoped)。本地不动 status,等 tool.completed(status=denied) */
async function handleDenyToolCall(
runId: string,
confirmationId: string,
toolCallId: string,
) {
try {
await runsApi.denyRunConfirmation(runId, confirmationId);
markToolCallResolving(runId, toolCallId);
} catch (err) {
setError(describeError(err), traceIdOf(err));
}
}
return (
<div className="flex min-h-0 flex-1 overflow-hidden">
<section
id="chat-region"
aria-label="镜面对话"
className="relative flex min-h-0 w-full flex-1 overflow-hidden border-rule bg-ink"
>
{/* 镜背轨:铭文往来 / 此刻 两面照见,tab 切换 */}
{!sidebarCollapsed && (
<div className="relative hidden shrink-0 md:flex">
<aside className="min-h-0 w-70 flex flex-col overflow-hidden border-r border-rule bg-sidebar">
<RailTabs view={railView} onChange={setRailView} />
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{railView === "now" ? (
sessionId ? (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<ChatContextRail embedded character={initialCharacter} />
</div>
) : null
) : (
<SessionSidebar
embedded
onCreate={handleCreateSession}
creating={creating}
characterNameMap={characterNameMap}
onSessionSelect={handleSessionSelect}
newSessionIds={newSessionIds}
/>
)}
</div>
</aside>
</div>
)}
{/* 镜面:人物、对话流与铭文输入框 */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-[linear-gradient(180deg,rgba(255,255,255,0.035),rgba(255,255,255,0.006)_28%,transparent)]">
<ActiveCharacterHeader
character={initialCharacter}
models={gateway?.models}
railCollapsed={sidebarCollapsed}
onToggleRail={toggleSidebar}
/>
<div className="flex shrink-0 items-center border-b border-rule px-3 py-1.5 md:hidden">
<button
type="button"
onClick={() => setMobileRailOpen(true)}
className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-[11px] text-fg-faint transition-colors hover:bg-fg/5 hover:text-fg"
>
<ChevronRightIcon />
镜背 · 往来
{newSessionIds.length > 0 && (
<span className="h-1.5 w-1.5 rounded-full bg-cinnabar" />
)}
</button>
</div>
{error && (
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-cinnabar-dim bg-cinnabar/10 px-4 py-1.5 text-xs text-cinnabar backdrop-blur-sm">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">{error}</span>
{errorTraceId && <ErrorTraceLink traceId={errorTraceId} />}
</span>
<button
type="button"
onClick={() => setError(null)}
className="shrink-0 underline"
>
关闭
</button>
</div>
)}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{!sessionId ? (
creating || initializing ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6">
<TextSkeleton lines={2} className="w-64" />
</div>
) : (
<MirrorHero character={initialCharacter} />
)
) : (
<>
<MessageList
onApproveToolCall={handleApproveToolCall}
onDenyToolCall={handleDenyToolCall}
onRegenerate={handleRegenerate}
characterName={
initialCharacter?.display_name || initialCharacter?.name
}
/>
{activePlan && <PlanPanel plan={activePlan} />}
<ChatInput
onSend={handleSend}
onFollowUp={handleFollowUp}
onStop={handleStop}
stopping={stopping}
/>
</>
)}
</div>
</div>
{/* 右侧文件预览面板:独立于聊天区 */}
{activePreviewIndex >= 0 && <FilePreviewPanel />}
{/* 移动端镜背抽屉:限制在对话区域内,不覆盖全局导航 */}
{mobileRailOpen && (
<div className="absolute inset-0 z-50 overflow-hidden md:hidden">
<button
type="button"
aria-label="关闭镜背"
onClick={() => setMobileRailOpen(false)}
className="absolute inset-0 cursor-default bg-black/50 backdrop-blur-sm"
/>
<aside className="absolute inset-y-0 left-0 flex w-[min(84vw,320px)] min-h-0 flex-col overflow-hidden border-r border-rule bg-sidebar shadow-2xl">
<div className="flex shrink-0 items-center justify-between border-b border-rule px-4 py-3">
<span className="font-serif text-sm text-fg-dim">镜背</span>
<button
type="button"
onClick={() => setMobileRailOpen(false)}
className="rounded border border-rule px-2 py-1 text-[10px] text-fg-faint hover:text-fg"
>
合镜
</button>
</div>
<RailTabs view={railView} onChange={setRailView} />
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{railView === "now" ? (
sessionId ? (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<ChatContextRail embedded character={initialCharacter} />
</div>
) : null
) : (
<SessionSidebar
embedded
onCreate={handleCreateSession}
creating={creating}
characterNameMap={characterNameMap}
onSessionSelect={(selectedSessionId, characterId) => {
setMobileRailOpen(false);
void handleSessionSelect(selectedSessionId, characterId);
}}
newSessionIds={newSessionIds}
/>
)}
</div>
</aside>
</div>
)}
</section>
</div>
);
}
function planFromCreatedEvent(payload: Record<string, unknown>): Plan {
const steps = Array.isArray(payload.steps)
? payload.steps.flatMap((value) => {
if (
!isRecord(value) ||
typeof value.id !== "string" ||
typeof value.title !== "string"
) {
return [];
}
return [
{
id: value.id,
title: value.title,
description: stringValue(value.description) ?? "",
tool: stringValue(value.tool),
status: planStepStatus(value.status),
risk_level: riskLevel(value.risk_level),
},
];
})
: [];
return {
id: stringValue(payload.task_id) ?? "active-plan",
goal_id: stringValue(payload.task_id) ?? "active-plan",
steps,
assumptions: stringItems(payload.assumptions),
risks: stringItems(payload.risks),
required_tools: steps.flatMap((step) => (step.tool ? [step.tool] : [])),
success_criteria: [],
status: "running",
};
}
function updatePlanStep(
plan: Plan | null,
payload: Record<string, unknown>,
): Plan | null {
const stepId = stringValue(payload.step_id);
if (!plan || !stepId) return plan;
return {
...plan,
steps: plan.steps.map((step) =>
step.id === stepId
? {
...step,
status: planStepStatus(payload.status),
result_summary: stringValue(payload.result_summary),
}
: step,
),
};
}
function updatePlanStepForTool(
plan: Plan | null,
toolName: string,
): Plan | null {
if (!plan || !toolName) return plan;
const step = plan.steps.find(
(item) => item.tool === toolName && item.status !== "completed",
);
if (!step) return plan;
return {
...plan,
steps: plan.steps.map((item) =>
item.id === step.id ? { ...item, status: "waiting_confirmation" } : item,
),
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function stringItems(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function planStepStatus(value: unknown): Plan["steps"][number]["status"] {
return value === "running" ||
value === "waiting_confirmation" ||
value === "completed" ||
value === "failed" ||
value === "skipped"
? value
: "pending";
}
function riskLevel(value: unknown): Plan["steps"][number]["risk_level"] {
return value === "L1" || value === "L2" || value === "L3" || value === "L4"
? value
: "L0";
}
function ChevronRightIcon() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M6 3l5 5-5 5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
/** 镜背轨双视图切换:铭文往来(会话历史)/ 此刻(人物状态·六爻·息壤)。
* 设计稿 .rail-tab 同源——mono 字标、active 鎏金、底部短下划线。 */
function RailTabs({
view,
onChange,
}: {
view: "sessions" | "now";
onChange: (view: "sessions" | "now") => void;
}) {
const tabs: { key: "sessions" | "now"; label: string }[] = [
{ key: "sessions", label: "铭文往来" },
{ key: "now", label: "此刻" },
];
return (
<div className="flex shrink-0 border-b border-rule">
{tabs.map((tab) => {
const active = view === tab.key;
return (
<button
key={tab.key}
type="button"
onClick={() => onChange(tab.key)}
aria-pressed={active}
className={cn(
"mono relative flex-1 py-2.5 text-[11px] tracking-[0.08em] transition-colors",
active ? "text-gilt" : "text-fg-faint hover:text-fg-dim",
)}
>
{tab.label}
{active && (
<span className="absolute inset-x-[24%] -bottom-px h-[1.5px] bg-gilt" />
)}
</button>
);
})}
</div>
);
}
@@ -0,0 +1,364 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { readSandboxFile } from "@/lib/api/sandbox";
import { useChatStore } from "@/lib/stores/chat-store";
import { cn } from "@/lib/utils/cn";
import { MarkdownContent } from "@/components/chat/MarkdownContent";
type ViewMode = "source" | "preview";
const MIN_PANEL_WIDTH = 280;
const MAX_PANEL_FRACTION = 0.6;
/**
* 右侧独立文件预览面板,支持多文件标签页切换。
*/
export function FilePreviewPanel() {
const previewFiles = useChatStore((s) => s.previewFiles);
const activeIndex = useChatStore((s) => s.activePreviewIndex);
const switchFile = useChatStore((s) => s.switchPreviewFile);
const closePanel = useChatStore((s) => s.closePreviewPanel);
const characterId = useChatStore((s) => s.characterId);
const file = previewFiles[activeIndex] ?? null;
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [mimeType, setMimeType] = useState("");
const [sourceText, setSourceText] = useState("");
const [viewMode, setViewMode] = useState<ViewMode>("preview");
const objectUrlRef = useRef<string | null>(null);
// 拖拽:使用 ref + 直接 DOM 操作避免 re-render 风暴;仅在 mouseup 时 commit 最终宽度
const [panelWidth, setPanelWidth] = useState(420);
const [isDragging, setIsDragging] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const dragStartXRef = useRef(0);
const dragStartWidthRef = useRef(0);
const handleDragStart = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
dragStartXRef.current = e.clientX;
dragStartWidthRef.current = panelWidth;
setIsDragging(true);
},
[panelWidth],
);
useEffect(() => {
if (!isDragging) return;
const el = panelRef.current;
if (!el) return;
const handleMouseMove = (e: MouseEvent) => {
const delta = dragStartXRef.current - e.clientX;
const newWidth = dragStartWidthRef.current + delta;
const clamped = Math.max(
MIN_PANEL_WIDTH,
Math.min(newWidth, window.innerWidth * MAX_PANEL_FRACTION),
);
// 直接操作 DOM 跳过 React 渲染周期
el.style.width = `${clamped}px`;
};
const handleMouseUp = () => {
// 在 mouseup 时才将最终宽度同步到 state(供下次拖拽起始宽度使用)
if (el) {
const finalWidth = parseFloat(el.style.width) || panelWidth;
setPanelWidth(finalWidth);
}
setIsDragging(false);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "none";
document.body.style.cursor = "col-resize";
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
};
}, [isDragging, panelWidth]);
// 当前文件详情
const displayName = file?.fileName || file?.path.split("/").pop() || "";
const ext = file?.path.split(".").pop()?.toLowerCase() ?? "";
const canRender =
mimeType === "text/html" ||
mimeType.startsWith("image/") ||
mimeType === "application/pdf" ||
mimeType === "text/markdown" ||
ext === "svg" ||
ext === "md" ||
ext === "markdown";
// 切换文件时重新加载
useEffect(() => {
if (!file) return;
let cancelled = false;
(async () => {
try {
setLoading(true);
setError(null);
setMimeType("");
setSourceText("");
setViewMode("preview");
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
setObjectUrl(null);
const blob = await readSandboxFile(file.path, {
runId: file.runId,
characterId: characterId ?? undefined,
isolated: file.isolated ?? true,
});
if (cancelled) return;
const mt = blob.type || "text/plain";
setMimeType(mt);
const url = URL.createObjectURL(blob);
objectUrlRef.current = url;
setObjectUrl(url);
if (mt.startsWith("text/") || !mt || ext === "svg") {
const text = await blob.text();
if (!cancelled) setSourceText(text);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "读取文件失败");
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
};
}, [file, characterId]); // eslint-disable-line react-hooks/exhaustive-deps
// 卸载清理
useEffect(() => {
return () => {
if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current);
};
}, []);
if (!file) return null;
return (
<div
className="relative flex shrink-0 will-change-[width]"
ref={panelRef}
style={{ width: panelWidth }}
>
{/* 拖拽手柄 */}
<div
className={cn(
"absolute left-0 top-0 z-10 h-full w-1 cursor-col-resize transition-colors",
isDragging ? "bg-patina/50" : "hover:bg-patina/30",
)}
onMouseDown={handleDragStart}
/>
<aside className="flex min-h-0 flex-1 flex-col overflow-hidden border-l border-rule bg-night-bronze/95 backdrop-blur-sm">
{/* —— 标签栏:文件列表 + 关闭 —— */}
<div className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-rule px-3 py-2 has-[button:only-of-type]:py-1.5">
{previewFiles.map((f, i) => {
const tabExt = f.path.split(".").pop()?.toLowerCase() ?? "";
const tabName = f.fileName || f.path.split("/").pop() || f.path;
return (
<button
key={`${f.runId ?? ""}:${f.path}`}
type="button"
onClick={() => switchFile(i)}
className={cn(
"flex shrink-0 items-center gap-1 rounded px-2 py-1 text-[11px] font-mono transition-colors",
i === activeIndex
? "bg-patina/15 text-patina"
: "text-muted hover:bg-fg/5 hover:text-fg",
)}
>
<FileIcon ext={tabExt} />
<span className="max-w-[120px] truncate">{tabName}</span>
</button>
);
})}
<div className="ml-auto flex items-center">
<button
type="button"
onClick={closePanel}
className="flex h-6 w-6 items-center justify-center rounded text-muted transition-colors hover:bg-fg/5 hover:text-fg"
aria-label="关闭预览"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path
d="M4 4l8 8M12 4l-8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
</div>
{/* —— 模式切换(当前激活文件) —— */}
{canRender && !loading && objectUrl && (
<div className="flex shrink-0 items-center justify-end gap-0.5 border-b border-rule px-4 py-1.5">
<button
type="button"
onClick={() => setViewMode("source")}
className={cn(
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
viewMode === "source"
? "bg-patina text-white"
: "text-muted hover:text-fg",
)}
>
{"<"}源码{">"}
</button>
<button
type="button"
onClick={() => setViewMode("preview")}
className={cn(
"rounded px-1.5 py-0.5 text-[10px] transition-colors",
viewMode === "preview"
? "bg-patina text-white"
: "text-muted hover:text-fg",
)}
>
◉ 预览
</button>
</div>
)}
{/* —— 内容区 —— */}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
{loading && (
<div className="flex min-h-0 flex-1 items-center justify-center">
<div className="flex items-center gap-2 text-xs text-muted/60">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted/30 border-t-patina" />
加载中…
</div>
</div>
)}
{error && (
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
<div className="text-center text-xs text-cinnabar">{error}</div>
</div>
)}
{viewMode === "source" && !loading && sourceText && (
<pre className="min-h-0 flex-1 overflow-auto p-4 font-mono text-[12px] leading-relaxed text-fg/80">
{sourceText}
</pre>
)}
{viewMode === "preview" && !loading && objectUrl && (
<>
{mimeType === "text/html" ? (
<iframe
src={objectUrl}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
className="min-h-0 flex-1 w-full border-0"
title={displayName}
/>
) : mimeType.startsWith("image/") || ext === "svg" ? (
<div className="flex min-h-0 flex-1 items-center justify-center bg-fg/[0.02] p-6">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={objectUrl}
alt={displayName}
className="max-h-full max-w-full rounded object-contain shadow-sm"
/>
</div>
) : mimeType === "application/pdf" ? (
<iframe
src={objectUrl}
className="min-h-0 flex-1 w-full border-0"
title={displayName}
/>
) : ext === "md" ||
ext === "markdown" ||
mimeType === "text/markdown" ? (
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<MarkdownContent content={sourceText} />
</div>
) : (
<pre className="min-h-0 flex-1 overflow-auto p-4 font-mono text-[12px] leading-relaxed text-fg/80">
{sourceText || "(不支持预览此文件类型)"}
</pre>
)}
</>
)}
{!loading && !error && !objectUrl && !sourceText && (
<div className="flex min-h-0 flex-1 items-center justify-center">
<span className="text-xs text-muted/50">(空内容)</span>
</div>
)}
</div>
</aside>
</div>
);
}
function FileIcon({ ext }: { ext: string }) {
const icon = EXT_ICONS[ext] ?? EXT_ICONS._default;
return (
<span className="shrink-0" aria-hidden>
{icon}
</span>
);
}
const EXT_ICONS: Record<string, string> = {
html: "🌐",
htm: "🌐",
svg: "🎨",
png: "🖼",
jpg: "🖼",
jpeg: "🖼",
gif: "🖼",
webp: "🖼",
pdf: "📄",
json: "📋",
yaml: "📋",
yml: "📋",
toml: "📋",
md: "📝",
markdown: "📝",
py: "🐍",
js: "📜",
ts: "📘",
tsx: "⚛️",
jsx: "⚛️",
css: "🎨",
scss: "🎨",
rs: "🦀",
go: "🔷",
sh: "💻",
bash: "💻",
txt: "📄",
csv: "📊",
xml: "📋",
_default: "📄",
};
@@ -0,0 +1,34 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { MarkdownContent } from "./MarkdownContent";
describe("MarkdownContent", () => {
it("渲染纯文本", () => {
render(<MarkdownContent content="Hello" />);
expect(screen.getByText("Hello")).toBeInTheDocument();
});
it("渲染 Markdown 加粗", () => {
render(<MarkdownContent content="**加粗**" />);
expect(screen.getByText("加粗")).toBeInTheDocument();
});
it("渲染链接", () => {
render(<MarkdownContent content="[链接](https://example.com)" />);
const link = screen.getByRole("link", { name: "链接" });
expect(link).toHaveAttribute("href", "https://example.com");
expect(link).toHaveAttribute("target", "_blank");
});
it("渲染行内代码", () => {
render(<MarkdownContent content="`code`" />);
expect(screen.getByText("code")).toBeInTheDocument();
});
it("渲染列表项", () => {
render(<MarkdownContent content={"- 项1\n- 项2"} />);
expect(screen.getByText("项1")).toBeInTheDocument();
expect(screen.getByText("项2")).toBeInTheDocument();
});
});
Loaded 100 of 963 files, more files were not shown because too many files have changed in this diff. Show more