feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 帧壳 `turnId` 在 socket 层不再被丢(`lib/ws.ts` 的 dispatchFrame 补 turnId/seq)—— 按路分桶的前提
- 并行帧组接入:`turn_begin` / `steer_ack` / `pending_ack` / `pending_drain` / `pending_full` /
  `chat_duplicate` ⇒ 六态 + 灰态 + `(sessionId, turnId)` 两级分桶;`appendChunk` 不再永远写尾气泡
- 多路忙态:`session_interrupted` 不再无条件当空闲(看 mode/activeTurns);`stream_end` 不再只按会话过滤
- `documents` 渲染(含 status/chars/truncated 与**逐字中文 reason**);`turnId` 历史行分组小标
- `System.tsx` 键名改正(真键 HOST/PORT/ADMIN_USERNAME/ADMIN_PASSWORD,避免照抄静默失效)
- 主人档案**按平台分组** + 平台下拉 + 账号候选;**写盘仍展平数组**(后端契约不变)
- 验证:tsc --noEmit ✅、npm run build ✅、22 个前端测试文件全通过(本次新增 3 文件 108 条断言)
- 未做(如实):握手 history handler 与断线补拉、上行 interrupt 按钮、proactive_message/subagent_result
  等帧落点、文档上传入口与图片预检、voice 管线、System 的 env 读端点
This commit is contained in:
AskaEth committed 2026-10-05 11:09:24 +08:00
1 parent 6a545badbd
commit eaada3af5d
34 files changed
+2457 -223

No files matched your search

