完善前端

This commit is contained in:
chenxin committed 2026-09-21 11:19:07 +08:00
1 parent 7c57d27494
commit b63542add3
81 files changed
+12075 -1441

No files matched your search

@@ -0,0 +1,14 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { RunsPanel } from "@/components/panels";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="天工阁" here="Agent 任务工作台" />} />
<div className="content">
<RunsPanel />
</div>
</>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { AvatarPanel } from "@/components/panels";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="形仪" here="形象与资产" />} />
<div className="content">
<AvatarPanel />
</div>
</>
);
}
@@ -0,0 +1,14 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { CharactersPagePanel } from "@/components/characters/CharactersPagePanel";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="镜生" here="角色" />} />
<div className="content content--wide">
<CharactersPagePanel />
</div>
</>
);
}
@@ -0,0 +1,14 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { CollaborationPanel } from "@/components/collaboration/CollaborationPanel";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="同契台" here="协作空间" />} />
<div className="content">
<CollaborationPanel />
</div>
</>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { CommunityPanel } from "@/components/community/CommunityPanel";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="雅集" here="社区广场" />} />
<div className="content">
<CommunityPanel />
</div>
</>
);
}
@@ -0,0 +1,22 @@
"use client";
import { ThemeButton } from "@/lib/theme";
import { ConversationPanel } from "@/components/conversation/ConversationPanel";
export default function Page() {
return (
<>
<header className="topbar">
<div className="crumbs">
<span className="cn">镜树</span>
<span className="crumbs__sep">/</span>
<span className="here">分支对话</span>
</div>
<div className="topbar__spacer" />
<span className="prototype-tag">真实接口 · 联调中</span>
<ThemeButton />
</header>
<ConversationPanel />
</>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { DevicesPanel } from "@/components/panels";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="器谱" here="设备与感知" />} />
<div className="content">
<DevicesPanel />
</div>
</>
);
}
@@ -0,0 +1,14 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { GovernancePanel } from "@/components/panels";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="司命台" here="安全与治理" />} />
<div className="content">
<GovernancePanel />
</div>
</>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { HomePanel } from "@/components/panels";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="镜庭" here="个人首页" />} />
<div className="content">
<HomePanel />
</div>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { AppShell } from "@/components/shell/AppShell";
/** 用户侧 14 域 + 设计说明共用应用外壳 */
export default function AppLayout({ children }: { children: React.ReactNode }) {
return <AppShell>{children}</AppShell>;
}
@@ -0,0 +1,14 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { MarketplacePanel } from "@/components/marketplace/MarketplacePanel";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="云市" here="创作市场" />} />
<div className="content">
<MarketplacePanel />
</div>
</>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { MemoryPanel } from "@/components/memory/MemoryPanel";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="息壤" here="记忆与知识" />} />
<div className="content">
<MemoryPanel />
</div>
</>
);
}
@@ -0,0 +1,14 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { GatewayPanel } from "@/components/panels";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="神识阁" here="模型与路由" />} />
<div className="content">
<GatewayPanel />
</div>
</>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="设计说明" here="React 转写版" />} />
<div className="content doc">
<div className="doc-hero">
<h1>魂镜 <span className="ver">原型 v0.2</span></h1>
<p>魂镜 Web 界面静态 HTML 原型(v0.1 第一批方向已确认,v0.2 增补第二批共 15 个界面),供审查视觉方向、布局结构与文化语言的界面落地方式。全部确认后按此实现为 React 组件;未在本文档登记的视觉细节以本页令牌为准。</p>
</div>
<section className="doc-sec">
<h2>原型页面</h2>
<p className="sub">第一批(已确认):镜庭、镜生、镜树;第二批:息壤、形仪、天工阁、神识阁、百工坊、雅集、同契台、云市、器谱、司命台、设置,及运营者/自托管使用的管理面与认证页。全站链接已疏通:跨页指向对应页面、页内为锚点;logo 单击回本目录,350ms 内连点 5 次触发「镜启」彩蛋(承接 main 分支旧版设计);侧栏可折叠并记忆偏好。</p>
<div className="page-nav">
<a className="card" href="/home"><span className="cn">镜庭<span className="cn__plain">个人首页</span></span><span className="d">全局框架、角色入口、任务与待审事项</span><span className="p">home.html</span></a>
<a className="card" href="/conversation"><span className="cn">镜树<span className="cn__plain">分支对话</span></span><span className="d">ContentBlock、请命朱批、界域、候答、流式芽体、分支树</span><span className="p">conversation.html</span></a>
<a className="card" href="/characters"><span className="cn">镜生<span className="cn__plain">角色身份</span></span><span className="d">道体、缘契、内驱力、大周天、谱系、生命周期</span><span className="p">characters.html</span></a>
<a className="card" href="/memory"><span className="cn">息壤<span className="cn__plain">记忆与知识</span></span><span className="d">候选审阅、正影纠正、明忆版本、玄络投影</span><span className="p">memory.html</span></a>
<a className="card" href="avatar.html"><span className="cn">形仪<span className="cn__plain">形象与资产</span></span><span className="d">真形锚、主仪化仪、声音、Live2D 与 3D、易相历史</span><span className="p">avatar.html</span></a>
<a className="card" href="/agent-workspace"><span className="cn">天工阁<span className="cn__plain">Agent 任务</span></span><span className="d">工筹计划、预算、分工使、轨迹、验收</span><span className="p">agent-workspace.html</span></a>
<a className="card" href="/model-gateway"><span className="cn">神识阁<span className="cn__plain">模型与路由</span></span><span className="d">逻辑模型、路由继承链、BYOK、用量观测</span><span className="p">model-gateway.html</span></a>
<a className="card" href="/plugins"><span className="cn">百工坊<span className="cn__plain">插件生态</span></span><span className="d">沙箱、已安装、升级权限确认、授契记录</span><span className="p">plugins.html</span></a>
<a className="card" href="community.html"><span className="cn">雅集<span className="cn__plain">社区广场</span></span><span className="d">动态流、角色谱、话题、推荐解释与重置</span><span className="p">community.html</span></a>
<a className="card" href="collaboration.html"><span className="cn">同契台<span className="cn__plain">协作空间</span></span><span className="d">分支版本、审稿合卷、成员权限矩阵、活动</span><span className="p">collaboration.html</span></a>
<a className="card" href="marketplace.html"><span className="cn">云市<span className="cn__plain">创作市场</span></span><span className="d">商品、市契许可、退契规则、分润结算</span><span className="p">marketplace.html</span></a>
<a className="card" href="devices.html"><span className="cn">器谱<span className="cn__plain">设备与感知</span></span><span className="d">形器登记、授契、通感会话、直白权限请求</span><span className="p">devices.html</span></a>
<a className="card" href="/governance"><span className="cn">司命台<span className="cn__plain">安全与治理</span></span><span className="d">治理队列、诊断双重授权、审计、安全概览</span><span className="p">governance.html</span></a>
<a className="card" href="admin.html"><span className="cn">管理面<span className="cn__plain">运营与部署后台</span></span><span className="d">司议队列、支持工单与脱敏支持包、权限管理(RBAC / 双人授权)、部署健康(灵台双形态)、SLO</span><span className="p">admin.html</span></a>
<a className="card" href="login.html"><span className="cn">登录<span className="cn__plain">铜镜鉴心</span></span><span className="d">铭文环镜背、扫描亮带、会话 Cookie / PKCE 说明、通行密钥与恢复钥匙</span><span className="p">login.html</span></a>
<a className="card" href="register.html"><span className="cn">注册<span className="cn__plain">创建账户</span></span><span className="d">邮箱验证码、数据权告知(默认不训练 / 可导出删除 / 私箧持钥)、条款确认</span><span className="p">register.html</span></a>
<a className="card" href="/settings"><span className="cn">设置<span className="cn__plain">账户与偏好</span></span><span className="d">会话、私箧、数据权、删除、封镜</span><span className="p">settings.html</span></a>
</div>
</section>
<section className="doc-sec">
<h2>设计立场</h2>
<p className="sub">四条约束所有页面的决定,审查时优先核对这四条是否成立。</p>
<div className="card" style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
<div><b className="cn">一 · 双层文字系统</b><span style={{ color: "var(--text-2)" }}> —— 文化名用衬线宋体,直白解释用无衬线小字,两种字体本身就是"文化名 · 直白解释"两层结构的可视化;风险、金额、权限、删除等后果始终以直白语言为主,文化名只作辅助。</span></div>
<div><b className="cn">二 · 朱砂只属于朱批</b><span style={{ color: "var(--text-2)" }}> —— 朱砂色是"用户确认决定"的专用语义色:请命卡的允许按钮、危险动作入口使用它;普通操作一律铜镜青。颜色即权限语言。</span></div>
<div><b className="cn">三 · 墨与月白</b><span style={{ color: "var(--text-2)" }}> —— 深色为墨(青调黑,非纯黑),浅色为月白(青灰白,非暖米);铜镜青为主色,取铜镜铜绿之意,不用蓝紫系默认色。</span></div>
<div><b className="cn">四 · 状态永远三段式</b><span style={{ color: "var(--text-2)" }}> —— 状态徽章固定为「文化状态 · 直白状态 + 稳定工程值」,例如「运转 · 运行中 <span className="mono">running</span>」;错误与交易状态绝不只显示文化名。</span></div>
</div>
</section>
<section className="doc-sec">
<h2>色彩令牌</h2>
<p className="sub">右下角可切换主题;以下色板随主题联动。转 React 时这些令牌进入 Tailwind 配置 / CSS 变量。</p>
<div className="swatches">
<div className="swatch"><div className="swatch__c" style={{ background: "var(--bg)", borderBottom: "1px solid var(--line-soft)" }}></div><div className="swatch__m"><b>底 · 墨 / 月白</b>表面层级 0<span className="mono">--bg</span></div></div>
<div className="swatch"><div className="swatch__c" style={{ background: "var(--bg-raised)" }}></div><div className="swatch__m"><b>浮面</b>卡片与气泡<span className="mono">--bg-raised</span></div></div>
<div className="swatch"><div className="swatch__c" style={{ background: "var(--accent)" }}></div><div className="swatch__m"><b>铜镜青</b>主色 · 选中/链接/运转<span className="mono">--accent</span></div></div>
<div className="swatch"><div className="swatch__c" style={{ background: "var(--cinnabar)" }}></div><div className="swatch__m"><b>朱砂</b>朱批 · 确认与危险<span className="mono">--cinnabar</span></div></div>
<div className="swatch"><div className="swatch__c" style={{ background: "var(--ok)" }}></div><div className="swatch__m"><b>已成</b>完成态<span className="mono">--ok</span></div></div>
<div className="swatch"><div className="swatch__c" style={{ background: "var(--warn)" }}></div><div className="swatch__m"><b>秋香</b>整备 / 幻境<span className="mono">--warn</span></div></div>
<div className="swatch"><div className="swatch__c" style={{ background: "var(--err)" }}></div><div className="swatch__m"><b>受阻</b>失败态<span className="mono">--err</span></div></div>
<div className="swatch"><div className="swatch__c" style={{ background: "var(--text-1)" }}></div><div className="swatch__m"><b>正文</b>一级文字<span className="mono">--text-1</span></div></div>
</div>
</section>
<section className="doc-sec">
<h2>字体规范</h2>
<p className="sub">不引入外部字体文件,使用系统中文字体栈;转 React 时同栈迁移。</p>
<div className="card type-spec">
<div className="type-spec__row"><span className="type-spec__label">文化名 · 宋体衬线</span><span className="type-spec__sample cn" style={{ fontSize: "22px", fontWeight: "600" }}>镜树 · 分支对话</span></div>
<div className="type-spec__row"><span className="type-spec__label">界面正文 · 无衬线</span><span className="type-spec__sample" style={{ fontSize: "14px" }}>正文 14px / 行高 1.65,辅助文字 12.5px,元信息 11.5px</span></div>
<div className="type-spec__row"><span className="type-spec__label">稳定值 · 等宽</span><span className="type-spec__sample mono" style={{ fontSize: "13px" }}>running · m_01h8xq… · block_id: blk_7f3a</span></div>
</div>
</section>
<section className="doc-sec">
<h2>状态徽章全集</h2>
<p className="sub">对应 docs/product/cultural-language.md 第 10 节;徽章三段:文化状态 · 直白状态 · 稳定值。</p>
<div className="card">
<div className="table-wrap"><table className="data badge-table">
<thead><tr><th>徽章</th><th>稳定值</th><th>使用场景</th></tr></thead>
<tbody>
<tr><td><span className="badge badge--muted"><span className="badge__dot"></span>待启 · 排队中 <span className="mono">queued</span></span></td><td className="mono">queued</td><td>Run / 媒体任务排队</td></tr>
<tr><td><span className="badge badge--info"><span className="badge__dot"></span>运转 · 运行中 <span className="mono">running</span></span></td><td className="mono">running</td><td>Run 执行中</td></tr>
<tr><td><span className="badge badge--warn"><span className="badge__dot"></span>请命中 · 等待确认 <span className="mono">waiting_approval</span></span></td><td className="mono">waiting_approval</td><td>等待朱批</td></tr>
<tr><td><span className="badge badge--ok"><span className="badge__dot"></span>已成 · 已完成 <span className="mono">completed</span></span></td><td className="mono">completed</td><td>Run / 任务完成</td></tr>
<tr><td><span className="badge badge--err"><span className="badge__dot"></span>受阻 · 失败 <span className="mono">failed</span></span></td><td className="mono">failed</td><td>失败(附原因与重试)</td></tr>
<tr><td><span className="badge badge--muted"><span className="badge__dot"></span>已止 · 已取消 <span className="mono">cancelled</span></span></td><td className="mono">cancelled</td><td>用户取消</td></tr>
<tr><td><span className="badge badge--muted"><span className="badge__dot"></span>守一 · 安静待命 <span className="mono">idle</span></span></td><td className="mono">idle</td><td>角色无运行</td></tr>
<tr><td><span className="badge badge--warn"><span className="badge__dot"></span>整备 · 正在处理 <span className="mono">processing</span></span></td><td className="mono">processing</td><td>媒体 / 资源处理</td></tr>
<tr><td><span className="badge badge--warn"><span className="badge__dot"></span>待审 · 等待审核 <span className="mono">pending_review</span></span></td><td className="mono">pending_review</td><td>社区 / 市场审核</td></tr>
<tr><td><span className="badge badge--ok"><span className="badge__dot"></span>已刊 · 已发布 <span className="mono">published</span></span></td><td className="mono">published</td><td>社区内容</td></tr>
</tbody>
</table></div>
</div>
</section>
<section className="doc-sec">
<h2>组件样例</h2>
<p className="sub">按钮语义:铜镜青为主操作,朱砂仅用于朱批(用户确认决定)与危险操作,描边为次操作。</p>
<div className="card demo-block">
<div className="demo-row">
<button className="btn btn--primary">开始对话</button>
<button className="btn btn--cinnabar">允许(朱批)</button>
<button className="btn btn--outline">查看谱系</button>
<button className="btn btn--ghost">取消</button>
<button className="btn btn--danger">删除角色数据</button>
<button className="btn btn--outline btn--sm">续生</button>
</div>
<div className="demo-row">
<span className="cn" style={{ fontSize: "16px" }}>请命<span className="cn__plain">请求你的确认</span></span>
<span className="cn" style={{ fontSize: "16px" }}>息壤<span className="cn__plain">角色记忆</span></span>
<span className="cn" style={{ fontSize: "16px" }}>幻境<span className="cn__plain">临时试演 · 不入正史</span></span>
</div>
</div>
<div className="card demo-block">
<div className="block__head" style={{ borderRadius: "var(--radius) var(--radius) 0 0" }}><span className="lang">python</span><span className="fname">reading_progress.py</span><span className="acts"><button className="icon-btn" style={{ width: "26px", height: "26px" }} aria-label="复制源码"><svg style={{ width: "13px", height: "13px" }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button></span></div>
<pre className="code"><span className="c"># 汇总共读接龙进度(示意)</span>
<span className="k">from</span> pathlib <span className="k">import</span> Path
<span className="k">def</span> <span className="f">summarize</span>(records: <span className="k">list</span>[<span className="k">dict</span>]) -&gt; <span className="k">dict</span>:
done = [r <span className="k">for</span> r <span className="k">in</span> records <span className="k">if</span> r[<span className="s">"finished"</span>]]
<span className="k">return</span> {'{'}
<span className="s">"total"</span>: <span className="f">len</span>(records),
<span className="s">"finished"</span>: <span className="f">len</span>(done),
<span className="s">"pending"</span>: [r[<span className="s">"name"</span>] <span className="k">for</span> r <span className="k">in</span> records <span className="k">if</span> <span className="k">not</span> r[<span className="s">"finished"</span>]],
{'}'}</pre>
</div>
<div className="card demo-block">
<div className="math">注意力权重 = softmax( QK<sup>T</sup> / √d<sub>k</sub> ) · V
<span className="math__fallback">fallback:注意力权重等于 Q、K 转置乘积经缩放与归一化后对 V 的加权求和</span>
</div>
</div>
</section>
<section className="doc-sec">
<h2>审查重点</h2>
<p className="sub">第一批(色彩、字体、镜树/镜生/镜庭结构)已确认;本轮重点核对第二批页面,反馈时按页名 + 位置描述即可。</p>
<div className="card" style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<div>1. 息壤:候选审阅的「归真入忆 / 正影纠正 / 忽略」操作,以及大周天来源、重忆提示的呈现是否可理解。</div>
<div>2. 天工阁:工筹步骤、预算、分工使、轨迹的信息层级是否符合你对 Run 治理的预期。</div>
<div>3. 神识阁 / 百工坊:路由继承链与「升级新增权限需重新确认」的展示是否清晰,朱批语义是否仍然专一。</div>
<div>4. 云市:金额、分润、退契规则是否足够直白(文化名没有掩盖交易后果)。</div>
<div>5. 器谱 / 司命台 / 设置:直白权限句、双重授权、私箧与删除等高风险项的措辞与视觉重量。</div>
<div>6. 雅集 / 同契台:社区帖卡与协作审稿流的信息密度是否合适。</div>
</div>
</section>
<section className="doc-sec">
<h2>旧版传承与候选</h2>
<p className="sub">已系统挖掘 main 分支旧版 25 页设计原型(铜镜鉴心体系),按「取手法、不照搬旧概念名」原则融入——六爻 / 九枢 / 三魂等概念 1.0 已由小周天、灵台六模块、内驱力等替代,仅传承其可视化手法。</p>
<div className="card">
<div className="table-wrap"><table className="data">
<thead><tr><th>旧版想法</th><th>来源页</th><th>状态</th></tr></thead>
<tbody>
<tr><td>鎏金发丝线三档令牌</td><td>tokens.css</td><td className="mark-ok">✓ 已融入全站(分组标题 / 金标竖条 / 品牌外环 / 时间线)</td></tr>
<tr><td>logo 五连击「镜启」诗签彩蛋</td><td>_theme.js</td><td className="mark-ok">✓ 已融入(修好首次单击即跳转的缺陷)</td></tr>
<tr><td>铜镜登录页(铭文环 / 铜面噪点 / 扫描亮带)</td><td>copper-mirror-login</td><td className="mark-ok">✓ 已融入登录 / 注册页</td></tr>
<tr><td>极光铜晕底 + 卡片 hover 光扫</td><td>_base.css / rune-engine</td><td className="mark-ok">✓ 已融入全站(极淡,不干扰阅读)</td></tr>
<tr><td>日晷时辰盘 + 节气 + 相伴日文案</td><td>time</td><td className="mark-ok">✓ 已融入镜庭 hero(巳时 · 白露 · 第 918 日)</td></tr>
<tr><td>朱批卡风险分级(RISK L2)</td><td>chat-redesign</td><td className="mark-ok">✓ 已融入镜树请命卡</td></tr>
<tr><td>「呈镜」发送按钮命名(旧版已确立血脉)</td><td>chat-redesign</td><td className="mark-ok">✓ 已融入镜树输入区(aria 仍读「发送」)</td></tr>
<tr><td>运行阶段爻象三态(done / active / pending)</td><td>chat / evolution</td><td className="mark-ok">✓ 已融入天工阁工筹(active 步骤鎏金光晕)</td></tr>
<tr><td>河图置信度图谱(置信→线宽,假设→朱漆虚线待验)</td><td>world</td><td className="mark-ok">✓ 已融入息壤玄络(待验边 + 标注)</td></tr>
<tr><td>息壤浮现(本次回应用了哪些记忆)</td><td>chat / copper-v2</td><td className="mark-ok">✓ 对应 1.0 上下文镜(镜树右栏,同源 ContextSnapshot)</td></tr>
<tr><td>九枢星盘「活的仪器」+ 铜镜四态健康词汇</td><td>admin / copper-v2</td><td className="mark-ok">✓ 已复刻为灵台六枢星盘(太一居中 + 五枢环绕 + 呼吸刻度 / 慢旋 / 告警闪烁)+ 镜态徽章(管理面)</td></tr>
<tr><td>五行生克健康环(系统健康=关系动力学)</td><td>wuxing</td><td className="mark-ok">✓ 已复刻为相生 / 相克指标对告警(管理面星盘卡;环状图 React 阶段评估)</td></tr>
<tr><td>眉批 depth 标尺注入预览</td><td>margin-notes</td><td style={{ color: "var(--warn)" }}>候选 —— 上下文镜深度维度(React 阶段与 Context Manifest 联动)</td></tr>
<tr><td>好奇萤火(亮度=期望值)与探索天平</td><td>drive</td><td className="mark-ok">✓ 萤火已融入内驱力维度(亮度=维度值);探索天平待天工阁预算增强时评估</td></tr>
<tr><td>周天四镜阶段链(评 / 演 / 候 / 藏 四面小铜镜)</td><td>evolution</td><td className="mark-ok">✓ 已复刻为大周天四镜:回 / 照 / 议 / 呈(镜生页,ADR-023 四阶段)</td></tr>
<tr><td>会话列表「今日 / 本周 / 更早」分组 + 彩点状态</td><td>chat-redesign</td><td className="mark-ok">✓ 已融入镜树会话列表(活跃铜绿 / 幻境秋香 / 待朱批朱砂)</td></tr>
<tr><td>「深虑 / 博闻」输入控制药丸 + 用户消息鎏金右边线</td><td>chat-redesign / copper-v2</td><td className="mark-ok">✓ 已融入镜树输入区与用户气泡</td></tr>
<tr><td>起居注时间河「卷」章分割</td><td>admin</td><td className="mark-ok">✓ 已融入管理面审计快照</td></tr>
<tr><td>太极阴阳平衡 gauge(三态)</td><td>cosmos</td><td className="mark-ok">✓ 已复刻为朱批治理阴阳平衡(司命台,阳亢 / 平衡 / 阴盛)</td></tr>
<tr><td>角色 card-banner 渐变横幅</td><td>character-detail</td><td className="mark-ok">✓ 已融入镜生详情页</td></tr>
<tr><td>思考块折叠卡(复刻 chat-redesign)</td><td>chat-redesign</td><td className="mark-ok">✓ 已融入(用户确认):思考摘要可折叠 + 深虑强度(低/中/高)——<b>契约候选</b>:正式实现前需在 content-blocks 契约登记 <span className="mono">thinking</span> 块类型与流式语义;摘要仅会话内可见,不入长期记录与诊断导出</td></tr>
</tbody>
</table></div>
</div>
</section>
</div>
</>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { PluginsPanel } from "@/components/plugins/PluginsPanel";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="百工坊" here="插件与能力生态" />} />
<div className="content">
<PluginsPanel />
</div>
</>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { SettingsPanel } from "@/components/panels";
export default function Page() {
return (
<>
<Topbar crumbs={<Crumbs cn="设置" here="账户与偏好" />} />
<div className="content">
<SettingsPanel />
</div>
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { AdminShell } from "@/components/shell/AdminShell";
/** 管理面独立外壳(运营 / 部署 / 观测) */
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <AdminShell>{children}</AdminShell>;
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { Topbar } from "@/components/shell/Topbar";
import { Crumbs } from "@/components/ui";
import { AdminPanel } from "@/components/admin/AdminPanel";
import { readSession } from "@/lib/api";
import { useEffect, useState } from "react";
export default function Page() {
const [session, setSession] = useState<{ role: string; email: string } | null>(null);
useEffect(() => {
// 会话身份含 localStorage,须在挂载后读取(避免 SSR/客户端不一致)
setSession(readSession());
}, []);
const isAdmin = session?.role === "admin";
return (
<>
<Topbar
crumbs={<Crumbs cn="司命台" here="管理面 · 运营与部署" />}
extra={
session === null ? null : isAdmin ? (
<span className="badge badge--warn">
<span className="badge__dot" />
管理员 · {session.email || "dev"} <span className="mono">role: admin</span>
</span>
) : (
<span className="badge badge--muted">
<span className="badge__dot" />
普通用户 <span className="mono">role: user</span> · 治理队列需管理员
</span>
)
}
/>
<div className="content">
<div className="realm-banner" role="note">
管理面边界:管理员不可读取私箧与用户明文内容;敏感操作需双人授权并全部写入审计(开发档单管理员,双人授权待运营流程接入)。
</div>
<AdminPanel />
</div>
</>
);
}
File diff suppressed because it is too large. Load diff
+15 -2
View File
@@ -1,14 +1,27 @@
import type { Metadata } from "next";
import "./globals.css";
import { ThemeProvider } from "@/lib/theme";
import { QueryProvider } from "@/lib/query";
export const metadata: Metadata = {
title: "魂镜 Hunjing",
description: "长期 AI 角色、Agent 与创作社区平台",
};
/** 主题初始化(防闪):与 ThemeProvider 的 localStorage 键一致 */
const themeInit = `try{var t=localStorage.getItem("hunjing-theme");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>{children}</body>
<html lang="zh-CN" data-theme="dark" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInit }} />
</head>
<body>
<ThemeProvider>
<QueryProvider>{children}</QueryProvider>
</ThemeProvider>
</body>
</html>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { Fingerprint, Key } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { api, ApiError, storeCsrf, storeSession } from "@/lib/api";
import { ThemeButton } from "@/lib/theme";
/** 登录页(StageShell 铜镜鉴心)——开发档:后端仅校验邮箱即签发会话 */
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setPending(true);
setError(null);
try {
const session = await api<{ csrf_token: string; user_id: string; role: string; email: string }>(
"/v1/auth/sessions",
{ method: "POST", json: { email } },
);
storeCsrf(session.csrf_token);
storeSession(session);
router.push("/home");
} catch (err) {
setError(err instanceof ApiError ? err.message : "登录失败,请稍后再试");
} finally {
setPending(false);
}
}
return (
<div className="stage">
<div className="stage__bg" aria-hidden="true">
<div className="stage__rings">
<svg viewBox="0 0 800 800">
<defs>
<path id="ringpath" d="M400,400 m-300,0 a300,300 0 1,1 600,0 a300,300 0 1,1 -600,0" />
</defs>
<circle className="ring-ink" cx="400" cy="400" r="392" />
<circle className="ring-faint" cx="400" cy="400" r="336" />
<g className="ring-slow"><circle className="ring-faint" cx="400" cy="400" r="272" /></g>
<circle className="ring-ink" cx="400" cy="400" r="206" />
<circle className="ring-faint" cx="400" cy="400" r="158" />
<g className="ring-slow" style={{ animationDirection: "reverse" }}>
<circle className="ring-faint" cx="400" cy="400" r="104" />
</g>
<text fill="var(--gilt-faint)" fontSize="12" letterSpacing="6" style={{ fontFamily: "var(--font-serif)" }}>
<textPath href="#ringpath">镜中有镜 · 心外无心 · 汝来看此镜时 · 此镜亦照汝 · 铜镜本无字 · 照尽往来人 ·</textPath>
</text>
</svg>
</div>
<svg style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0.035 }} aria-hidden="true">
<filter id="grain"><feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2" /></filter>
<rect width="100%" height="100%" filter="url(#grain)" />
</svg>
</div>
<header className="stage__topbar">
<span style={{ fontSize: 12, color: "var(--text-3)" }}>联调中 · 开发档登录</span>
<span className="spacer" />
<a href="/" style={{ fontSize: 12, color: "var(--text-3)" }}>原型目录</a>
<ThemeButton />
</header>
<main className="stage__main">
<div className="mirror-card">
<div className="mirror-card__brand">
<div className="brand__mark" aria-hidden="true" />
<div>
<div style={{ fontFamily: "var(--font-serif)", fontSize: 20, fontWeight: 600, lineHeight: 1.2 }}>魂镜</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>Hunjing</div>
</div>
</div>
<div className="mirror-card__title"><h1>登入魂镜</h1></div>
<p className="mirror-card__sub">长期 AI 角色、Agent 与创作社区平台</p>
<form onSubmit={submit}>
<div className="field-group">
<label htmlFor="login-email">邮箱</label>
<input
className="input"
id="login-email"
type="email"
autoComplete="username"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="field-group">
<label htmlFor="login-pwd">密码</label>
<input
className="input"
id="login-pwd"
type="password"
autoComplete="current-password"
placeholder="开发档暂不校验,仅邮箱即可"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && (
<p style={{ fontSize: 12.5, color: "var(--err)", marginBottom: 10 }} role="alert">
{error}
</p>
)}
<button
type="submit"
className="btn btn--primary"
style={{ width: "100%", marginTop: 6 }}
disabled={pending}
>
{pending ? "登入中…" : "登入"}
</button>
</form>
<div className="login-alt">其他方式</div>
<div style={{ display: "flex", gap: 10 }}>
<a className="btn btn--outline btn--sm" href="#" style={{ flex: 1 }} title="通行密钥(WebAuthn,待接入)"><Fingerprint />通行密钥</a>
<a className="btn btn--outline btn--sm" href="#" style={{ flex: 1 }} title="恢复钥匙(待接入)"><Key />恢复钥匙</a>
</div>
<p className="login-note">
开发档:输入邮箱即可登入(自动建户)。正式档为 OAuth 2.1 安全会话;凭据只经 Header 传递。
</p>
<div className="login-foot">
还没有账户?<a href="/register">创建账户</a>
</div>
</div>
</main>
</div>
);
}
-8
View File
@@ -1,8 +0,0 @@
export default function Home() {
return (
<main style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
<h1>魂镜 Hunjing</h1>
<p>工程骨架已建立;界面随 WP-03/WP-10 工作包实现。</p>
</main>
);
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { api, ApiError, storeCsrf } from "@/lib/api";
import { ThemeButton } from "@/lib/theme";
/** 注册页(StageShell 铜镜鉴心) */
export default function RegisterPage() {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
const email = (document.getElementById("reg-email") as HTMLInputElement).value;
if (!email) return;
setPending(true);
setError(null);
try {
const session = await api<{ csrf_token: string }>("/v1/auth/sessions", { method: "POST", json: { email } });
storeCsrf(session.csrf_token);
router.push("/home");
} catch (err) {
setError(err instanceof ApiError ? err.message : "创建失败");
} finally {
setPending(false);
}
}
return (
<div className="stage">
<div className="stage__bg" aria-hidden="true">
<div className="stage__rings">
<svg viewBox="0 0 800 800">
<defs>
<path id="ringpath" d="M400,400 m-300,0 a300,300 0 1,1 600,0 a300,300 0 1,1 -600,0" />
</defs>
<circle className="ring-ink" cx="400" cy="400" r="392" />
<circle className="ring-faint" cx="400" cy="400" r="336" />
<g className="ring-slow"><circle className="ring-faint" cx="400" cy="400" r="272" /></g>
<circle className="ring-ink" cx="400" cy="400" r="206" />
<circle className="ring-faint" cx="400" cy="400" r="158" />
<g className="ring-slow" style={{ animationDirection: "reverse" }}>
<circle className="ring-faint" cx="400" cy="400" r="104" />
</g>
<text fill="var(--gilt-faint)" fontSize="12" letterSpacing="6" style={{ fontFamily: "var(--font-serif)" }}>
<textPath href="#ringpath">幻由心生 · 镜随心转 · 一念起 · 万镜生 · 且向镜中看 · 那个是谁 ·</textPath>
</text>
</svg>
</div>
<svg style={{ position: "absolute", inset: 0, width: "100%", height: "100%", opacity: 0.035 }} aria-hidden="true">
<filter id="grain"><feTurbulence type="fractalNoise" baseFrequency="0.8" numOctaves="2" /></filter>
<rect width="100%" height="100%" filter="url(#grain)" />
</svg>
</div>
<header className="stage__topbar">
<a href="/login" style={{ fontSize: 12, color: "var(--text-3)" }}>← 返回登录</a>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>React 转写 · 待联调</span>
<span className="spacer" />
<a href="/" style={{ fontSize: 12, color: "var(--text-3)" }}>原型目录</a>
<ThemeButton />
</header>
<main className="stage__main">
<div className="mirror-card">
<div className="mirror-card__brand">
<div className="brand__mark" aria-hidden="true" />
<div>
<div style={{ fontFamily: "var(--font-serif)", fontSize: 20, fontWeight: 600, lineHeight: 1.2 }}>魂镜</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>Hunjing</div>
</div>
</div>
<div className="mirror-card__title"><h1>创建账户</h1></div>
<p className="mirror-card__sub">化名与法定身份分离核验;随时可导出、撤权或删除</p>
<form onSubmit={submit}>
<div className="field-group">
<label htmlFor="reg-email">邮箱</label>
<input className="input" id="reg-email" type="email" autoComplete="email" placeholder="you@example.com" />
</div>
<div className="field-group">
<label htmlFor="reg-code">验证码</label>
<div className="field-row">
<input className="input" id="reg-code" type="text" inputMode="numeric" placeholder="6 位验证码" />
<button className="btn btn--outline" type="button">发送验证码</button>
</div>
</div>
<div className="field-group">
<label htmlFor="reg-pwd">密码</label>
<input className="input" id="reg-pwd" type="password" autoComplete="new-password" placeholder="至少 12 位,建议通行密钥" />
</div>
<div className="field-group">
<label htmlFor="reg-pwd2">确认密码</label>
<input className="input" id="reg-pwd2" type="password" autoComplete="new-password" placeholder="再次输入" />
</div>
<div style={{ margin: "18px 0 16px", borderTop: "1px solid var(--line-soft)", paddingTop: 16 }}>
<div style={{ fontSize: 12.5, color: "var(--text-2)", marginBottom: 10 }}>开始前请知悉(数据权):</div>
<div className="check-row">
<input type="checkbox" id="ck-train" defaultChecked disabled aria-label="默认不用于模型训练" />
<label className="t" htmlFor="ck-train"><b>默认不用于模型训练</b> —— 你的个人域数据默认不参与任何模型训练;自愿贡献是独立开关,可随时撤销。</label>
</div>
<div className="check-row">
<input type="checkbox" id="ck-export" defaultChecked disabled aria-label="可导出与删除" />
<label className="t" htmlFor="ck-export"><b>可导出、可删除</b> —— 角色、记忆、对话与来源谱系随时可携导出;删除有冷静期并传播到全部派生数据。</label>
</div>
<div className="check-row">
<input type="checkbox" id="ck-vault" aria-label="了解私箧规则" />
<label className="t" htmlFor="ck-vault"><b>私箧独立持钥</b> —— 加密私密区只有你能解开;平台无法恢复,钥匙丢失即永久不可读取。</label>
</div>
<div className="check-row">
<input type="checkbox" id="ck-terms" aria-label="同意服务条款" />
<label className="t" htmlFor="ck-terms"><b>我已阅读并同意服务条款与社区规范</b> —— 角色不拥有生物意识或法律人格;社区有举报与治理机制。</label>
</div>
</div>
<button type="submit" className="btn btn--cinnabar" style={{ width: "100%" }} disabled={pending}>
{pending ? "创建中…" : "创建账户"}
</button>
{error && <p style={{ fontSize: 12.5, color: "var(--err)", marginTop: 10 }} role="alert">{error}</p>}
</form>
<p className="login-note">注册即创建你的镜域(个人数据域);角色、记忆与授权都归属镜域治理。</p>
<div className="login-foot">
已有账户?<a href="/login">登入</a>
</div>
</div>
</main>
</div>
);
}
@@ -0,0 +1,120 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { api, ApiError, type ToolCall } from "@/lib/api";
import { PanelState } from "@/lib/queries";
interface ModerationCase {
case_id: string;
status: string;
report: { target_type?: string; target_id?: string; reason?: string } | null;
action_taken: string | null;
created_at: string;
}
/** 管理面数据区:治理案件(真实裁决)+ 运行观测 + 支持包 */
export function AdminPanel() {
const qc = useQueryClient();
const isAdminDenied = false; // 由下方 PanelState 呈现 403
const cases = useQuery({
queryKey: ["moderation-cases"],
queryFn: () => api<{ data: ModerationCase[] }>("/v1/admin/moderation-cases"),
});
const metrics = useQuery({
queryKey: ["metrics"],
queryFn: async () => {
const res = await fetch("/api/v1/metrics");
if (!res.ok) throw new Error(`metrics ${res.status}`);
return res.text();
},
});
const [bundle, setBundle] = useState<string | null>(null);
const resolve = useMutation({
mutationFn: ({ id, action }: { id: string; action: string }) =>
api(`/v1/admin/moderation-cases/${id}/resolutions`, {
method: "POST",
json: { action },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["moderation-cases"] }),
});
return (
<div>
<section className="section">
<div className="section__head">
<span className="cn">司议<span className="cn__plain">治理案件 · 真实裁决</span></span>
</div>
<PanelState pending={cases.isPending} error={cases.error} onRetry={() => cases.refetch()} empty={(cases.data?.data ?? []).length === 0}>
<div className="card" style={{ padding: "6px 18px" }}>
{(cases.data?.data ?? []).map((c) => (
<div className="list-row" key={c.case_id}>
<div className="list-row__body">
<div className="list-row__title mono">{c.case_id.slice(-10)} · {c.report?.target_type ?? ""}</div>
<div className="list-row__sub">
{c.report?.reason ?? ""} · {new Date(c.created_at).toLocaleString("zh-CN", { hour12: false })}
{c.action_taken && ` · 已处置:${c.action_taken}`}
</div>
</div>
{c.status === "pending" ? (
<div style={{ display: "flex", gap: 6 }}>
<button
className="btn btn--danger btn--sm"
disabled={resolve.isPending}
onClick={() => resolve.mutate({ id: c.case_id, action: "content_removed" })}
>
移除内容
</button>
<button
className="btn btn--outline btn--sm"
disabled={resolve.isPending}
onClick={() => resolve.mutate({ id: c.case_id, action: "dismissed" })}
>
驳回
</button>
</div>
) : (
<span className="badge badge--muted" style={{ fontSize: 11 }}>
<span className="badge__dot" />{c.status}
</span>
)}
</div>
))}
</div>
</PanelState>
</section>
<section className="section card">
<div className="card__head">
<span className="cn">运行观测<span className="cn__plain">/v1/metrics</span></span>
</div>
<PanelState pending={metrics.isPending} error={metrics.error} onRetry={() => metrics.refetch()}>
<pre className="code" style={{ maxHeight: 220, overflow: "auto" }}>{metrics.data}</pre>
</PanelState>
</section>
<section className="section card">
<div className="card__head">
<span className="cn">支持包<span className="cn__plain">脱敏诊断导出</span></span>
</div>
<p style={{ fontSize: 12.5, color: "var(--text-2)", marginBottom: 12 }}>
支持包为脱敏导出:不包含消息明文、私箧、隐藏推理、密钥与签名 URL。
</p>
<button
className="btn btn--cinnabar btn--sm"
onClick={async () => {
try {
const r = await api<{ id?: string }>("/v1/support/bundles", { method: "POST", json: {} });
setBundle(`已生成支持包 ${r.id ?? ""}`);
} catch (err) {
setBundle(err instanceof ApiError ? err.message : "生成失败");
}
}}
>
生成支持包
</button>
{bundle && <p style={{ fontSize: 12.5, color: "var(--text-3)", marginTop: 10 }}>{bundle}</p>}
</section>
</div>
);
}
@@ -0,0 +1,141 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { api, ApiError, type Character, type CursorPage } from "@/lib/api";
/** 角色状态 → 徽章(稳定值来自后端,文化名前端本地化) */
const STATUS_LABEL: Record<string, { cn: string; plain: string; tone: "info" | "muted" | "warn" | "ok" }> = {
draft: { cn: "灵胚", plain: "草稿", tone: "warn" },
active: { cn: "临照", plain: "运行中", tone: "info" },
resting: { cn: "静修", plain: "停止运行", tone: "muted" },
archived: { cn: "归藏", plain: "已隐藏", tone: "muted" },
deceased: { cn: "死亡", plain: "永久闭合", tone: "muted" },
};
function StatusBadge({ status }: { status: string }) {
const label = STATUS_LABEL[status] ?? { cn: status, plain: "未知状态", tone: "muted" as const };
return (
<span className={`badge badge--${label.tone}`} style={{ fontSize: 11, padding: "1px 8px" }}>
<span className="badge__dot" />
{label.cn} · {label.plain}
</span>
);
}
/** 镜生左列:真实角色列表(未登入 / 空 / 有数据三态)+ 创建 */
export function CharacterListPanel() {
const qc = useQueryClient();
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const list = useQuery({
queryKey: ["characters"],
queryFn: () => api<CursorPage<Character>>("/v1/characters?limit=20"),
});
const create = useMutation({
mutationFn: (newName: string) =>
api<Character>("/v1/characters", { method: "POST", json: { name: newName, summary: "" } }),
onSuccess: () => {
setName("");
setCreating(false);
setError(null);
qc.invalidateQueries({ queryKey: ["characters"] });
},
onError: (err) => setError(err instanceof ApiError ? err.message : "创建失败"),
});
if (list.isError) {
const status = list.error instanceof ApiError ? list.error.status : 0;
if (status === 401 || status === 403) {
return (
<div className="card" style={{ padding: "26px 20px", textAlign: "center" }}>
<p style={{ fontSize: 13.5, color: "var(--text-2)", marginBottom: 14 }}>尚未登入,无法读取你的镜域。</p>
<a className="btn btn--primary btn--sm" href="/login">去登入</a>
</div>
);
}
return (
<div className="card" style={{ padding: "26px 20px", textAlign: "center" }}>
<p style={{ fontSize: 13.5, color: "var(--err)", marginBottom: 8 }}>读取失败</p>
<p style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 12 }}>
{list.error instanceof ApiError ? list.error.message : "请稍后重试"}
</p>
<button className="btn btn--outline btn--sm" onClick={() => list.refetch()}>重试</button>
</div>
);
}
if (list.isPending) {
return (
<div className="card" style={{ padding: "22px 20px", textAlign: "center" }}>
<p style={{ fontSize: 13, color: "var(--text-3)" }}>正在读取镜生…</p>
</div>
);
}
const items = list.data?.data ?? [];
return (
<div className="card" style={{ padding: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "4px 8px 10px" }}>
<span className="cn" style={{ fontSize: 15, fontWeight: 600 }}>
镜生<span className="cn__plain">{items.length} 位角色</span>
</span>
<button className="btn btn--outline btn--sm" onClick={() => setCreating((v) => !v)}>创建</button>
</div>
{items.length === 0 && (
<div style={{ padding: "22px 12px", textAlign: "center" }}>
<p style={{ fontSize: 13.5, color: "var(--text-2)", marginBottom: 4 }}>镜域里还没有角色。</p>
<p style={{ fontSize: 12, color: "var(--text-3)" }}>从一位灵胚(角色草稿)开始——可删除,不等于角色死亡。</p>
</div>
)}
{items.map((c) => (
<div key={c.id} className="char-list-item">
<span className="avatar">{c.name.slice(0, 1)}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="char-list-item__name">{c.name}</div>
<div className="char-list-item__sub">
{STATUS_LABEL[c.status]?.cn === "灵胚" ? "灵胚 · 未启镜" : `道体 ${c.current_version_id ? "有版本" : "待定"}`} ·{" "}
{new Date(c.created_at).toLocaleDateString("zh-CN")}
</div>
</div>
<StatusBadge status={c.status} />
</div>
))}
{creating && (
<form
style={{ padding: "10px 8px 6px", display: "flex", gap: 8 }}
onSubmit={(e) => {
e.preventDefault();
if (name.trim()) create.mutate(name.trim());
}}
>
<input
className="input"
placeholder="角色名,如:青梧"
value={name}
onChange={(e) => setName(e.target.value)}
aria-label="新角色名"
autoFocus
/>
<button className="btn btn--primary btn--sm" type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "创建中…" : "创建灵胚"}
</button>
</form>
)}
{error && <p style={{ fontSize: 12, color: "var(--err)", padding: "4px 8px" }}>{error}</p>}
<div style={{ borderTop: "1px solid var(--line-soft)", marginTop: 8, paddingTop: 8 }}>
<div className="char-list-item" style={{ cursor: "default", opacity: 0.6 }}>
<span style={{ fontSize: 13, color: "var(--text-3)" }}>随缘 · 生成匿名灵胚候选(待接入)</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,218 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { api, ApiError, type Character, type CursorPage } from "@/lib/api";
import { PanelState } from "@/lib/queries";
/** 角色状态 → 徽章(稳定值来自后端,文化名前端本地化) */
const STATUS_LABEL: Record<string, { cn: string; plain: string; tone: "info" | "muted" | "warn" | "ok" }> = {
draft: { cn: "灵胚", plain: "草稿", tone: "warn" },
active: { cn: "临照", plain: "运行中", tone: "info" },
resting: { cn: "静修", plain: "停止运行", tone: "muted" },
archived: { cn: "归藏", plain: "已隐藏", tone: "muted" },
deceased: { cn: "死亡", plain: "永久闭合", tone: "muted" },
};
function StatusBadge({ status }: { status: string }) {
const label = STATUS_LABEL[status] ?? { cn: status, plain: "未知状态", tone: "muted" as const };
return (
<span className={`badge badge--${label.tone}`} style={{ fontSize: 11, padding: "1px 8px" }}>
<span className="badge__dot" />
{label.cn} · {label.plain}
</span>
);
}
/** 镜生页:左列真实列表 + 右列真实详情(选中联动) */
export function CharactersPagePanel() {
const qc_client = { key: "characters" };
const [selectedId, setSelectedId] = useState<string | null>(null);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const list = useQuery({
queryKey: [qc_client.key],
queryFn: () => api<CursorPage<Character>>("/v1/characters?limit=20"),
});
const detail = useQuery({
queryKey: ["character", selectedId],
enabled: !!selectedId,
queryFn: () => api<Character>(`/v1/characters/${selectedId}`),
});
async function create(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
setError(null);
try {
const c = await api<Character>("/v1/characters", { method: "POST", json: { name: name.trim(), summary: "" } });
setName("");
setCreating(false);
list.refetch();
setSelectedId(c.id);
} catch (err) {
setError(err instanceof ApiError ? err.message : "创建失败");
}
}
async function activate(id: string) {
try {
await api(`/v1/characters/${id}/activations`, { method: "POST", json: {} });
list.refetch();
detail.refetch();
} catch (err) {
setError(err instanceof ApiError ? err.message : "启镜失败");
}
}
const items = list.data?.data ?? [];
return (
<div className="char-layout">
<div>
<div className="card" style={{ padding: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "4px 8px 10px" }}>
<span className="cn" style={{ fontSize: 15, fontWeight: 600 }}>
镜生<span className="cn__plain">{items.length} 位角色</span>
</span>
<button className="btn btn--outline btn--sm" onClick={() => setCreating((v) => !v)}>创建</button>
</div>
{list.isPending && <p style={{ fontSize: 13, color: "var(--text-3)", padding: "18px 8px" }}>正在读取…</p>}
{list.isError && (
<div style={{ padding: "18px 8px" }}>
{(list.error as ApiError).status === 401 || (list.error as ApiError).status === 403 ? (
<>
<p style={{ fontSize: 13.5, color: "var(--text-2)", marginBottom: 12 }}>尚未登入,无法读取你的镜域。</p>
<a className="btn btn--primary btn--sm" href="/login">去登入</a>
</>
) : (
<>
<p style={{ fontSize: 13, color: "var(--err)", marginBottom: 10 }}>读取失败</p>
<button className="btn btn--outline btn--sm" onClick={() => list.refetch()}>重试</button>
</>
)}
</div>
)}
{items.length === 0 && !list.isPending && !list.isError && (
<div style={{ padding: "22px 12px", textAlign: "center" }}>
<p style={{ fontSize: 13.5, color: "var(--text-2)", marginBottom: 4 }}>镜域里还没有角色。</p>
<p style={{ fontSize: 12, color: "var(--text-3)" }}>从一位灵胚(角色草稿)开始——可删除,不等于角色死亡。</p>
</div>
)}
{items.map((c) => (
<div
key={c.id}
className={selectedId === c.id ? "char-list-item is-active" : "char-list-item"}
style={{ cursor: "pointer" }}
onClick={() => setSelectedId(c.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter") setSelectedId(c.id); }}
aria-current={selectedId === c.id ? "true" : undefined}
>
<span className="avatar">{c.name.slice(0, 1)}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="char-list-item__name">{c.name}</div>
<div className="char-list-item__sub">
{c.status === "draft" ? "灵胚 · 未启镜" : c.status} · {new Date(c.created_at).toLocaleDateString("zh-CN")}
</div>
</div>
<StatusBadge status={c.status} />
</div>
))}
{creating && (
<form style={{ padding: "10px 8px 6px", display: "flex", gap: 8 }} onSubmit={create}>
<input className="input" placeholder="角色名,如:青梧" value={name} onChange={(e) => setName(e.target.value)} aria-label="新角色名" autoFocus />
<button className="btn btn--primary btn--sm" type="submit" disabled={!name.trim()}>创建灵胚</button>
</form>
)}
{error && <p style={{ fontSize: 12, color: "var(--err)", padding: "4px 8px" }}>{error}</p>}
</div>
</div>
<div>
{!selectedId && (
<div className="card">
<div className="card__head">
<span className="cn">镜生<span className="cn__plain">选择一位角色查看详情</span></span>
</div>
<p style={{ fontSize: 13, color: "var(--text-3)" }}>
详情包含道体、内驱力(可编辑)、大周天与生命周期操作——全部来自真实接口。
</p>
</div>
)}
{selectedId && (
<PanelState pending={detail.isPending} error={detail.error} onRetry={() => detail.refetch()}>
{(() => {
const c = detail.data as Character;
if (!c) return null;
return (
<div>
<div className="char-banner" aria-hidden="true" />
<div className="char-head">
<span className="avatar avatar--lg">{c.name.slice(0, 1)}</span>
<div className="char-head__title">
<h2 style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
{c.name} <StatusBadge status={c.status} />
</h2>
<div className="char-head__motto mono" style={{ fontSize: 12 }}>{c.id}</div>
</div>
<div className="char-head__actions">
{c.status === "draft" && (
<button className="btn btn--primary" onClick={() => activate(c.id)}>启镜 · 正式创建</button>
)}
<a className="btn btn--outline" href="/conversation">进入镜树</a>
</div>
</div>
<section className="section card">
<div className="card__head">
<span className="cn">道体<span className="cn__plain">稳定身份基线</span></span>
</div>
<p style={{ fontSize: 13.5, color: "var(--text-2)" }}>
{Object.keys(c.identity_baseline ?? {}).length === 0
? "道体尚未设定——启镜并完成初照自陈后形成首个版本。"
: JSON.stringify(c.identity_baseline, null, 2)}
</p>
</section>
<section className="section card">
<div className="card__head">
<span className="cn">内驱力<span className="cn__plain">动机维度 · 可编辑(PATCH drives)</span></span>
</div>
<p style={{ fontSize: 13.5, color: "var(--text-2)" }}>
{(c.drives ?? []).length === 0 ? "尚未声明内驱力维度。" : JSON.stringify(c.drives, null, 2)}
</p>
</section>
<section className="section card">
<div className="card__head">
<span className="cn">生命周期<span className="cn__plain">与数据删除分离</span></span>
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
{["静修 · 停止运行", "归藏 · 隐藏可恢复", "复照 · 从旧版本重开"].map((op) => (
<button key={op} className="btn btn--outline btn--sm" title="生命周期操作端点接入后启用">{op}</button>
))}
{["铸魂 · 重大重塑(强确认)", "死亡 · 永久闭合(冷静期)"].map((op) => (
<button key={op} className="btn btn--danger btn--sm" title="生命周期操作端点接入后启用">{op}</button>
))}
</div>
<p style={{ fontSize: 12, color: "var(--text-3)", marginTop: 10 }}>
铸魂与死亡走强确认与谱系留痕;后端 lifecycle 端点(POST /v1/characters/{"{id}"}/{"{action}"})已具备,交互流程待接入。
</p>
</section>
</div>
);
})()}
</PanelState>
)}
</div>
</div>
);
}
@@ -0,0 +1,57 @@
"use client";
import { useState } from "react";
import { useCreateWorkspace } from "@/lib/queries";
import type { Workspace } from "@/lib/api";
export function CollaborationPanel() {
const create = useCreateWorkspace();
const [name, setName] = useState("");
const [created, setCreated] = useState<Workspace | null>(null);
return (
<div>
<div className="realm-banner" role="note">
成员通过同一明确契约协作;编辑、发布、市场上架与财务权限分别授予。
</div>
<div className="card">
<div className="card__head">
<span className="cn">同契台<span className="cn__plain">创建协作空间</span></span>
</div>
<form
style={{ display: "flex", gap: 10, flexWrap: "wrap" }}
onSubmit={async (e) => {
e.preventDefault();
if (!name.trim()) return;
const ws = await create.mutateAsync({ name: name.trim() });
setCreated(ws);
setName("");
}}
>
<input className="input" style={{ flex: 1, minWidth: 220 }} placeholder="空间名,如:镜斋工作室" value={name} onChange={(e) => setName(e.target.value)} aria-label="空间名" />
<button className="btn btn--primary btn--sm" type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "创建中…" : "创建空间"}
</button>
</form>
{create.isError && (
<p style={{ fontSize: 12, color: "var(--err)", marginTop: 8 }}>{(create.error as { message?: string }).message}</p>
)}
{created && (
<div style={{ marginTop: 14 }}>
<div className="list-row">
<span className="avatar avatar--sm">契</span>
<div className="list-row__body">
<div className="list-row__title">{created.name}</div>
<div className="list-row__sub mono">{created.id}</div>
</div>
<span className="badge badge--ok" style={{ fontSize: 11 }}><span className="badge__dot" />已创建</span>
</div>
</div>
)}
<p style={{ fontSize: 12, color: "var(--text-3)", marginTop: 10 }}>
成员、项目分支与审稿接口(邀请 / 版本 / 合卷)已具备;空间列表端点待补齐后在此展示全部空间。
</p>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
"use client";
import { useState } from "react";
import { PanelState, useContents, useCreateContent } from "@/lib/queries";
import type { CommunityContent } from "@/lib/api";
export function CommunityPanel() {
const contents = useContents();
const create = useCreateContent();
const [title, setTitle] = useState("");
const list = contents.data?.data ?? [];
return (
<div>
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
<div className="card__head"><span className="cn">发布<span className="cn__plain">动态 · 公开内容</span></span></div>
<form
style={{ display: "flex", gap: 10, flexWrap: "wrap" }}
onSubmit={(e) => {
e.preventDefault();
if (title.trim()) create.mutate({ kind: "post", title: title.trim() });
}}
>
<input
className="input"
style={{ flex: 1, minWidth: 220 }}
placeholder="发布一条动态,如:共读《陶庵梦忆》第一讲笔记"
value={title}
onChange={(e) => setTitle(e.target.value)}
aria-label="动态标题"
/>
<button className="btn btn--primary btn--sm" type="submit" disabled={create.isPending || !title.trim()}>
{create.isPending ? "发布中…" : "发布"}
</button>
</form>
{create.isError && (
<p style={{ fontSize: 12, color: "var(--err)", marginTop: 8 }}>{(create.error as { message?: string }).message}</p>
)}
</div>
<PanelState pending={contents.isPending} error={contents.error} onRetry={() => contents.refetch()} empty={list.length === 0}>
{list.map((c: CommunityContent) => (
<div className="card post" key={c.id} style={{ marginBottom: 14 }}>
<div className="post__head">
<span className="avatar avatar--sm">镜</span>
<div>
<div className="post__author">镜域 {c.author_domain_id.slice(-6)}</div>
<div className="post__time">{new Date(c.created_at).toLocaleString("zh-CN", { hour12: false })}</div>
</div>
<span className="badge badge--ok" style={{ marginLeft: "auto", fontSize: 11 }}>
<span className="badge__dot" />已刊 · {c.status}
</span>
</div>
<div className="post__body">
<p style={{ color: "var(--text-1)", fontSize: 14.5 }}>{c.title ?? "(无标题)"}</p>
</div>
<div className="post__foot">
<button className="act-btn">评论</button>
<button className="act-btn">收藏</button>
<button className="act-btn" style={{ marginLeft: "auto" }}>鸣冤</button>
</div>
</div>
))}
</PanelState>
</div>
);
}
@@ -0,0 +1,170 @@
import type { ContentBlockLike } from "@/lib/api";
/**
* ContentBlock 判别联合渲染器(静态阶段)。
* 契约要求:穷尽判别、未知类型走兼容占位不崩溃;Markdown 未闭合结构保持源码态。
* 完整的分级实时渲染(Shiki/KaTeX/Mermaid Worker)按 frontend-content-rendering.md 在后续接入。
*/
function src(b: ContentBlockLike, ...keys: string[]): string {
for (const k of keys) {
const v = b[k];
if (typeof v === "string" && v) return v;
}
return "";
}
function TextBlock({ b }: { b: ContentBlockLike }) {
const text = src(b, "text", "source");
if (b.status === "streaming") {
return <p style={{ whiteSpace: "pre-wrap" }}>{text}<span className="stream-cursor" aria-hidden="true" /></p>;
}
return <p style={{ whiteSpace: "pre-wrap" }}>{text}</p>;
}
function CodeBlock({ b }: { b: ContentBlockLike }) {
return (
<div className="block">
<div className="block__head"><span className="lang">{src(b, "language") || "code"}</span>{src(b, "filename") && <span className="fname">{src(b, "filename")}</span>}</div>
<pre className="code">{src(b, "source", "code", "text")}</pre>
</div>
);
}
function QuoteBlock({ b }: { b: ContentBlockLike }) {
return (
<blockquote className="quote">
{src(b, "text", "content")}
{src(b, "source") && <span className="quote__src">引文锚 · {src(b, "source")}</span>}
</blockquote>
);
}
function TableBlock({ b }: { b: ContentBlockLike }) {
const columns = Array.isArray(b.columns) ? (b.columns as string[]) : [];
const rows = Array.isArray(b.rows) ? (b.rows as unknown[][]) : [];
return (
<div className="block">
<div className="block__head">{src(b, "caption") || "表格"}</div>
<div className="table-wrap">
<table className="data">
{columns.length > 0 && (
<thead><tr>{columns.map((c, i) => <th key={i}>{c}</th>)}</tr></thead>
)}
<tbody>
{rows.map((row, i) => (
<tr key={i}>{row.map((cell, j) => <td key={j}>{String(cell ?? "")}</td>)}</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function CalloutBlock({ b }: { b: ContentBlockLike }) {
const kind = src(b, "kind") || "info";
const tone = kind === "danger" ? "var(--err)" : kind === "warning" || kind === "success" ? "var(--warn)" : "var(--accent)";
return (
<div className="block" style={{ borderLeft: `3px solid ${tone}` }}>
<div className="block__body">
{src(b, "title") && <b style={{ display: "block", marginBottom: 4 }}>{src(b, "title")}</b>}
<p style={{ whiteSpace: "pre-wrap" }}>{src(b, "content")}</p>
</div>
</div>
);
}
function MathBlock({ b }: { b: ContentBlockLike }) {
return (
<div className="block">
<div className="math">
{src(b, "source")}
{src(b, "fallback_text") && <span className="math__fallback">fallback:{src(b, "fallback_text")}</span>}
</div>
</div>
);
}
function MermaidBlock({ b }: { b: ContentBlockLike }) {
return (
<div className="block">
<div className="block__head"><span className="lang">mermaid</span><span>完成后以 strict mode 隔离渲染</span></div>
<pre className="code">{src(b, "source")}</pre>
</div>
);
}
function MediaBlock({ b }: { b: ContentBlockLike }) {
const label = src(b, "file_id") ? `文件 ${src(b, "file_id").slice(-8)}` : "媒体";
return (
<div className="block">
<div className="block__head">{b.type}{src(b, "filename") && <span className="fname">{src(b, "filename")}</span>}</div>
<div className="block__body"><p style={{ fontSize: 12.5, color: "var(--text-3)" }}>{label} · 资源就绪后加载(短期授权 URL 换取待接入)</p></div>
</div>
);
}
function StatusBlock({ b }: { b: ContentBlockLike }) {
const label: Record<string, string> = {
tool_call: "工具调用", tool_result: "工具结果", approval_request: "请命",
agent_status: "Agent 状态", artifact: "产物", citation: "引文", error: "错误",
};
return (
<div className="block tool">
<div className="tool__head">
<span className="tool__name">{label[b.type] ?? b.type}</span>
<span className="tool__purpose">{src(b, "tool_name", "name", "title")}</span>
{src(b, "status") && <span className="badge badge--muted" style={{ marginLeft: "auto", fontSize: 11 }}><span className="badge__dot" />{src(b, "status")}</span>}
</div>
<div className="tool__body">
<div className="tool__row"><span className="k2">内容</span><span className="mono" style={{ fontSize: 11.5 }}>{JSON.stringify(b).slice(0, 160)}</span></div>
</div>
</div>
);
}
export function BlockRenderer({ block }: { block: ContentBlockLike }) {
switch (block.type) {
case "text":
case "markdown":
return <TextBlock b={block} />;
case "code":
return <CodeBlock b={block} />;
case "quote":
return <QuoteBlock b={block} />;
case "table":
return <TableBlock b={block} />;
case "callout":
return <CalloutBlock b={block} />;
case "math":
return <MathBlock b={block} />;
case "mermaid":
return <MermaidBlock b={block} />;
case "image":
case "video":
case "audio":
case "file":
case "gallery":
case "avatar":
return <MediaBlock b={block} />;
case "tool_call":
case "tool_result":
case "approval_request":
case "agent_status":
case "artifact":
case "citation":
case "error":
return <StatusBlock b={block} />;
default:
return (
<div className="block">
<div className="block__body">
<p style={{ fontSize: 12.5, color: "var(--text-3)" }}>
当前客户端暂不支持「{block.type}」块——已按兼容占位处理,内容不丢失。
</p>
</div>
</div>
);
}
}
@@ -0,0 +1,86 @@
"use client";
import { ChevronsLeft } from "lucide-react";
import { useState } from "react";
/**
* 镜树三栏布局:左右面板可折叠,内容区自适应放宽(773 → 1000 → 1140)。
* 折叠时浮动恢复按钮出现在消息流顶角。
*/
export function ConvLayout({
side,
main,
ctx,
}: {
side: React.ReactNode;
main: React.ReactNode;
ctx: React.ReactNode;
}) {
const [noLeft, setNoLeft] = useState(false);
const [noRight, setNoRight] = useState(false);
const cls = ["conv-layout", noLeft ? "no-left" : "", noRight ? "no-right" : ""]
.filter(Boolean)
.join(" ");
return (
<div className={cls}>
<aside className="conv-side" aria-label="会话列表">
<div className="conv-side__head">
<span className="avatar avatar--sm">梧</span>
<div style={{ flex: 1 }}>
<div className="cn" style={{ fontWeight: 600 }}>
青梧
</div>
<div style={{ fontSize: 11, color: "var(--text-3)" }}>道体 v3 · 临照</div>
</div>
<button
type="button"
className="panel-toggle"
onClick={() => setNoLeft(true)}
aria-label="收起会话列表"
title="收起会话列表"
style={{ marginLeft: 4 }}
>
<ChevronsLeft />
</button>
</div>
{side}
</aside>
<section className="conv-main">
<button
type="button"
className="conv-restore conv-restore--left"
onClick={() => setNoLeft(false)}
aria-label="展开会话列表"
title="展开会话列表"
>
<ChevronsLeft />
</button>
<button
type="button"
className="conv-restore conv-restore--right"
onClick={() => setNoRight(false)}
aria-label="展开分支面板"
title="展开分支与上下文面板"
>
<ChevronsLeft style={{ transform: "rotate(180deg)" }} />
</button>
{main}
</section>
<aside className="conv-ctx" aria-label="分支与上下文">
<div style={{ display: "flex", justifyContent: "flex-end", marginBottom: 6 }}>
<button
type="button"
className="panel-toggle"
onClick={() => setNoRight(true)}
aria-label="收起面板"
title="收起分支与上下文面板"
>
<ChevronsLeft style={{ transform: "rotate(180deg)" }} />
</button>
</div>
{ctx}
</aside>
</div>
);
}
@@ -0,0 +1,382 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { Send } from "lucide-react";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { BlockRenderer } from "@/components/conversation/BlockRenderer";
import { EffortControl, SearchPill } from "@/components/conversation/EffortControl";
import { ConvLayout } from "@/components/conversation/ConvLayout";
import {
useConversation,
useCreateConversation,
useCreateToolCall,
useMessages,
useRunEvents,
useSendMessage,
} from "@/lib/queries";
import type { Character, CursorPage, Message } from "@/lib/api";
function MessageRow({ msg }: { msg: Message }) {
if (msg.role === "user") {
return (
<div className="msg msg--user">
<div className="msg__meta">
<span className="cn">问节</span>
<span className="mono">{msg.id.slice(-6)}</span>
<span>{new Date(msg.created_at).toLocaleString("zh-CN", { hour12: false })}</span>
</div>
<div className="bubble">
{msg.content.map((b, i) => (
<BlockRenderer key={String(b.id ?? i)} block={b} />
))}
</div>
</div>
);
}
return (
<div className="msg">
<div className="msg--char__head">
<span className="avatar avatar--sm">镜</span>
<span className="msg--char__name">{msg.role === "assistant" ? "角色" : msg.role}</span>
{msg.canonical_scope && (
<span className="badge badge--muted" style={{ fontSize: 11 }}>
<span className="badge__dot" />
归真 · {msg.canonical_scope}
</span>
)}
</div>
<div className="msg__blocks" style={{ paddingLeft: 0 }}>
<div className="block block--text">
<div className="block__body">{msg.content.map((b, i) => <BlockRenderer key={String(b.id ?? i)} block={b} />)}</div>
</div>
</div>
</div>
);
}
export function ConversationPanel() {
const qc = useQueryClient();
const [convId, setConvId] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [activeRunId, setActiveRunId] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [characterId, setCharacterId] = useState("");
const [effortHint, setEffortHint] = useState("深虑 · 中");
const [error, setError] = useState<string | null>(null);
// URL ?c= 携带当前会话
useEffect(() => {
const c = new URLSearchParams(window.location.search).get("c");
if (c) setConvId(c);
}, []);
const characters = useQuerySafe<Character[]>("/v1/characters?limit=20");
const conversation = useConversation(convId);
const messages = useMessages(convId);
const send = useSendMessage(convId);
const create = useCreateConversation();
function openConversation(id: string) {
setConvId(id);
window.history.replaceState(null, "", `/conversation?c=${id}`);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!draft.trim() || !convId) return;
setError(null);
try {
const result = await send.mutateAsync(draft.trim());
setDraft("");
if (result?.run?.id) setActiveRunId(result.run.id);
} catch (err) {
setError((err as { message?: string }).message ?? "发送失败");
}
}
const items = (characters.data as Character[] | undefined) ?? [];
useEffect(() => {
if (!characterId && items.length > 0) setCharacterId(items[0].id);
}, [items, characterId]);
return (
<ConvLayout
side={
<>
<div style={{ padding: "4px 6px 12px" }}>
<form
style={{ display: "flex", flexDirection: "column", gap: 8 }}
onSubmit={async (e) => {
e.preventDefault();
setError(null);
try {
const c = await create.mutateAsync({
title: title.trim() || "未命名对话",
character_id: characterId || undefined,
});
setTitle("");
openConversation(c.id);
qc.invalidateQueries({ queryKey: ["messages"] });
} catch (err) {
setError((err as { message?: string }).message ?? "镜树创建失败,请重试");
}
}}
>
<select
className="input"
value={characterId}
onChange={(e) => setCharacterId(e.target.value)}
aria-label="选择角色"
>
<option value="">不绑定角色</option>
{items.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
<input
className="input"
placeholder="新对话主题(可留空)"
value={title}
onChange={(e) => setTitle(e.target.value)}
aria-label="新对话主题"
/>
<button className="btn btn--outline btn--sm" type="submit" disabled={create.isPending}>
{create.isPending ? "创建中…" : "开始新镜树"}
</button>
{error && (
<p role="alert" style={{ fontSize: 12, color: "var(--err)" }}>{error}</p>
)}
</form>
</div>
{convId && (
<a className="conv-item is-active" href={`/conversation?c=${convId}`} aria-current="page">
<div className="conv-item__title">
<span className="conv-item__dot conv-item__dot--active" title="当前会话" />
{String((conversation.data as { title?: string } | undefined)?.title ?? "当前对话")}
</div>
<div className="conv-item__sub">会话列表端点待 WP-03 补齐,当前按会话直达</div>
</a>
)}
</>
}
main={
<>
<div className="conv-stream">
<div className="conv-stream__inner">
{!convId && (
<div className="realm-banner" role="status">
从左侧选择角色并开始一棵新的镜树——分支、候答与界域切换随消息数据启用。
</div>
)}
{convId && messages.isPending && <p style={{ color: "var(--text-3)", fontSize: 13 }}>正在读取消息…</p>}
{convId && messages.isError && (
<p style={{ color: "var(--err)", fontSize: 13 }}>
读取失败:{(messages.error as { message?: string }).message ?? "请重试"}
</p>
)}
{convId &&
((messages.data?.data.length ?? 0) === 0) &&
!messages.isPending && (
<p style={{ color: "var(--text-3)", fontSize: 13, textAlign: "center", padding: "40px 0" }}>
这棵镜树还没有消息——在下方呈镜第一句。
</p>
)}
{messages.data?.data.map((m) => <MessageRow key={m.id} msg={m} />)}
<RunStream runId={activeRunId} onDone={(finalStatus) => { messages.refetch(); if (finalStatus !== "failed") setActiveRunId(null); }} />
</div>
</div>
<div className="composer">
<div className="composer__inner">
<div className="composer__box">
<textarea
className="composer__ta"
rows={2}
placeholder={convId ? "对角色说……(Enter 发送 · Shift+Enter 换行)" : "先在左侧开始一棵镜树"}
aria-label="消息输入"
disabled={!convId}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit(e as unknown as React.FormEvent);
}
}}
/>
<div className="composer__bar">
<EffortControl
effort="中"
onEffortChange={(v) => setEffortHint("深虑 · " + v)}
onToggle={(on) => setEffortHint(on ? "深虑 · 中" : "深虑 · 关")}
/>
<SearchPill />
<span className="spacer" style={{ flex: 1 }} />
<button
type="button"
className="btn btn--primary btn--sm"
aria-label="发送(呈镜)"
title="发送 · 呈镜"
disabled={!convId || send.isPending || !draft.trim()}
onClick={() => submit({ preventDefault: () => {} } as React.FormEvent)}
>
<Send />呈镜
</button>
</div>
</div>
{error && <p style={{ fontSize: 12, color: "var(--err)", marginTop: 6 }}>{error}</p>}
<div className="composer__hint">
<span>模型 <span className="k">会话临时(网关路由待 WP-05 暴露选择端点)</span></span>
<span>思考 <span className="k">{effortHint}</span>(点深虑开关,右侧调力度)</span>
</div>
</div>
</div>
</>
}
ctx={
<>
<section className="ctx-section">
<div className="ctx-section__head">
<span className="cn">镜枝<span className="cn__plain">分支结构</span></span>
</div>
{convId ? (
<BranchTree convId={convId} />
) : (
<p style={{ fontSize: 12.5, color: "var(--text-3)" }}>开始会话后显示真实分支。</p>
)}
</section>
<section className="ctx-section">
<div className="ctx-section__head">
<span className="cn">上下文镜<span className="cn__plain">ContextSnapshot</span></span>
</div>
<p style={{ fontSize: 12.5, color: "var(--text-3)" }}>
召回证据与省略项随 WP-04 ContextSnapshot 暴露后接入;当前仅呈现结构占位。
</p>
</section>
</>
}
/>
);
}
/** 分支树:真实 branches 数据(主脉/当前分支) */
function BranchTree({ convId }: { convId: string }) {
const branches = useQuerySafe<{ id: string; is_current?: boolean; [k: string]: unknown }>(
`/v1/conversations/${convId}/branches`,
);
if (branches.pending) return <p style={{ fontSize: 12.5, color: "var(--text-3)" }}>读取分支…</p>;
if (branches.error) return <p style={{ fontSize: 12.5, color: "var(--err)" }}>分支读取失败</p>;
const list = branches.data ?? [];
if (list.length === 0)
return <p style={{ fontSize: 12.5, color: "var(--text-3)" }}>尚无分支——重生成会产生候答分支。</p>;
return (
<div className="tree" role="group" aria-label="分支树">
<ul>
{list.map((b) => (
<li key={b.id}>
<div className={b.is_current ? "tree__node tree__node--main tree__node--now" : "tree__node"}>
<span className="nm mono">{b.id.slice(-8)}</span>
{b.is_current && <span className="tree__tag tree__tag--main">主脉 · 当前</span>}
</div>
</li>
))}
</ul>
</div>
);
}
/** 轻量查询(面板内局部使用) */
import { useQuery } from "@tanstack/react-query";
function useQuerySafe<T>(path: string) {
const q = useQuery({ queryKey: ["panel", path], queryFn: () => api<CursorPage<T> | { data: T[] }>(path) });
const data = (q.data as CursorPage<T> | undefined)?.data ?? ((q.data as { data?: T[] } | undefined)?.data as T[] | undefined);
return { pending: q.isPending, error: q.error, data, refetch: q.refetch };
}
/** Run 流式区:临时芽体(delta 累积)+ 失败卡 + 完成后由父级对账权威快照 */
function RunStream({ runId, onDone }: { runId: string | null; onDone: (finalStatus: string | null) => void }) {
const stream = useRunEvents(runId, onDone);
if (!runId) return null;
return (
<div className="msg">
<div className="msg--char__head">
<span className="avatar avatar--sm">镜</span>
<span className="msg--char__name">临时芽体</span>
<span className={`badge badge--${stream.done ? (stream.finalStatus === "failed" ? "err" : "ok") : "info"}`}>
<span className="badge__dot" />
{stream.done
? stream.finalStatus === "failed" ? `受阻 · ${stream.runStatus}` : `已成 · ${stream.runStatus}`
: `运转 · ${stream.runStatus}`}
<span className="mono">{stream.runStatus}</span>
</span>
</div>
<div className="msg__blocks" style={{ paddingLeft: 0 }}>
{stream.blocks.map((b) => (
<div key={b.id} className={b.status === "streaming" ? "block sprout" : "block"}>
{b.status === "streaming" && (
<div className="sprout__head">
<span className="cn" style={{ fontWeight: 600 }}>临时芽体</span>
<span className="cn__plain">流式生成中 · 完成后才固化</span>
<span style={{ marginLeft: "auto" }} className="mono">{b.type}</span>
</div>
)}
<div className="block__body">
{b.status === "failed" ? (
<p style={{ color: "var(--err)", fontSize: 13.5 }}>
受阻 · 失败:<span className="mono">{b.error}</span>
{b.error === "no_endpoint_available" && (
<span style={{ color: "var(--text-3)", display: "block", fontSize: 12, marginTop: 4 }}>
没有可用的模型端点——在神识阁配置端点(WP-05)后重试;流中断不伪装完成。
</span>
)}
</p>
) : (
<p style={{ whiteSpace: "pre-wrap" }}>
{b.text}
{b.status === "streaming" && <span className="stream-cursor" aria-hidden="true" />}
</p>
)}
</div>
</div>
))}
{stream.blocks.length === 0 && !stream.done && (
<p style={{ fontSize: 13, color: "var(--text-3)" }}>正在连接事件流…</p>
)}
{stream.done && stream.finalStatus && (
<p style={{ fontSize: 11.5, color: "var(--text-3)" }}>
message.completed(final_status: <span className="mono">{stream.finalStatus}</span>)——已按权威快照对账。
</p>
)}
</div>
</div>
);
}
/** 工具调用发起(挂在当前会话最新 Run 或新 Run 上) */
export function ToolCallForm({ runId }: { runId: string | null }) {
const create = useCreateToolCall(runId);
const [input, setInput] = useState('{"expression":"1+2*3"}');
if (!runId) return null;
return (
<form
style={{ padding: "8px 6px", display: "flex", gap: 8, flexWrap: "wrap" }}
onSubmit={(e) => {
e.preventDefault();
try {
create.mutate({ tool: "calculate", input: JSON.parse(input) });
} catch {
/* JSON 非法时忽略 */
}
}}
>
<span style={{ fontSize: 12, color: "var(--text-3)", alignSelf: "center" }} className="mono">calculate</span>
<input className="input" style={{ flex: 1, minWidth: 180 }} value={input} onChange={(e) => setInput(e.target.value)} aria-label="工具参数 JSON" />
<button className="btn btn--outline btn--sm" type="submit" disabled={create.isPending}>
{create.isPending ? "调用中…" : "发起工具调用"}
</button>
{create.isError && <span style={{ fontSize: 12, color: "var(--err)", alignSelf: "center" }}>{(create.error as { message?: string }).message}</span>}
</form>
);
}
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
/**
* 深虑控制:点击深虑 = 开启深度思考;开启后右侧伴随出现力度选项(独立控件,只调档);
* 再次点击 = 关闭,力度选项随之消失;重开保留上次档位。
*/
export function EffortControl({
effort,
onEffortChange,
onToggle,
}: {
effort: string;
onEffortChange: (v: "低" | "中" | "高") => void;
onToggle: (on: boolean) => void;
}) {
const [on, setOn] = useState(true);
const [val, setVal] = useState<"低" | "中" | "高">(effort as "低" | "中" | "高");
const toggle = () => {
const next = !on;
setOn(next);
onToggle(next);
};
return (
<>
<button
type="button"
className={on ? "pill-btn is-on" : "pill-btn"}
aria-pressed={on}
data-pill="effort"
onClick={toggle}
title={
on
? "深虑 · 深度思考已开启,再次点击关闭"
: "深虑 · 已关闭:本条消息不思考,直出答复(推理型模型不可关)"
}
>
深虑
</button>
<span className="effort-seg" data-effort-seg hidden={!on} role="radiogroup" aria-label="推理力度">
{(["低", "中", "高"] as const).map((level) => (
<button
key={level}
type="button"
role="radio"
aria-checked={val === level}
title={{ 低: "低 · 快省", 中: "中 · 均衡", 高: "高 · 深究慢" }[level]}
onClick={(e) => {
e.stopPropagation();
setVal(level);
onEffortChange(level);
}}
>
{level}
</button>
))}
</span>
<span hidden>
<input readOnly value={on ? val : "关"} aria-hidden="true" tabIndex={-1} />
</span>
</>
);
}
/** 博闻:独立开关(联网检索,走百工坊白名单) */
export function SearchPill() {
const [on, setOn] = useState(false);
return (
<button
type="button"
className={on ? "pill-btn is-on" : "pill-btn"}
aria-pressed={on}
onClick={() => setOn((v) => !v)}
title="博闻 · 本条消息启用联网检索(走百工坊白名单,点击开关)"
>
博闻<small>{on ? "开" : "关"}</small>
</button>
);
}
@@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import { PanelState, useCreateListing, useFinanceSummary, useListings } from "@/lib/queries";
import type { Listing } from "@/lib/api";
function yuan(minor: number | null, currency: string | null) {
if (minor == null) return "免费";
return `¥${(minor / 100).toFixed(2)}${currency ? ` ${currency.toUpperCase()}` : ""}`;
}
export function MarketplacePanel() {
const listings = useListings();
const finance = useFinanceSummary();
const create = useCreateListing();
const [title, setTitle] = useState("");
const [price, setPrice] = useState("");
const list = listings.data?.data ?? [];
return (
<div>
<div className="realm-banner" role="note">
购买的是版本与许可(市契),不是人格所有权。金额、分润与退契规则始终直白展示。
</div>
<div className="card" style={{ marginBottom: 16, padding: 16 }}>
<div className="card__head"><span className="cn">上架<span className="cn__plain">成为创作者</span></span></div>
<form
style={{ display: "flex", gap: 10, flexWrap: "wrap" }}
onSubmit={(e) => {
e.preventDefault();
if (!title.trim()) return;
const minor = Math.round(parseFloat(price || "0") * 100);
create.mutate({ title: title.trim(), price_minor: isNaN(minor) ? 0 : minor, currency: "cny" });
}}
>
<input className="input" style={{ flex: 1, minWidth: 200 }} placeholder="商品名,如:雾中茶寮 · 世界合集" value={title} onChange={(e) => setTitle(e.target.value)} aria-label="商品名" />
<input className="input" style={{ width: 120 }} placeholder="价格 ¥(0 为免费)" inputMode="decimal" value={price} onChange={(e) => setPrice(e.target.value)} aria-label="价格" />
<button className="btn btn--primary btn--sm" type="submit" disabled={create.isPending || !title.trim()}>
{create.isPending ? "上架中…" : "上架"}
</button>
</form>
</div>
<section className="section">
<div className="section__head">
<span className="cn">云市<span className="cn__plain">商品</span></span>
</div>
<PanelState pending={listings.isPending} error={listings.error} onRetry={() => listings.refetch()} empty={list.length === 0}>
<div className="asset-grid">
{list.map((l: Listing) => (
<div className="asset-card" key={l.id}>
<div className="asset-thumb" style={{ background: "linear-gradient(150deg,#2f4858,#6a8caf 60%,#3b5a70)" }}>
<span className="glyph">{l.title.slice(0, 1)}</span>
</div>
<div className="asset-body">
<span className="asset-name">{l.title}</span>
<span className="asset-sub">{l.status === "published" ? "已发布" : l.status}</span>
<div style={{ display: "flex", justifyContent: "space-between", marginTop: 2 }}>
<span className="price">{l.price_minor ? yuan(l.price_minor, l.currency) : <span className="price price--free">免费</span>}</span>
</div>
</div>
</div>
))}
</div>
</PanelState>
</section>
<section className="section card">
<div className="card__head"><span className="cn">结算<span className="cn__plain">创作者收益</span></span></div>
<PanelState pending={finance.isPending} error={finance.error} empty={Object.keys(finance.data?.totals_minor ?? {}).length === 0}>
<div style={{ display: "flex", gap: 28, flexWrap: "wrap", alignItems: "center" }}>
{Object.entries(finance.data?.totals_minor ?? {}).map(([cur, minor]) => (
<div key={cur}>
<div style={{ fontSize: 11.5, color: "var(--text-3)" }}>{cur.toUpperCase()} 余额</div>
<div className="price" style={{ fontSize: 22 }}>{yuan(minor, cur)}</div>
</div>
))}
<span className="badge badge--ok"><span className="badge__dot" />四账平衡 · {String(finance.data?.balanced ?? false)}</span>
</div>
<p style={{ fontSize: 12, color: "var(--text-3)", marginTop: 10 }}>
Ledger 不可变,修正写反向分录;每日自动对账(支付 / 订单 / Ledger / 结算)。
</p>
</PanelState>
</section>
</div>
);
}
@@ -0,0 +1,135 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api";
import { PanelState, useDecideCandidate, useMemories, useMemoryCandidates } from "@/lib/queries";
import type { Memory, MemoryCandidate } from "@/lib/api";
const KIND_LABEL: Record<string, string> = {
fact: "事实", preference: "偏好", event: "事件", relationship: "关系",
};
function candidateText(c: MemoryCandidate) {
const p = c.payload as { text?: string; summary?: string; content?: string };
return p.text ?? p.summary ?? p.content ?? JSON.stringify(c.payload);
}
export function MemoryPanel() {
const candidates = useMemoryCandidates();
const memories = useMemories();
const decide = useDecideCandidate();
const [tab, setTab] = useState<"candidates" | "memories">("candidates");
const candList = candidates.data?.data ?? [];
const memList = memories.data?.data ?? [];
return (
<div>
<div className="tabs" role="tablist" aria-label="记忆类型">
<button className={tab === "candidates" ? "tab is-on" : "tab"} role="tab" aria-selected={tab === "candidates"} onClick={() => setTab("candidates")}>
候选审阅 <span className="cnt">{candList.length}</span>
</button>
<button className={tab === "memories" ? "tab is-on" : "tab"} role="tab" aria-selected={tab === "memories"} onClick={() => setTab("memories")}>
明忆 <span className="cnt">{memList.length}</span>
</button>
</div>
<div className="realm-banner" role="note">
归真的约束:候选经你确认才会影响未来上下文;境界内容只成世界知识候选,幻境内容须手动入忆。
</div>
{tab === "candidates" && (
<PanelState pending={candidates.isPending} error={candidates.error} onRetry={() => candidates.refetch()}>
{candList.length === 0 ? (
<div className="card">
<p style={{ fontSize: 13.5, color: "var(--text-2)" }}>当前没有待审候选。</p>
<p style={{ fontSize: 12, color: "var(--text-3)", marginTop: 6 }}>
候选来自镜树对话与大周天评估——发一段对话后回到这里,或使用下方手动添加。
</p>
<ManualCandidate />
</div>
) : (
candList.map((c) => (
<div className="card" key={c.id} style={{ marginBottom: 14 }}>
<div className="card__head">
<span className="mem-type">{KIND_LABEL[c.kind] ?? c.kind}</span>
<span style={{ fontSize: 11.5, color: "var(--text-3)" }}>
来源 · {c.source} · 敏感度 {c.sensitivity}
</span>
<span style={{ marginLeft: "auto", fontSize: 11.5, color: "var(--text-3)" }}>{c.id.slice(-8)}</span>
</div>
<p className="cn" style={{ fontSize: 14 }}>{candidateText(c)}</p>
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
<button className="btn btn--primary btn--sm" disabled={decide.isPending} onClick={() => decide.mutate({ id: c.id, approve: true })}>
归真入忆
</button>
<button className="btn btn--ghost btn--sm" disabled={decide.isPending} onClick={() => decide.mutate({ id: c.id, approve: false })}>
忽略
</button>
</div>
</div>
))
)}
</PanelState>
)}
{tab === "memories" && (
<PanelState pending={memories.isPending} error={memories.error} onRetry={() => memories.refetch()} empty={memList.length === 0}>
<div className="card" style={{ padding: "6px 18px" }}>
{memList.map((m: Memory) => (
<div className="list-row" key={m.id}>
<span className="mem-type" style={{ flex: "none" }}>{KIND_LABEL[m.kind] ?? m.kind}</span>
<div className="list-row__body">
<div className="list-row__title">{candidateText(m as unknown as MemoryCandidate)}</div>
<div className="list-row__sub">{m.source} · {new Date(m.created_at).toLocaleString("zh-CN", { hour12: false })}</div>
</div>
<span className="badge badge--ok" style={{ fontSize: 11 }}><span className="badge__dot" />已归真</span>
</div>
))}
</div>
</PanelState>
)}
</div>
);
}
/** 手动添加候选(走同一审核流,用于联调验证闭环) */
function ManualCandidate() {
const [text, setText] = useState("");
const [kind, setKind] = useState("preference");
const [state, setState] = useState<{ pending: boolean; error: string | null; ok: boolean }>({ pending: false, error: null, ok: false });
return (
<form
style={{ display: "flex", gap: 8, marginTop: 14, flexWrap: "wrap" }}
onSubmit={async (e) => {
e.preventDefault();
if (!text.trim()) return;
setState({ pending: true, error: null, ok: false });
try {
await api("/v1/memories/candidates", {
method: "POST",
json: { kind, sensitivity: "low", realm: "reality", payload: { text: text.trim() }, source: "manual" },
});
setText("");
setState({ pending: false, error: null, ok: true });
window.location.reload();
} catch (err) {
setState({ pending: false, error: (err as { message?: string }).message ?? "创建失败", ok: false });
}
}}
>
<select className="input" style={{ width: 100 }} value={kind} onChange={(e) => setKind(e.target.value)} aria-label="候选类型">
<option value="preference">偏好</option>
<option value="fact">事实</option>
<option value="event">事件</option>
<option value="relationship">关系</option>
</select>
<input className="input" style={{ flex: 1, minWidth: 200 }} placeholder="手动添加一条候选,如:读书会隔周四 19:30" value={text} onChange={(e) => setText(e.target.value)} aria-label="候选内容" />
<button className="btn btn--outline btn--sm" type="submit" disabled={state.pending || !text.trim()}>
{state.pending ? "添加中…" : "添加候选"}
</button>
{state.error && <span style={{ fontSize: 12, color: "var(--err)", alignSelf: "center" }}>{state.error}</span>}
</form>
);
}
+364
View File
@@ -0,0 +1,364 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { api, clearCsrf, type Character, type CursorPage, type ToolCall } from "@/lib/api";
import { PanelState, useApproveToolCall, useLogout, useMemoryCandidates, useToolCalls } from "@/lib/queries";
/* ---------- 镜庭:聚合真实状态 ---------- */
export function HomePanel() {
const characters = useQuery({
queryKey: ["characters"],
queryFn: () => api<CursorPage<Character>>("/v1/characters?limit=10"),
});
const candidates = useMemoryCandidates();
const toolCalls = useToolCalls();
const chars = characters.data?.data ?? [];
const candCount = candidates.data?.data.length ?? 0;
const calls = toolCalls.data?.data ?? [];
return (
<div>
<div className="hero">
<div>
<h1>镜庭</h1>
<p style={{ color: "var(--text-2)", fontSize: 13, marginTop: 4 }}>
镜庭只聚合状态,各域才是数据事实源
</p>
</div>
<div className="hero__stats">
<a className="hero__stat" href="/characters"><b>{chars.length}</b><span>镜生 · 角色</span></a>
<a className="hero__stat" href="/memory"><b>{candCount}</b><span>息壤候选 · 待审</span></a>
<a className="hero__stat" href="/agent-workspace"><b>{calls.length}</b><span>天工 · 工具调用</span></a>
</div>
</div>
<section className="section">
<div className="section__head">
<span className="cn">镜生<span className="cn__plain">你的角色</span></span>
<a className="more" href="/characters">管理 →</a>
</div>
<PanelState pending={characters.isPending} error={characters.error} onRetry={() => characters.refetch()} empty={chars.length === 0}>
<div className="grid-cards">
{chars.map((c) => (
<a className="card char-card" key={c.id} href="/characters">
<div className="char-card__top">
<span className="avatar">{c.name.slice(0, 1)}</span>
<div>
<div className="char-card__name">{c.name}</div>
<div style={{ fontSize: 12, color: "var(--text-3)" }}>
{c.status === "draft" ? "灵胚 · 未启镜" : c.status}
</div>
</div>
</div>
<p className="char-card__line">
{c.status === "draft"
? "灵胚是未创建完成的角色草稿——可删除,不等于角色死亡。"
: "角色运行中。"}
</p>
<div className="char-card__foot">
<span className={`badge badge--${c.status === "draft" ? "warn" : "info"}`}>
<span className="badge__dot" />
{c.status === "draft" ? "灵胚 · 草稿" : `${c.status}`}
</span>
<span className="char-card__time">{new Date(c.created_at).toLocaleDateString("zh-CN")}</span>
</div>
</a>
))}
</div>
</PanelState>
</section>
<section className="section card">
<div className="card__head">
<span className="cn">待你决定<span className="cn__plain">审批与候选</span></span>
</div>
<div className="list-row">
<span className="badge" style={{ flex: "none" }}>息壤候选 ×{candCount}</span>
<div className="list-row__body">
<div className="list-row__title">{candCount > 0 ? "有候选等待你的归真决定" : "暂无待审候选"}</div>
<div className="list-row__sub">归真后才会影响未来上下文</div>
</div>
<a className="btn btn--outline btn--sm" href="/memory">去审阅</a>
</div>
</section>
</div>
);
}
/* ---------- 形仪:真实资产列表 ---------- */
export function AvatarPanel() {
const avatars = useQuery({
queryKey: ["avatars"],
queryFn: () => api<{ data: { id: string; kind?: string; status?: string; [k: string]: unknown }[] }>("/v1/avatars"),
});
const list = avatars.data?.data ?? [];
return (
<section className="section">
<div className="section__head">
<span className="cn">资产库<span className="cn__plain">图像 / 声音 / Live2D / 3D</span></span>
</div>
<PanelState pending={avatars.isPending} error={avatars.error} onRetry={() => avatars.refetch()} empty={list.length === 0}>
<div className="asset-grid">
{list.map((a) => (
<div className="asset-card" key={a.id}>
<div className="asset-thumb" style={{ background: "linear-gradient(150deg,#233b37,#3f7a6e 60%,#2a4d47)" }}>
<span className="glyph">{String(a.kind ?? "形").slice(0, 1)}</span>
</div>
<div className="asset-body">
<span className="asset-name mono">{a.id.slice(-10)}</span>
<span className="asset-sub">{String(a.kind ?? "avatar")} · {String(a.status ?? "")}</span>
</div>
</div>
))}
</div>
</PanelState>
</section>
);
}
/* ---------- 天工阁:能力与调用 ---------- */
export function RunsPanel() {
const tools = useQuery({
queryKey: ["tools"],
queryFn: () => api<{ data: { name: string; description: string; risk_level: string; requires_approval: boolean }[] }>("/v1/tools"),
});
const calls = useToolCalls();
const toolList = tools.data?.data ?? [];
const callList = calls.data?.data ?? [];
return (
<div>
<div className="realm-banner" role="note">
天工是功能执行者,不冒充角色身份;工具结果只是观察,采用前需验证。
</div>
<section className="section">
<div className="section__head"><span className="cn">工筹<span className="cn__plain">可用工具</span></span></div>
<PanelState pending={tools.isPending} error={tools.error} onRetry={() => tools.refetch()} empty={toolList.length === 0}>
<div className="card" style={{ padding: "6px 18px" }}>
{toolList.map((t) => (
<div className="list-row" key={t.name}>
<div className="list-row__body">
<div className="list-row__title mono">{t.name}</div>
<div className="list-row__sub">{t.description}</div>
</div>
<span className={`badge badge--${t.requires_approval ? "warn" : "ok"}`} style={{ fontSize: 11 }}>
<span className="badge__dot" />{t.requires_approval ? "须请命" : "只读"}
</span>
</div>
))}
</div>
</PanelState>
</section>
<section className="section">
<div className="section__head"><span className="cn">轨迹<span className="cn__plain">工具调用记录</span></span></div>
<PanelState pending={calls.isPending} error={calls.error} onRetry={() => calls.refetch()} empty={callList.length === 0}>
<div className="card" style={{ padding: "6px 18px" }}>
{callList.map((c: ToolCall) => (
<div className="list-row" key={String(c.id)}>
<div className="list-row__body">
<div className="list-row__title mono">{String(c.tool_name ?? c.id.slice(-8))}</div>
<div className="list-row__sub">{String(c.status ?? "")}</div>
</div>
</div>
))}
</div>
</PanelState>
</section>
</div>
);
}
/* ---------- 神识阁:观测(模型路由选择端点待 WP-05) ---------- */
export function GatewayPanel() {
const metrics = useQuery({
queryKey: ["metrics"],
queryFn: async () => {
const res = await fetch("/api/v1/metrics");
if (!res.ok) throw new Error(`metrics ${res.status}`);
return res.text();
},
});
return (
<div>
<div className="realm-banner" role="note">
神识是可替换推理能力,不等于角色身份。首个可见输出后不切换 Provider。
</div>
<section className="section card">
<div className="card__head"><span className="cn">运行观测<span className="cn__plain">/v1/metrics</span></span></div>
<PanelState pending={metrics.isPending} error={metrics.error} onRetry={() => metrics.refetch()}>
<pre className="code" style={{ maxHeight: 260, overflow: "auto" }}>{metrics.data}</pre>
</PanelState>
</section>
<section className="section card">
<div className="card__head"><span className="cn">逻辑模型与路由</span></div>
<p style={{ fontSize: 13, color: "var(--text-3)" }}>
逻辑模型目录与路由继承链的读取端点属 WP-05(模型网关与运行观测),暴露后在此呈现五层继承与 BYOK 管理。
</p>
</section>
</div>
);
}
/* ---------- 司命台:审批与治理(真实数据 + 边界) ---------- */
export function GovernancePanel() {
const calls = useToolCalls();
const approve = useApproveToolCall();
const list = calls.data?.data ?? [];
const history = useQuery({
queryKey: ["tool-calls-history"],
queryFn: async () => {
const [ok, denied] = await Promise.all([
api<{ data: ToolCall[] }>("/v1/tool-calls?status_filter=succeeded"),
api<{ data: ToolCall[] }>("/v1/tool-calls?status_filter=denied"),
]);
return [...(ok.data ?? []), ...(denied.data ?? [])];
},
});
return (
<div>
<div className="realm-banner" role="note">
治理不支配角色或用户数据;隐藏推理、密钥与凭据永不进入诊断与审计展示。
</div>
<section className="section">
<div className="section__head"><span className="cn">请命<span className="cn__plain">等待你确认的工具调用</span></span></div>
<PanelState pending={calls.isPending} error={calls.error} onRetry={() => calls.refetch()} empty={list.length === 0}>
<div className="card" style={{ padding: "6px 18px" }}>
{list.map((c: ToolCall) => {
const pending = String(c.status ?? "") === "pending_approval";
return (
<div className="list-row" key={String(c.id)}>
<div className="list-row__body">
<div className="list-row__title mono">{String(c.tool_name ?? c.id.slice(-8))}</div>
<div className="list-row__sub">
{String(JSON.stringify(c.input ?? {}))}{c.output ? ` → ${String(c.output).slice(0, 60)}` : ""}
</div>
</div>
{pending ? (
<>
<button
className="btn btn--cinnabar btn--sm"
disabled={approve.isPending}
onClick={() => approve.mutate({ id: String(c.id), approve: true })}
>
允许(朱批)
</button>
<button
className="btn btn--outline btn--sm"
disabled={approve.isPending}
onClick={() => approve.mutate({ id: String(c.id), approve: false })}
>
拒绝
</button>
</>
) : (
<span className={`badge badge--${c.status === "executed" || c.status === "completed" ? "ok" : c.status === "rejected" ? "muted" : "muted"}`} style={{ fontSize: 11 }}>
<span className="badge__dot" />{String(c.status)}
</span>
)}
</div>
);
})}
</div>
</PanelState>
</section>
<section className="section">
<div className="section__head"><span className="cn">朱批留痕<span className="cn__plain">已处理的调用</span></span></div>
<PanelState pending={history.isPending} error={history.error} empty={(history.data ?? []).length === 0}>
<div className="card" style={{ padding: "6px 18px" }}>
{(history.data ?? []).map((c: ToolCall) => (
<div className="list-row" key={String(c.id)}>
<div className="list-row__body">
<div className="list-row__title mono">{String(c.tool_name)}</div>
<div className="list-row__sub">{String(JSON.stringify(c.output ?? c.input ?? {})).slice(0, 90)}</div>
</div>
<span className={`badge badge--${c.status === "succeeded" ? "ok" : "muted"}`} style={{ fontSize: 11 }}>
<span className="badge__dot" />{String(c.status)}
</span>
</div>
))}
</div>
</PanelState>
</section>
<section className="section card">
<div className="card__head"><span className="cn">诊断授权<span className="cn__plain">双重授权</span></span></div>
<p style={{ fontSize: 12.5, color: "var(--text-2)" }}>
读取 Run 详细诊断需普通认证之外叠加短期诊断令牌:绑定单个 Run、单次使用、默认 15 分钟。
需诊断时从镜树/天工阁的具体 Run 发起(POST /v1/runs/{"{run_id}"}/diagnostic-grants)。
</p>
</section>
</div>
);
}
/* ---------- 设置:登出 + 财务(真实)+ 其余空态 ---------- */
export function SettingsPanel() {
const logout = useLogout();
const finance = useQuery({
queryKey: ["finance-summary"],
queryFn: () => api<{ totals_minor: Record<string, number>; balanced: boolean }>("/v1/finance/summary"),
});
return (
<div>
<section className="section card">
<div className="card__head"><span className="cn">会话</span></div>
<div className="set-row">
<div className="set-row__body">
<div className="set-row__t">退出当前会话</div>
<div className="set-row__d">登出并撤销 Session Cookie(DELETE /v1/auth/sessions/current)</div>
</div>
<button
className="btn btn--outline btn--sm"
disabled={logout.isPending}
onClick={async () => {
await logout.mutateAsync().catch(() => undefined);
clearCsrf();
window.location.href = "/login";
}}
>
{logout.isPending ? "登出中…" : "登出"}
</button>
</div>
</section>
<section className="section card">
<div className="card__head"><span className="cn">结算<span className="cn__plain">财务摘要</span></span></div>
<PanelState pending={finance.isPending} error={finance.error}>
<div style={{ display: "flex", gap: 24, flexWrap: "wrap" }}>
{Object.entries(finance.data?.totals_minor ?? {}).map(([cur, minor]) => (
<div key={cur}>
<div style={{ fontSize: 11.5, color: "var(--text-3)" }}>{cur.toUpperCase()}</div>
<div className="price">{`¥${(minor / 100).toFixed(2)}`}</div>
</div>
))}
<span className="badge badge--ok"><span className="badge__dot" />账平 · {String(finance.data?.balanced ?? false)}</span>
</div>
</PanelState>
</section>
<section className="section card">
<div className="card__head"><span className="cn">数据权与私箧</span></div>
<p style={{ fontSize: 12.5, color: "var(--text-3)" }}>
导出、撤权、删除与私箧管理端点属后续工作包(WP-01 私箧 / WP-02 可携导出),暴露后在此接入。
</p>
</section>
</div>
);
}
/* ---------- 器谱:真实空态(设备 API 属 WP-10) ---------- */
export function DevicesPanel() {
return (
<div>
<div className="realm-banner" role="note">
设备能力同时受系统权限与授契约束;采集有持续指示,可即时撤销。
</div>
<div className="card">
<div className="card__head"><span className="cn">形器<span className="cn__plain">登记设备</span></span></div>
<p style={{ fontSize: 13.5, color: "var(--text-2)" }}>
设备登记与 DeviceGrant 端点属 WP-10(Web、桌面、移动与感知),尚未暴露。
</p>
<p style={{ fontSize: 12.5, color: "var(--text-3)", marginTop: 6 }}>
接入后此处显示:形器列表、授契(闻声 / 观屏 / 问路 · 范围与期限)、通感进行中、直白权限请求。
</p>
</div>
</div>
);
}
@@ -0,0 +1,77 @@
"use client";
import { useState } from "react";
import { PanelState, useInstallPlugin, usePlugins, useTools } from "@/lib/queries";
import type { PluginMeta, ToolMeta } from "@/lib/api";
export function PluginsPanel() {
const plugins = usePlugins();
const tools = useTools();
const install = useInstallPlugin();
const [installed, setInstalled] = useState<string[]>([]);
const pluginList = plugins.data?.data ?? [];
const toolList = tools.data?.data ?? [];
return (
<div>
<div className="realm-banner" role="note">
购买不等于安装,安装不等于启用,启用不等于授予新增权限。安全内核不可插件化。
</div>
<section className="section">
<div className="section__head">
<span className="cn">百工<span className="cn__plain">可用能力集合 · 能力可发现不等于已授权</span></span>
</div>
<PanelState pending={tools.isPending} error={tools.error} onRetry={() => tools.refetch()} empty={toolList.length === 0}>
<div className="card" style={{ padding: "6px 18px" }}>
{toolList.map((t: ToolMeta) => (
<div className="list-row" key={t.name}>
<div className="list-row__body">
<div className="list-row__title mono">{t.name}</div>
<div className="list-row__sub">{t.description}</div>
</div>
<span className={`badge badge--${t.requires_approval ? "warn" : "ok"}`} style={{ fontSize: 11 }}>
<span className="badge__dot" />{t.requires_approval ? "须请命" : t.risk_level === "read_only" ? "只读" : t.risk_level}
</span>
<span className={`badge badge--${t.enabled ? "ok" : "muted"}`} style={{ fontSize: 11 }}>
<span className="badge__dot" />{t.enabled ? "已启用" : "未启用"}
</span>
</div>
))}
</div>
</PanelState>
</section>
<section className="section">
<div className="section__head">
<span className="cn">插件<span className="cn__plain">注册表</span></span>
</div>
<PanelState pending={plugins.isPending} error={plugins.error} onRetry={() => plugins.refetch()} empty={pluginList.length === 0}>
<div className="card" style={{ padding: "6px 18px" }}>
{pluginList.map((p: PluginMeta) => (
<div className="list-row" key={p.name}>
<div className="list-row__body">
<div className="list-row__title mono">{p.name}</div>
<div className="list-row__sub">{String(p.description ?? p.kind ?? "plugin")}</div>
</div>
<button
className="btn btn--outline btn--sm"
disabled={install.isPending || installed.includes(p.name)}
onClick={async () => {
await install.mutateAsync(p.name);
setInstalled((prev) => [...prev, p.name]);
}}
>
{installed.includes(p.name) ? "已安装" : "安装"}
</button>
</div>
))}
</div>
<p style={{ fontSize: 12, color: "var(--text-3)", marginTop: 8 }}>
插件注册表为空时表示本部署尚无已注册插件;安装不自动启用,启用不授予新增权限。
</p>
</PanelState>
</section>
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import { useEffect, useState } from "react";
import { ADMIN_NAV } from "@/lib/nav";
import { readSession } from "@/lib/api";
import { Sidebar, useBrandEgg } from "@/components/shell/Sidebar";
import { ThemeButton } from "@/lib/theme";
import { RequireAuth } from "./RequireAuth";
/** 管理面外壳:独立侧栏(锚点导航)+ 管理员身份 + 双人授权提示 */
export function AdminShell({ children }: { children: React.ReactNode }) {
const [folded, setFolded] = useState(false);
const [foot, setFoot] = useState<{ char: string; name: string }>({ char: "司", name: "加载中…" });
useEffect(() => {
const s = readSession();
setFoot({ char: "司", name: s.role === "admin" ? `${s.email.split("@")[0] || "dev"} · 管理员` : "普通用户" });
}, []);
const { onBrandClick, brandState, toast } = useBrandEgg();
useEffect(() => {
try {
const saved = localStorage.getItem("hunjing-sidebar");
if (saved === "collapsed") setFolded(true);
} catch {
/* 忽略 */
}
}, []);
return (
<div className={folded ? "app app--fold" : "app"}>
{toast && <div className="egg-toast is-on">{toast}</div>}
<Sidebar
groups={ADMIN_NAV}
folded={folded}
onToggleFold={() => setFolded((v) => !v)}
onBrandClick={onBrandClick}
brandState={brandState}
brandSub="司命台 · 管理面"
foot={foot}
below={
<div className="admin-back">
<a href="/home">← 返回用户界面</a>
</div>
}
/>
<main className="main">
<RequireAuth>{children}</RequireAuth>
</main>
</div>
);
}
export function AdminThemeButton() {
return <ThemeButton />;
}
@@ -0,0 +1,52 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { NAV_GROUPS } from "@/lib/nav";
import { Sidebar, useBrandEgg } from "./Sidebar";
import { RequireAuth } from "./RequireAuth";
/** 应用外壳:主侧栏(可折叠,localStorage 记忆)+ 主区 */
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [folded, setFolded] = useState(false);
const { onBrandClick, brandState, toast } = useBrandEgg();
useEffect(() => {
try {
const saved = localStorage.getItem("hunjing-sidebar");
if (saved === "collapsed") setFolded(true);
} catch {
/* 忽略 */
}
}, []);
const toggleFold = () => {
setFolded((prev) => {
const next = !prev;
try {
localStorage.setItem("hunjing-sidebar", next ? "collapsed" : "expanded");
} catch {
/* 忽略 */
}
return next;
});
};
return (
<div className={folded ? "app app--fold" : "app"}>
{toast && <div className="egg-toast is-on">{toast}</div>}
<Sidebar
groups={NAV_GROUPS}
activeHref={pathname}
folded={folded}
onToggleFold={toggleFold}
onBrandClick={onBrandClick}
brandState={brandState}
/>
<main className="main">
<RequireAuth>{children}</RequireAuth>
</main>
</div>
);
}
@@ -0,0 +1,46 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { api, ApiError, type CursorPage } from "@/lib/api";
/**
* 登录守卫:进入应用外壳时校验会话(GET /v1/characters),
* 未登录(401/403)跳转登录页。校验期间显示轻量占位,不闪烁内容。
*/
export function RequireAuth({ children }: { children: React.ReactNode }) {
const router = useRouter();
const check = useQuery({
queryKey: ["auth-check"],
queryFn: () => api<CursorPage<unknown>>("/v1/characters?limit=1"),
retry: false,
staleTime: 30_000,
});
if (check.isError) {
const status = check.error instanceof ApiError ? check.error.status : 0;
if (status === 401 || status === 403) {
router.replace("/login");
return (
<div style={{ display: "grid", placeItems: "center", minHeight: "60vh" }}>
<p style={{ fontSize: 14, color: "var(--text-3)" }}>未登入,正在前往登录页…</p>
</div>
);
}
return (
<div style={{ display: "grid", placeItems: "center", minHeight: "60vh" }}>
<p style={{ fontSize: 14, color: "var(--err)" }}>会话校验失败,请刷新重试</p>
</div>
);
}
if (check.isPending) {
return (
<div style={{ display: "grid", placeItems: "center", minHeight: "60vh" }}>
<p style={{ fontSize: 14, color: "var(--text-3)" }}>正在校验会话…</p>
</div>
);
}
return <>{children}</>;
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { ChevronsLeft } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import type { NavGroup } from "@/lib/nav";
import { ThemeButton } from "@/lib/theme";
/** 镜启彩蛋诗签(承接 main 分支旧版设计) */
const EGG_VERSES = [
"镜中有镜,心外无心。",
"汝来看此镜时,此镜亦照汝。",
"一念起,万镜生。",
"镜非鉴物,乃鉴心也。",
"幻由心生,镜随心转。",
"铜镜本无字,照尽往来人。",
"且向镜中看,那个是谁?",
];
interface SidebarProps {
groups: NavGroup[];
activeHref?: string;
folded: boolean;
onToggleFold: () => void;
onBrandClick: (e: React.MouseEvent) => void;
brandState: { tick: boolean; pulse: boolean };
brandSub?: string;
foot?: { char: string; name: string };
below?: React.ReactNode;
}
export function Sidebar({ groups, activeHref, folded, onToggleFold, onBrandClick, brandState, brandSub = "Hunjing", foot, below }: SidebarProps) {
return (
<aside className="sidebar">
<div
className={`brand${brandState.tick ? " tick" : ""}${brandState.pulse ? " egg-pulse" : ""}`}
onClick={onBrandClick}
role="link"
tabIndex={0}
aria-label="魂镜 · 单击回目录,五连击触发镜启彩蛋"
>
<div className="brand__mark" aria-hidden="true" />
<div className="brand__text">
<div className="brand__name">魂镜</div>
<div className="brand__sub">{brandSub}</div>
</div>
</div>
<nav className="side-nav" aria-label="主导航">
{groups.map((group) => (
<div className="nav-group" key={group.title}>
<div className="nav-group__title">{group.title}</div>
{group.items.map((item) => {
const Icon = item.icon;
const active = item.href === activeHref;
return (
<a
key={item.href + item.label}
className={active ? "nav-item is-active" : "nav-item"}
href={item.href}
aria-current={active ? "page" : undefined}
>
<Icon />
<span className="cn">{item.label}</span>
<span className="nav-plain">{item.plain}</span>
</a>
);
})}
</div>
))}
</nav>
<div className="side-foot">
<button
type="button"
className="icon-btn side-fold-btn"
onClick={onToggleFold}
aria-label={folded ? "展开侧栏" : "收起侧栏"}
title="收起 / 展开侧栏(记忆偏好)"
>
<ChevronsLeft />
</button>
{foot ? (
<>
<span className={`avatar avatar--sm avatar--user`}>{foot.char}</span>
<span className="side-foot__name">{foot.name}</span>
</>
) : (
<>
<span className="avatar avatar--sm avatar--user">舟</span>
<span className="side-foot__name">云舟</span>
</>
)}
<ThemeButton />
</div>
{below}
</aside>
);
}
/** 品牌彩蛋逻辑:单击延迟回目录;首击 + 4 次连点 = 镜启 */
export function useBrandEgg() {
const router = useRouter();
const seq = useRef(0);
const last = useRef(0);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [pulse, setPulse] = useState(false);
const [tick, setTick] = useState(false);
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
const onBrandClick = (e: React.MouseEvent) => {
e.preventDefault();
if (timer.current) clearTimeout(timer.current);
const now = Date.now();
const fast = now - last.current < 380;
last.current = now;
setTick(true);
setTimeout(() => setTick(false), 300);
if (fast) {
seq.current += 1;
if (seq.current >= 4) {
seq.current = 0;
setPulse(true);
setTimeout(() => setPulse(false), 1400);
const verse = EGG_VERSES[Math.floor(Math.random() * EGG_VERSES.length)];
setToast(`镜启 · ${verse}`);
setTimeout(() => setToast(null), 2500);
}
return;
}
seq.current = 0;
timer.current = setTimeout(() => router.push("/"), 380);
};
return { onBrandClick, brandState: { tick, pulse }, toast };
}
+19
View File
@@ -0,0 +1,19 @@
import { ThemeButton } from "@/lib/theme";
interface TopbarProps {
crumbs: React.ReactNode;
extra?: React.ReactNode;
}
/** 顶栏:面包屑 + 原型标记 + 主题切换 */
export function Topbar({ crumbs, extra }: TopbarProps) {
return (
<header className="topbar">
<div className="crumbs">{crumbs}</div>
{extra}
<div className="topbar__spacer" />
<span className="prototype-tag">静态原型 · 待联调</span>
<ThemeButton />
</header>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
/* ---------- 面包屑 ---------- */
export function Crumbs({ cn, here }: { cn: string; here?: string }) {
return (
<>
<span className="cn">{cn}</span>
{here && (
<>
<span className="crumbs__sep">/</span>
<span className="here">{here}</span>
</>
)}
</>
);
}
/* ---------- 状态徽章:文化状态 · 直白状态 · 稳定值 ---------- */
type BadgeTone = "ok" | "info" | "warn" | "err" | "muted";
export function Badge({
tone = "muted",
children,
stable,
style,
}: {
tone?: BadgeTone;
children: ReactNode;
stable?: string;
style?: React.CSSProperties;
}) {
return (
<span className={`badge badge--${tone}`} style={style}>
<span className="badge__dot" />
{children}
{stable && <span className="mono">{stable}</span>}
</span>
);
}
+228
View File
@@ -0,0 +1,228 @@
/**
* API 客户端(开发档联调):
* - 同源 /api 前缀经 Next rewrites 代理至后端,Session Cookie 自动携带;
* - 写请求带 Hunjing-CSRF-Token(双提交,契约头名);
* - 错误统一映射为 ApiError(status + code + message),不匹配错误字符串。
*/
const BASE = "/api";
export class ApiError extends Error {
constructor(
public status: number,
public code: string | null,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
function csrfToken(): string | null {
try {
return localStorage.getItem("hunjing-csrf");
} catch {
return null;
}
}
export function storeCsrf(token: string) {
try {
localStorage.setItem("hunjing-csrf", token);
} catch {
/* 忽略 */
}
}
export function storeSession(session: { role?: string; email?: string }) {
try {
localStorage.setItem("hunjing-role", session.role ?? "user");
localStorage.setItem("hunjing-email", session.email ?? "");
} catch {
/* 忽略 */
}
}
export function readSession(): { role: string; email: string } {
try {
return {
role: localStorage.getItem("hunjing-role") ?? "user",
email: localStorage.getItem("hunjing-email") ?? "",
};
} catch {
return { role: "user", email: "" };
}
}
export function clearCsrf() {
try {
localStorage.removeItem("hunjing-csrf");
localStorage.removeItem("hunjing-role");
localStorage.removeItem("hunjing-email");
} catch {
/* 忽略 */
}
}
export async function api<T>(
path: string,
init: RequestInit & { json?: unknown } = {},
): Promise<T> {
const headers = new Headers(init.headers);
if (init.json !== undefined) {
headers.set("Content-Type", "application/json");
}
const method = (init.method ?? "GET").toUpperCase();
if (method !== "GET" && method !== "HEAD") {
const csrf = csrfToken();
if (csrf) headers.set("Hunjing-CSRF-Token", csrf);
}
const res = await fetch(`${BASE}${path}`, {
...init,
headers,
body: init.json !== undefined ? JSON.stringify(init.json) : init.body,
credentials: "same-origin",
});
if (res.status === 204) return undefined as T;
const payload = await res.json().catch(() => null);
if (!res.ok) {
const detail = (payload as { detail?: string } | null)?.detail;
const code =
(payload as { error?: { code?: string } } | null)?.error?.code ?? null;
throw new ApiError(res.status, code, detail ?? `请求失败(${res.status})`);
}
return payload as T;
}
/* ---------- 联调用的领域类型(源自运行时 OpenAPI;正式契约冻结后切 openapi-typescript 生成) ---------- */
export interface SessionInfo {
csrf_token: string;
user_id: string;
role: string;
email: string;
}
export interface Character {
id: string;
name: string;
status: string;
origin: string;
based_on_real_person: boolean;
identity_baseline: Record<string, unknown>;
drives: unknown[];
current_version_id: string | null;
created_at: string;
}
export interface CursorPage<T> {
data: T[];
next_cursor: string | null;
has_more: boolean;
}
/* ---------- 联调领域类型(按后端实测形状;正式契约冻结后切生成类型) ---------- */
export interface Memory {
id: string;
character_id: string | null;
kind: string;
sensitivity: string;
realm: string | null;
payload: Record<string, unknown>;
source: string;
status: string;
created_at: string;
}
export interface MemoryCandidate {
id: string;
character_id: string | null;
kind: string;
sensitivity: string;
realm: string | null;
payload: Record<string, unknown>;
source: string;
status: string;
created_at: string;
}
export interface ContentBlockLike {
type: string;
[k: string]: unknown;
}
export interface Message {
id: string;
conversation_id: string;
branch_id: string | null;
role: string;
content: ContentBlockLike[];
parser_version: string | null;
created_at: string;
canonical_scope?: string | null;
}
export interface Conversation {
id: string;
title: string;
[k: string]: unknown;
}
export interface Branch {
id: string;
[k: string]: unknown;
}
export interface PluginMeta {
name: string;
[k: string]: unknown;
}
export interface CommunityContent {
id: string;
kind: string;
title: string | null;
body: Record<string, unknown>;
visibility: string;
status: string;
author_domain_id: string;
created_at: string;
}
export interface Listing {
id: string;
title: string;
price_minor: number | null;
currency: string | null;
status: string;
}
export interface Workspace {
id: string;
name: string;
owner_domain_id: string;
}
export interface ToolCall {
id: string;
[k: string]: unknown;
}
export interface AvatarAsset {
id: string;
[k: string]: unknown;
}
export interface FinanceSummary {
totals_minor: Record<string, number>;
balanced: boolean;
}
export interface ToolMeta {
name: string;
description: string;
risk_level: string;
requires_approval: boolean;
enabled: boolean;
}
+101
View File
@@ -0,0 +1,101 @@
import {
Activity,
BookOpen,
Database,
FileText,
GitBranch,
Globe,
Home,
Layers,
Lock,
Monitor,
Package,
Route,
Server,
Shield,
ShoppingBag,
SlidersHorizontal,
SquareKanban,
User,
Users,
} from "lucide-react";
export interface NavItem {
href: string;
label: string;
plain: string;
icon: React.ComponentType;
}
export interface NavGroup {
title: string;
items: NavItem[];
}
export const NAV_GROUPS: NavGroup[] = [
{
title: "常用",
items: [
{ href: "/home", label: "镜庭", plain: "首页", icon: Home },
{ href: "/conversation", label: "镜树", plain: "对话", icon: GitBranch },
{ href: "/characters", label: "镜生", plain: "角色", icon: User },
{ href: "/memory", label: "息壤", plain: "记忆", icon: BookOpen },
{ href: "/avatar", label: "形仪", plain: "形象", icon: Layers },
],
},
{
title: "创造",
items: [
{ href: "/agent-workspace", label: "天工阁", plain: "任务", icon: SquareKanban },
{ href: "/plugins", label: "百工坊", plain: "插件", icon: Package },
{ href: "/collaboration", label: "同契台", plain: "协作", icon: Users },
{ href: "/model-gateway", label: "神识阁", plain: "模型", icon: Route },
],
},
{
title: "世界",
items: [
{ href: "/community", label: "雅集", plain: "社区", icon: Globe },
{ href: "/marketplace", label: "云市", plain: "市场", icon: ShoppingBag },
],
},
{
title: "治理与账户",
items: [
{ href: "/governance", label: "司命台", plain: "治理", icon: Shield },
{ href: "/devices", label: "器谱", plain: "设备", icon: Monitor },
{ href: "/settings", label: "设置", plain: "账户", icon: SlidersHorizontal },
],
},
];
export const ADMIN_NAV: NavGroup[] = [
{
title: "运营",
items: [
{ href: "/admin#queue", label: "治理队列", plain: "", icon: SquareKanban },
{ href: "/admin#tickets", label: "支持工单", plain: "3", icon: FileText },
{ href: "/admin#users", label: "用户", plain: "", icon: Users },
{ href: "/admin#perms", label: "权限管理", plain: "", icon: Shield },
],
},
{
title: "部署",
items: [
{ href: "/admin#health", label: "服务健康", plain: "", icon: Server },
{ href: "/admin#backup", label: "迁移与备份", plain: "", icon: Database },
{ href: "/admin#queue", label: "插件治理", plain: "", icon: Package },
],
},
{
title: "观测",
items: [
{ href: "/admin#slo", label: "SLO 与指标", plain: "", icon: Activity },
{ href: "/admin#audit", label: "审计日志", plain: "", icon: Shield },
],
},
{
title: "边界",
items: [{ href: "/governance", label: "治理边界说明", plain: "", icon: Lock }],
},
];
+373
View File
@@ -0,0 +1,373 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { api, type CursorPage } from "./api";
/* ---------- 通用四态外壳:加载 / 未登入 / 失败 / 内容 ---------- */
export function PanelState({
pending,
error,
onRetry,
children,
empty,
}: {
pending: boolean;
error: unknown;
onRetry?: () => void;
children: React.ReactNode;
empty?: boolean;
}) {
if (pending) {
return <p style={{ fontSize: 13, color: "var(--text-3)", padding: "18px 4px" }}>正在读取…</p>;
}
if (error) {
const status = (error as { status?: number }).status ?? 0;
if (status === 401 || status === 403) {
return (
<div style={{ padding: "20px 4px", textAlign: "center" }}>
<p style={{ fontSize: 13.5, color: "var(--text-2)", marginBottom: 12 }}>尚未登入,无法读取你的镜域数据。</p>
<a className="btn btn--primary btn--sm" href="/login">去登入</a>
</div>
);
}
return (
<div style={{ padding: "18px 4px" }}>
<p style={{ fontSize: 13, color: "var(--err)", marginBottom: 6 }}>读取失败</p>
<p style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 10 }}>
{(error as { message?: string }).message ?? "请稍后重试"}
</p>
{onRetry && <button className="btn btn--outline btn--sm" onClick={onRetry}>重试</button>}
</div>
);
}
if (empty) {
return (
<p style={{ fontSize: 13, color: "var(--text-3)", padding: "18px 4px" }}>
暂无数据——这里将显示你的真实内容。
</p>
);
}
return <>{children}</>;
}
export function useUnauthorized(v: unknown) {
return (v as { status?: number })?.status === 401;
}
/* ---------- 会话(镜树) ---------- */
export function useCreateConversation() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { character_id?: string; title: string }) =>
api<{ id: string; title: string }>("/v1/conversations", { method: "POST", json: body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["conversation"] }),
});
}
export function useConversation(id: string | null) {
return useQuery({
queryKey: ["conversation", id],
enabled: !!id,
queryFn: () => api<Record<string, unknown>>(`/v1/conversations/${id}`),
});
}
export function useMessages(id: string | null) {
return useQuery({
queryKey: ["messages", id],
enabled: !!id,
queryFn: () => api<CursorPage<import("./api").Message>>(`/v1/conversations/${id}/messages?limit=50`),
});
}
export interface SendMessageResult {
message: { id: string };
run: { id: string } | null;
}
export function useSendMessage(conversationId: string | null) {
const qc = useQueryClient();
return useMutation({
mutationFn: (text: string) =>
api<SendMessageResult>(`/v1/conversations/${conversationId}/messages`, {
method: "POST",
json: { content: [{ type: "text", text }], start_run: true },
headers: { "Idempotency-Key": crypto.randomUUID() },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["messages", conversationId] }),
});
}
/* ---------- Run SSE:块级事件消费(重放持久化流,sequence 单调) ---------- */
export interface StreamBlock {
id: string;
type: string;
status: "streaming" | "completed" | "failed" | "cancelled";
text: string;
error?: string;
}
export interface RunStreamState {
runStatus: string;
blocks: StreamBlock[];
finalStatus: string | null;
done: boolean;
}
export function useRunEvents(runId: string | null, onDone?: (finalStatus: string | null) => void) {
const [state, setState] = useState<RunStreamState>({ runStatus: "connecting", blocks: [], finalStatus: null, done: false });
// onDone 经 ref 稳定化:避免内联回调导致事件流反复重连重置
const onDoneRef = useRef(onDone);
onDoneRef.current = onDone;
useEffect(() => {
if (!runId) return;
const ac = new AbortController();
setState({ runStatus: "connecting", blocks: [], finalStatus: null, done: false });
/** 契约事件归约(第 5 节):块级 started/delta/completed/failed + 终态 */
const applyEvent = (e: { type: string; data: Record<string, unknown> }) => {
setState((prev) => {
const next = { ...prev, blocks: [...prev.blocks] };
switch (e.type) {
case "run.status.changed":
next.runStatus = String(e.data.status ?? "");
break;
case "content.block.started": {
const b = (e.data.block ?? {}) as { id?: string; type?: string };
next.blocks.push({ id: String(b.id ?? crypto.randomUUID()), type: String(b.type ?? "text"), status: "streaming", text: "" });
break;
}
case "content.block.delta": {
const d = e.data as { block_id?: string; delta?: { text?: string } };
const idx = next.blocks.findIndex((x) => x.id === d.block_id);
const chunk = String(d.delta?.text ?? "");
if (idx >= 0) next.blocks[idx] = { ...next.blocks[idx], text: next.blocks[idx].text + chunk };
break;
}
case "content.block.completed": {
const b = (e.data.block ?? {}) as { id?: string };
const idx = next.blocks.findIndex((x) => x.id === b.id);
if (idx >= 0) next.blocks[idx] = { ...next.blocks[idx], status: "completed" };
break;
}
case "content.block.failed": {
const b = (e.data.block ?? {}) as { id?: string; type?: string };
const err = String(e.data.error ?? "unknown");
const idx = next.blocks.findIndex((x) => x.id === b.id);
const failed = { id: String(b.id ?? crypto.randomUUID()), type: String(b.type ?? "text"), status: "failed" as const, text: "", error: err };
if (idx >= 0) next.blocks[idx] = failed;
else next.blocks.push(failed);
break;
}
case "message.completed":
next.finalStatus = String(e.data.final_status ?? "");
break;
default:
break;
}
if (
e.type === "message.completed" ||
(e.type === "run.status.changed" && ["completed", "failed", "cancelled"].includes(next.runStatus))
) {
next.done = true;
}
return next;
});
};
const API_ORIGIN = process.env.NEXT_PUBLIC_API_ORIGIN ?? "http://127.0.0.1:8000";
const xhr = new XMLHttpRequest();
xhr.open("GET", `${API_ORIGIN}/v1/runs/${runId}/events`);
xhr.withCredentials = true;
xhr.setRequestHeader("Accept", "text/event-stream");
let processed = 0;
xhr.onprogress = () => {
// XHR 增量流式:responseText 为累积文本,按已处理游标切帧
const text = xhr.responseText;
const fresh = text.slice(processed);
processed = text.length;
const frames = fresh.split("\n\n");
for (const frame of frames) {
const line = frame.split("\n").find((l) => l.startsWith("data: "));
if (!line) continue;
try {
applyEvent(JSON.parse(line.slice(6)));
} catch {
/* 忽略无法解析的帧 */
}
}
};
xhr.onload = () => setState((prev) => (prev.done ? prev : { ...prev, done: true }));
xhr.onerror = () => {
setState((prev) => ({ ...prev, done: true, runStatus: prev.runStatus === "connecting" ? "recovering" : prev.runStatus }));
};
ac.signal.addEventListener("abort", () => xhr.abort());
xhr.send();
const doneCheck = setInterval(() => {
setState((prev) => {
if (prev.done) {
clearInterval(doneCheck);
xhr.abort();
onDoneRef.current?.(prev.finalStatus);
return prev;
}
return prev;
});
}, 250);
return () => {
ac.abort();
clearInterval(doneCheck);
};
}, [runId]);
return state;
}
/* ---------- 工具调用:发起与审批 ---------- */
export function useCreateToolCall(runId: string | null) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ tool, input }: { tool: string; input: Record<string, unknown> }) =>
api(`/v1/runs/${runId}/tool-calls`, {
method: "POST",
json: { tool_name: tool, input },
headers: { "Idempotency-Key": crypto.randomUUID() },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ["tool-calls"] }),
});
}
export function useApproveToolCall() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, approve }: { id: string; approve: boolean }) =>
api(`/v1/tool-calls/${id}/approvals`, { method: "POST", json: { approve } }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["tool-calls"] }),
});
}
/* ---------- 记忆(息壤) ---------- */
export function useMemories() {
return useQuery({
queryKey: ["memories"],
queryFn: () => api<CursorPage<import("./api").Memory>>("/v1/memories?limit=50"),
});
}
export function useMemoryCandidates() {
return useQuery({
queryKey: ["memory-candidates"],
queryFn: () => api<{ data: import("./api").MemoryCandidate[] }>("/v1/memories/candidates"),
});
}
export function useDecideCandidate() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, approve }: { id: string; approve: boolean }) =>
api(`/v1/memories/candidates/${id}/decisions`, { method: "POST", json: { approve } }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["memory-candidates"] });
qc.invalidateQueries({ queryKey: ["memories"] });
},
});
}
/* ---------- 社区(雅集) ---------- */
export function useContents() {
return useQuery({
queryKey: ["community-contents"],
queryFn: () => api<CursorPage<import("./api").CommunityContent>>("/v1/community/contents?limit=20"),
});
}
export function useCreateContent() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { kind: string; title: string }) =>
api("/v1/community/contents", { method: "POST", json: body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["community-contents"] }),
});
}
/* ---------- 市场(云市) ---------- */
export function useListings() {
return useQuery({
queryKey: ["listings"],
queryFn: () => api<{ data: import("./api").Listing[] }>("/v1/marketplace/listings"),
});
}
export function useCreateListing() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { title: string; price_minor: number; currency: string }) =>
api("/v1/marketplace/listings", { method: "POST", json: body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["listings"] }),
});
}
/* ---------- 协作(同契台) ---------- */
export function useCreateWorkspace() {
return useMutation({
mutationFn: (body: { name: string }) =>
api<import("./api").Workspace>("/v1/workspaces", { method: "POST", json: body }),
});
}
/* ---------- 插件(百工坊) ---------- */
export function usePlugins() {
return useQuery({
queryKey: ["plugins"],
queryFn: () => api<{ data: import("./api").PluginMeta[] }>("/v1/plugins"),
});
}
export function useInstallPlugin() {
return useMutation({
mutationFn: (name: string) =>
api(`/v1/plugins/${name}/installations`, { method: "POST", json: {} }),
});
}
/* ---------- 工具与审批(天工阁 / 司命台) ---------- */
export function useToolCalls() {
return useQuery({
queryKey: ["tool-calls"],
queryFn: () => api<CursorPage<import("./api").ToolCall>>("/v1/tool-calls?limit=30"),
});
}
export function useTools() {
return useQuery({
queryKey: ["tools"],
queryFn: () => api<{ data: import("./api").ToolMeta[] }>("/v1/tools"),
});
}
/* ---------- 形仪 ---------- */
export function useAvatars() {
return useQuery({
queryKey: ["avatars"],
queryFn: () => api<{ data: import("./api").AvatarAsset[] }>("/v1/avatars"),
});
}
/* ---------- 财务(设置 / 云市) ---------- */
export function useFinanceSummary() {
return useQuery({
queryKey: ["finance-summary"],
queryFn: () => api<import("./api").FinanceSummary>("/v1/finance/summary"),
});
}
/* ---------- 登出 ---------- */
export function useLogout() {
return useMutation({
mutationFn: () => api<void>("/v1/auth/sessions/current", { method: "DELETE" }),
});
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false, staleTime: 15_000 },
},
}),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { Moon } from "lucide-react";
import { createContext, useCallback, useContext, useEffect, useState } from "react";
type Theme = "dark" | "light";
const ThemeContext = createContext<{ theme: Theme; toggle: () => void }>({
theme: "dark",
toggle: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>("dark");
useEffect(() => {
const saved = localStorage.getItem("hunjing-theme");
if (saved === "light" || saved === "dark") {
setTheme(saved);
document.documentElement.setAttribute("data-theme", saved);
}
}, []);
const toggle = useCallback(() => {
setTheme((prev) => {
const next: Theme = prev === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
try {
localStorage.setItem("hunjing-theme", next);
} catch {
/* 忽略 */
}
return next;
});
}, []);
return <ThemeContext.Provider value={{ theme, toggle }}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
return useContext(ThemeContext);
}
export function ThemeButton() {
const { theme, toggle } = useTheme();
return (
<button
type="button"
className="icon-btn"
onClick={toggle}
aria-label={theme === "dark" ? "切换到月白主题" : "切换到墨色主题"}
>
<Moon />
</button>
);
}
+8
View File
@@ -0,0 +1,8 @@
import type {} from "react";
declare module "react" {
interface CSSProperties {
/** CSS 自定义属性(原型内联样式中大量使用 var(--token)) */
[key: `--${string}`]: string | number | undefined;
}
}