- chat/index.tsx 272 行(编排层:SplitChatBubble + MessageBubble + 各气泡) - chat/toolviz.tsx 112(工具卡 / 思维链可视件) - chat/labels.tsx 70(元数据小字) - chat/chrome.tsx 60(外壳判定与注记) - chat/split.ts 51(纯函数:br 分隔与段落切分,最易测) - 旧 chat.tsx 删除;调用方一行未动(沿用既有目录索引解析,与 messages/ 同一模式) 纯搬家证据:把旧文件与新 5 文件做行多重集 diff,差异只有 export 前缀、 一行 re-export(ThinkingBlock / ToolCard)与 import 重写 —— 没有一行 JSX / 样式类名 / 逻辑变化;测试文件一字未改。 对外导出名与形状逐字不变(仍是 ThinkingBlock / ToolCard / MessageBubble / SplitChatBubble)。 验收:tsc 0、render 189 checks、bubble 27 checks、四个邻接套件全绿、 vite build 294 模块;变异(拆后去掉 br 空段过滤 => ⑦ 红)证明测试仍在真测那些逻辑。
71 lines
3.0 KiB
TypeScript
71 lines
3.0 KiB
TypeScript
import type { ChatMessage, PersonaRecord } from "../../lib/types";
|
|
import { useAppStore } from "../../store/useAppStore";
|
|
import { useCurrentPersona } from "../../store/personaSelectors";
|
|
import { resolveMessagePersona } from "../../lib/persona";
|
|
import { DeliveryChip, TurnTag } from "../messages/TurnMeta";
|
|
|
|
/** Small muted source label for a persisted message (design/16 §4.5): shows the
|
|
* concrete client the message came from, e.g. "来自 web:chrome:a1b2c3". Rendered
|
|
* only when a deviceId is present, so legacy/unlabelled messages stay clean. The
|
|
* Web client defaults to this label; other clients decide themselves. */
|
|
export function SourceLabel({ deviceId, role }: { deviceId?: string; role?: string }): React.ReactNode {
|
|
if (!deviceId) return null;
|
|
// A user message is received FROM a client; an assistant reply goes TO the
|
|
// client that prompted it. Semantic direction differs — "来自" vs "发送至".
|
|
const dir = role === "assistant" ? "发送至" : "来自";
|
|
return (
|
|
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
|
{dir} {deviceId}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Small sender label for a third-party message (design/16 §4.6): shows the
|
|
* sender's display name, e.g. "第三方 · 张三". Blank for the owner's private chat
|
|
* (no senderName), so owner bubbles stay unlabelled. */
|
|
export function SenderLabel({ name }: { name?: string }): React.ReactNode {
|
|
if (!name) return null;
|
|
return (
|
|
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
|
第三方 · {name}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** The bubble-side binding of `lib/persona.ts#resolveMessagePersona` (design/22
|
|
* §7-5): which role spoke this message, and whether it differs from the role the
|
|
* session speaks as now. */
|
|
export function useSpokenPersona(personaId?: string): {
|
|
persona: PersonaRecord | null;
|
|
switched: boolean;
|
|
} {
|
|
const personas = useAppStore((s) => s.personas);
|
|
const active = useCurrentPersona();
|
|
return resolveMessagePersona(personaId, personas, active);
|
|
}
|
|
|
|
/** Muted "角色 · X" line for a bubble whose authoring role differs from the role
|
|
* the session speaks as now. */
|
|
export function PersonaLabel({ name }: { name?: string }): React.ReactNode {
|
|
if (!name) return null;
|
|
return (
|
|
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">角色 · {name}</div>
|
|
);
|
|
}
|
|
|
|
/** 用户气泡下面那行小字:回合小标 + 投递态角标 + 来源。两个用户气泡(单块 / 分段)
|
|
* 共用一处,避免「四份渲染各写一遍、日后漂移」。 */
|
|
export 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>
|
|
);
|
|
}
|