+30 -4
View File
@@ -8,6 +8,8 @@ import { Tag } from "./primitives";
import { Icon, type IconName } from "./Icon";
import { WakePath } from "./status";
import { modeBody } from "./messages";
import { DocumentList } from "./messages/Documents";
import { DeliveryChip, TurnTag, userBubbleClass } from "./messages/TurnMeta";
import { UserMessageActions } from "./messages/MessageActions";
import { renderContent } from "../lib/renderContent";
import { splitBubble, type ContentPart } from "../lib/parseContent";
@@ -169,6 +171,22 @@ function PersonaLabel({ name }: { name?: string }): React.ReactNode {
);
}
/** 用户气泡下面那行小字:回合小标 + 投递态角标 + 来源。两个用户气泡(单块 / 分段)
* 共用一处,避免「四份渲染各写一遍、日后漂移」。 */
function UserMeta({ message }: { message: ChatMessage }): React.ReactNode {
return (
<div className="flex flex-wrap items-center justify-end gap-1.5">
<TurnTag turnId={message.turnId} align="right" />
<DeliveryChip
delivery={message.delivery}
steerStep={message.steerStep}
rejectReason={message.rejectReason}
/>
<SourceLabel deviceId={message.deviceId} role={message.role} />
</div>
);
}
/** A single message bubble. Per design/07 §1 the bubble shape is driven by the
* message's own `mode`: the same role renders differently under different modes,
* and a single session may mix shapes. Falls back to `chat` when mode is absent
@@ -288,6 +306,7 @@ function AssistantParaBubble({
) : null}
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
{full && switched ? <PersonaLabel name={persona?.name} /> : null}
{full ? <TurnTag turnId={message.turnId} /> : null}
{full ? <WakePath active={active} /> : null}
{full ? <ThinkingBlock text={message.thinking} streaming={message.streaming} /> : null}
{full ? message.tools?.map((t, i) => <ToolCard key={i} tool={t} />) : null}
@@ -309,7 +328,9 @@ function UserParaBubble({
}): React.ReactNode {
return (
<div className="mu-rise-in flex flex-col items-end gap-0.5">
<div className="liquid-glass-accent max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
<div
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm shadow-[var(--mu-shadow-1)]`}
>
{full && message.image ? (
<img
src={message.image}
@@ -317,9 +338,10 @@ function UserParaBubble({
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
/>
) : null}
{full ? <DocumentList documents={message.documents} /> : null}
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
</div>
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
{full ? <UserMeta message={message} /> : null}
</div>
);
}
@@ -391,7 +413,9 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re
) : null;
return (
<div className="mu-rise-in group relative flex flex-col items-end gap-0.5">
<div className="liquid-glass-accent max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
<div
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm shadow-[var(--mu-shadow-1)]`}
>
{message.image ? (
<img
src={message.image}
@@ -399,9 +423,10 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
/>
) : null}
<DocumentList documents={message.documents} />
{body}
</div>
<SourceLabel deviceId={message.deviceId} role={message.role} />
<UserMeta message={message} />
<SenderLabel name={message.senderName} />
<UserMessageActions message={message} />
</div>
@@ -430,6 +455,7 @@ function AssistantBubble({
) : null}
<SourceLabel deviceId={message.deviceId} role={message.role} />
{switched ? <PersonaLabel name={persona?.name} /> : null}
<TurnTag turnId={message.turnId} />
<WakePath active={active} />
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
@@ -0,0 +1,84 @@
import type { MessageDocument } from "../../lib/types";
import { Icon } from "../Icon";
import { Tag } from "../primitives";
/**
* 一条消息携带的文档附件条(契约 §4.4,design/16)。放在用户气泡里(附件是**用户**
* 这一回合带进来的),逐条显示:文件名、状态、注入侧统计,以及 `status:"unreadable"`
* 时后端给的那句**逐字**中文原因。
*
* 两条红线:
* - `chars`/`truncated` 是**注入侧**语义(本回合注入模型上下文的字符数 / 是否被预算
* 截断),不是文件字数与文件截断,所以文案写「注入 N 字」而不是「N 字」;
* - `status` 允许后端新增取值:未知值**原样显示**(display-raw),绝不丢附件、也绝不
* 把未知当错误。`reason` 只由后端产出,前端不自己拼一句。
*/
const STATUS_LABEL: Record<string, string> = {
extracted: "已读入",
listed: "仅清单",
unreadable: "读不到正文",
};
const STATUS_TITLE: Record<string, string> = {
extracted: "正文已注入本轮上下文",
listed: "只给了模型附件清单,正文未进上下文",
unreadable: "读不到正文:原因见下方(服务端原话)",
};
export function DocumentList({
documents,
}: {
documents?: MessageDocument[];
}): React.ReactNode {
if (!documents || documents.length === 0) return null;
return (
<div className="mb-1.5 space-y-1">
{documents.map((doc, i) => (
<DocumentRow key={`${doc.name}-${i}`} doc={doc} />
))}
</div>
);
}
/** 状态标签色:读不到正文用告警色(用户需要知道这条附件没被读到),其余中性/强调。 */
function statusTone(status: string): "accent" | "warn" | "neutral" {
if (status === "unreadable") return "warn";
if (status === "extracted") return "accent";
return "neutral";
}
function DocumentRow({ doc }: { doc: MessageDocument }): React.ReactNode {
const status = doc.status ?? "";
const label = STATUS_LABEL[status] ?? (status || "附件");
return (
<div className="rounded-[var(--mu-radius-sm)] bg-black/15 px-2.5 py-1.5 text-xs">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
<Icon name="docs" size={12} className="shrink-0 text-white/70" />
<span className="min-w-0 max-w-[16rem] truncate font-medium text-white/90">
{doc.name}
</span>
<Tag tone={statusTone(status)} title={STATUS_TITLE[status]}>
{label}
</Tag>
{typeof doc.chars === "number" ? (
<span className="text-white/60">注入 {doc.chars} 字</span>
) : null}
{doc.truncated ? <span className="text-white/60">注入已截断</span> : null}
{typeof doc.size === "number" ? (
<span className="text-white/60">{formatSize(doc.size)}</span>
) : null}
</div>
{doc.reason ? (
<div className="mt-0.5 text-[11px] leading-tight text-white/80">{doc.reason}</div>
) : null}
</div>
);
}
/** 文件大小的紧凑展示(后端给的是字节)。 */
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
}
@@ -0,0 +1,83 @@
import type { MessageDelivery } from "../../lib/types";
import { useAppStore } from "../../store/useAppStore";
import { Icon } from "../Icon";
import { Tag } from "../primitives";
/**
* 六态投递态与回合分组小标(忙时并行规范草案 §1.1 / §1.4)。纯展示、无副作用:
* `delivery` 缺席(老后端 / REST 历史行)时不渲染任何角标,旧行为逐字不变。
*
* 为什么要有它:忙时一条消息可能处在「排队中」或「已受理·回答中(灰态)」——两者
* 语义不同(③ 她还没开始理你这条;④ 这条已经在被处理的那一轮里了),用户必须能
* 一眼分清;被拒时还要明确说「未送达」而不是让气泡静静躺着。
*/
export function DeliveryChip({
delivery,
steerStep,
rejectReason,
}: {
delivery?: MessageDelivery;
steerStep?: number;
rejectReason?: string;
}): React.ReactNode {
switch (delivery) {
case "sending":
return (
<Tag tone="neutral">
<Icon name="clock" size={11} />
发送中
</Tag>
);
case "queued":
return (
<Tag tone="warn" title="会话正忙,这条已排队;轮到她处理时会自动开始">
排队中
</Tag>
);
case "answering":
// 灰态:「已读、未答」。执行到第几步由 steer_ack 的 atStep 给出(后端没给就不写)。
return (
<Tag tone="neutral" title="已被本轮吃进,正在回答;该回合结束时自动收口">
{typeof steerStep === "number"
? `已读 · 回答中(第 ${steerStep} 步)`
: "已读 · 回答中"}
</Tag>
);
case "rejected":
// ⑥ 被拒:文案优先用后端给的(如「排队已满(上限 N 条)」),没给就说清后果。
return <Tag tone="err">{rejectReason ?? "未送达 · 会话正忙"}</Tag>;
default:
// sent / answered / 缺省:正常气泡,不加角标(不制造噪声)。
return null;
}
}
/** 灰态时用户气泡的底色:正文区转灰表示「已读、未答」,收口后回到正常的强调色。 */
export function userBubbleClass(delivery?: MessageDelivery): string {
return delivery === "answering"
? "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)] ring-1 ring-[var(--mu-glass-border)]"
: "liquid-glass-accent text-white";
}
/** 回合分组小标:显示这一行属于哪个回合(有绑定记录时补上「并行路 N」)。
* 它让「回复 ↔ 提问」在真并行下可核对,也是重连后按 `turnId` 分组的落点
* (规范草案 §1.4 呈现 A / §1.6)。 */
export function TurnTag({
turnId,
align = "left",
}: {
turnId?: string;
align?: "left" | "right";
}): React.ReactNode {
// 该路仍在绑定表里(还在飞)时能拿到会话内路序号;已收口的回合退化成只显示 id。
const leg = useAppStore((s) => (turnId ? s.turnBindings[turnId]?.leg : undefined));
if (!turnId) return null;
const short = turnId.length > 12 ? `${turnId.slice(0, 12)}…` : turnId;
return (
<div
className={`text-[10px] leading-tight text-[var(--mu-text-lo)] ${align === "right" ? "text-right" : ""}`}
>
{leg !== undefined && leg > 1 ? `并行路 ${leg} · ` : ""}回合 {short}
</div>
);
}
@@ -0,0 +1,133 @@
// 气泡渲染测试(契约 §4.4 `documents` + 忙时并行规范草案 §1.1 六态 / §1.4 分组小标)。
//
// 为什么需要它:`documents` 此前在**全链**缺席(DTO → parser → ChatMessage → 气泡),
// IM 附件在 Web 上完全看不见;而「已受理·回答中」的灰态与「排队中」「未送达」的区分
// 是画出来才算数的事 —— 纯逻辑测试证明不了它们真的出现在气泡上。
//
// 锁三件事:
// ① `documents` 渲染:文件名、状态、**注入侧**语义(`chars`=注入字符数、`truncated`=
// 注入被预算截断,不能写成「文件 N 字」)、以及 `status:"unreadable"` 时后端给的
// 那句**逐字**中文原因;
// ② 六态角标:发送中 / 排队中 / 已读·回答中(含 atStep)/ 未送达(含原因);缺省与
// 已答不加角标,灰态时气泡底色不再是强调色;
// ③ 回合小标:历史行显示所属 `turnId`(有绑定记录时补「并行路 N」)。
//
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/components/messages/bubble.render.test.ts \
// --bundle --platform=node --format=cjs \
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
// --outfile=/tmp/cyrene-bubble.test.cjs \
// && node /tmp/cyrene-bubble.test.cjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
// 不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { ChatMessage } from "../../lib/types";
import { SplitChatBubble } from "../chat";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(html: string, needle: string): boolean {
return html.includes(needle);
}
function render(m: ChatMessage): string {
return renderToStaticMarkup(createElement(SplitChatBubble, { message: m }));
}
const T = "turn-9f3c1a2b3c4d5e6f";
function user(extra: Partial<ChatMessage> = {}): ChatMessage {
return { id: "m1", role: "user", content: "看看这个附件", createdAt: 1, ...extra };
}
// ── ① documents 全链渲染 ────────────────────────────────────────────────────────
{
const html = render(
user({
documents: [
{ name: "年度报告.pdf", mime: "application/pdf", size: 2 * 1024 * 1024, chars: 8000, truncated: true, status: "extracted" },
{ name: "扫描件.pdf", status: "unreadable", reason: "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)" },
{ name: "只进了清单.docx", status: "listed" },
],
})
);
ok(has(html, "年度报告.pdf"), "渲染附件文件名");
ok(has(html, "扫描件.pdf"), "渲染第二个附件(≤5 条按发送顺序)");
ok(has(html, "已读入"), "extracted 显示「已读入」");
ok(has(html, "仅清单"), "listed 显示「仅清单」(正文没进上下文)");
ok(has(html, "读不到正文"), "unreadable 显示「读不到正文」");
ok(has(html, "注入 8000 字"), "chars 按**注入侧**语义呈现(不是文件字数)");
ok(has(html, "注入已截断"), "truncated 按**注入**口径呈现(不是文件被截断)");
ok(has(html, "2.0 MiB"), "size 渲染成可读体积");
ok(
has(html, "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"),
"unreadable 的 reason **逐字**渲染(后端给的文案,前端不自己拼)"
);
}
// 未知 status 原样呈现(display-raw:后端新增取值时客户端不丢附件、不报错)。
{
const html = render(user({ documents: [{ name: "x.bin", status: "quarantined" }] }));
ok(has(html, "x.bin"), "未知 status 的附件仍然渲染");
ok(has(html, "quarantined"), "未知 status 原样显示");
}
// 没有附件时不出现附件条(0 个附件时契约里键就缺席)。
{
const html = render(user());
ok(!has(html, "注入 "), "无附件时不渲染附件条");
}
// ── ② 六态角标(缺省 = 已送达/已答,不加角标) ──────────────────────────────────
{
const plain = render(user());
ok(!has(plain, "发送中") && !has(plain, "排队中") && !has(plain, "已读"), "缺省投递态不加任何角标");
ok(has(plain, "liquid-glass-accent"), "正常用户气泡仍是强调色");
ok(has(render(user({ delivery: "sending" })), "发送中"), "① sending 显示「发送中」");
ok(has(render(user({ delivery: "queued" })), "排队中"), "③ queued 显示「排队中」(不是灰态)");
const answering = render(user({ delivery: "answering" }));
ok(has(answering, "已读 · 回答中"), "④ answering 显示「已读 · 回答中」");
ok(!has(answering, "liquid-glass-accent"), "灰态时气泡不再是强调色(正文区转灰)");
ok(
has(render(user({ delivery: "answering", steerStep: 2 })), "已读 · 回答中(第 2 步)"),
"steer_ack 的 atStep 作为灰态小字"
);
const rejected = render(user({ delivery: "rejected", rejectReason: "会话正忙:排队已满(上限 5 条),这条未送达" }));
ok(has(rejected, "未送达"), "⑥ rejected 说清「未送达」");
ok(has(rejected, "排队已满(上限 5 条)"), "⑥ 被拒原因用后端给的文案");
ok(has(render(user({ delivery: "answered" })), "看看这个附件"), "⑤ answered 是正常气泡(内容照常)");
ok(!has(render(user({ delivery: "answered" })), "回答中"), "⑤ answered 不再显示「回答中」");
}
// ── ③ 回合小标(turnId)+ 并行路序号 ────────────────────────────────────────────
{
const msg = user({ turnId: T });
const html = render(msg);
ok(has(html, "回合 turn-9f3c1a2…"), "用户气泡带回合小标(可与 turn_begin 配对)");
const assistant = render({ id: "a1", role: "assistant", content: "回答", createdAt: 2, turnId: T });
ok(has(assistant, "回合 turn-9f3c1a2…"), "助手气泡同样带回合小标");
ok(!has(assistant, "并行路"), "无绑定记录时退回只显示回合 id");
ok(!has(render(user()), "回合 "), "没有 turnId 的历史行不渲染小标(旧行为不变)");
// ⚠️ 诚实边界:「并行路 N」取自 store 的 turnBindings,而 `renderToStaticMarkup` 用的是
// zustand 的 **server snapshot(初始状态)**,所以这里读不到绑定,无法在静态渲染里断言。
// 绑定与路序号本身由 parallel.test.ts 驱动真实 store 覆盖(turn_begin 的 leg 落表)。
}
console.log(`bubble render test: ${checks} checks passed`);
+7 -2
View File
@@ -109,16 +109,21 @@ export function WakePath({
}
/** 会话编排 busy/pending 轻状态提醒(design/15 §5)。她忙时显示在聊天区顶部;
* **不阻塞输入框**(输入框可继续打字),挂起消息数可见。 */
* **不阻塞输入框**(输入框可继续打字),挂起消息数与在途路数可见。
* 忙时并行(design/15 §2.5)后:`activeTurns > 1` 时显式写「并行 N 路」,
* 排队/额度已满这类后端给的原因(`queueNotice`)优先显示 —— 用户才知道「为什么还在等」。 */
export function SessionStatusHint(): React.ReactNode | null {
const busy = useAppStore((s) => s.sessionBusy);
const pendingCount = useAppStore((s) => s.pendingCount);
const activeTurns = useAppStore((s) => s.activeTurns);
const queueNotice = useAppStore((s) => s.queueNotice);
if (!busy) return null;
return (
<div className="mu-rise-in flex items-center gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/70 px-3 py-2 text-xs text-[var(--mu-text-lo)]">
<Icon name="clock" size={14} className="shrink-0 text-[var(--mu-warn)]" />
<span className="min-w-0 truncate">她正在忙,有新消息稍后回</span>
<span className="min-w-0 truncate">{queueNotice ?? "她正在忙,有新消息稍后回"}</span>
{activeTurns > 1 ? <Tag tone="accent">并行 {activeTurns} 路</Tag> : null}
{pendingCount > 0 ? <Tag tone="warn">{pendingCount} 条待回</Tag> : null}
</div>
);