初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
编辑 — {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"><</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("单条消息过长,请精简后发送。");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user