feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
- 帧壳 `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:
1 parent
6a545badbd
commit
eaada3af5d
34 files changed
+2457
-223
No files matched your search
@@ -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`);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user