import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core'; import { Message } from '../../../core/api/models'; import { fmtRelTime } from '../../../core/time'; import { fmtSize } from '../../../core/format'; import { Block, splitMessage, hasProcess } from '../../../core/message-segments'; import { MdRenderComponent } from '../../preview/md-render/md-render'; import { ToolActivityComponent } from '../tool-activity/tool-activity'; /** * 消息气泡 —— 底座零聊天语义组件,必须自建(《组件规范》§四-1) * * 覆盖: * · 己/对方气泡(`own`) * · Agent 汇报语义(`m.notice` → notice 样式,居中弱化) * · 文件卡(`m.text` 的 JSON body 解析后) * · 超长文本换行不撑破布局(AC-04.10) * · 失败态 + 重试入口(AC-04.8:失败消息不丢失) */ @Component({ selector: 'app-message-bubble', changeDetection: ChangeDetectionStrategy.OnPush, imports: [MdRenderComponent, ToolActivityComponent], template: ` @if (message().kind === 'notice') {

{{ message().text }}

} @else if (message().kind === 'emote') {

{{ message().text }}

} @else {
@if (showSender() && !own()) { @if (message().senderName) { {{ message().senderName }} } @if (mentionsWho().length > 0) { {{ mentionsWho().length }} } } @if (message().file; as file) { @if (isImage(file.mimeType) && !thumbBroken()) { } @else {
@if (file!.url) { }
} } @else {
@if (processy()) { @for (b of blocks(); track $index) { @if (b.kind === 'process') { } @else if (b.kind === 'blank') { } @else { } } } @else { }
} @if (message().state === 'sending') { 发送中 } @if (message().state === 'failed') { {{ message().error || '发送失败' }} } @if (message().state !== 'failed' && !message().file) { } @if (message().taskId) { }
} `, styles: [ ` :host { display: block; /* * 入场淡入(对齐参考实现):新消息从下方 4px 归位 + 淡入。 * 只动 transform / opacity —— 不动 height,避免触发重排导致滚动位置跳。 * prefers-reduced-motion 下由底座全局规则关闭动画。 */ animation: bubble-in var(--motion-detail) var(--ease-standard) both; } @keyframes bubble-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } .bubble-row { display: flex; gap: var(--sp-3); align-items: flex-start; } .bubble-row.own { justify-content: flex-end; } /* * 操作条常驻(2026-10-31 用户要求):时间与复制按钮**始终可见**。 * 原先按钮默认 opacity:0、悬停才淡入 —— 用户不知道可以复制。 * 常驻后保持低对比(默认 .55),悬停/聚焦时恢复全亮,避免抢正文视觉。 */ .acts { display: inline-flex; align-items: center; gap: 2px; margin-left: var(--sp-1); opacity: 0.55; pointer-events: auto; transition: opacity var(--motion-fast) var(--ease-standard); } .bubble-row:hover .acts, .bubble-row:focus-within .acts, .acts:focus-within { opacity: 1; pointer-events: auto; } .act { display: grid; place-items: center; width: 26px; height: 26px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); font-size: var(--fs-12); cursor: pointer; } .act:hover { background: var(--surface-hover); color: var(--ink); } .act:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; } .sender-line { display: inline-flex; align-items: center; gap: var(--sp-2); padding-left: var(--sp-1); } .sender-name { font-size: var(--fs-11); font-weight: 600; color: var(--muted); } .mention-tag { display: inline-flex; align-items: center; gap: 2px; padding: 0 5px; border-radius: var(--r-tag); background: var(--primary-subtle); color: var(--primary); font-size: var(--fs-11); line-height: 16px; } .bubble-col { display: grid; gap: var(--sp-1); /* * AI 回复撑满限宽容器(对话流本身已限宽 768px); * 用户消息限宽 620px —— 短句不必横跨整行,靠右对齐也更像"我说的话"。 */ max-width: 100%; flex: 1; /* * 🔴 必须显式允许收缩。 * * grid 子项的默认 min-width 是 auto —— 内容(尤其是不含空格的 * 长中文串、URL、代码)撑多宽就多宽:max-width 只约束了轨道, * 约束不住子项。于是气泡把 .messages 撑出横向滚动条(用户实测)。 * * min-width: 0 让轨道允许收窄,配合下方的 overflow-wrap 才真正断行。 */ min-width: 0; } /* * 气泡样式(2026-10-29 对照 ChatGPT demo 重做): * * 用户消息 → **有气泡**(主色浅底、圆角、靠右) * AI 回复 → **无边框正文**,左对齐撑满限宽容器 * * 🔴 原来两侧都套同一个灰色边框气泡,AI 的长回复被框在 680px × 边框里, * 大段 markdown 读起来像「卡片里的卡片」,信息密度和呼吸感都不对。 * ChatGPT 的做法是让 AI 回复像文档一样直接铺开 —— 这正是它的观感来源。 */ .bubble { min-width: 0; overflow-wrap: anywhere; padding: var(--sp-3) var(--sp-4); border-radius: var(--r-lg); background: var(--surface); border: 1px solid var(--edge); color: var(--ink); } /* AI 回复:去掉卡片,正文直接铺开 */ .bubble-row:not(.own) .bubble { padding: 0; border: 0; background: transparent; } /* * 用户气泡(对齐参考实现):大圆角 + 更宽松的内边距, * 与 AI 的无边框正文形成明确的"我说的 / 它说的"对比。 */ .bubble-row.own .bubble { background: var(--primary-subtle); border-color: transparent; border-radius: var(--r-lg); padding: var(--sp-3) var(--sp-4); } .bubble-row.own .bubble-col { flex: 0 1 auto; max-width: min(620px, 82%); justify-items: end; } /* 分段渲染后各块之间的间距(markdown 块自带 margin,这里只兜底) */ .bubble app-md-render:not(:first-child) { display: block; min-width: 0; margin-top: var(--sp-2); } .seg-gap { display: block; height: var(--sp-2); } .bubble.failed { border-color: var(--danger); } /* 超长文本换行 + 不撑破布局(AC-04.10) */ .bubble-text { margin: 0; font-size: var(--fs-15); line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; } /* * 元信息行(2026-10-29):时间与操作条一起**只在悬停/聚焦时出现**。 * 原来时间常驻、独占一行 —— 每条消息下方挂一个「13:10」, * 一屏十几条就是十几行无用信息,把对话节奏切碎。 */ .bubble-meta { display: flex; align-items: center; gap: var(--sp-2); min-height: 0; font-size: var(--fs-11); color: var(--muted); /* * 🔴 **必须常驻**(2026-11-01 用户要求)。 * 原来这里是 opacity: 0 + 悬停才显示 —— 时间与操作条都在这个容器里, * 结果两者默认都看不见(实测:timeDisplay=block 但 metaOpacity=0)。 */ opacity: 1; } /* 发送中 / 失败 需要更醒目:提高对比度而不是靠悬停 */ .bubble-meta:has(.state) { color: var(--ink-2); } .bubble-row.own .bubble-meta { justify-content: flex-end; } .state { display: inline-flex; align-items: center; gap: var(--sp-1); } .state.failed { color: var(--danger); } .retry { min-height: 28px; padding: 0 var(--sp-2); border: 1px solid currentColor; border-radius: var(--r-sm); background: transparent; color: inherit; font-size: var(--fs-11); cursor: pointer; } .notice, .emote { margin: var(--sp-2) auto; max-width: 520px; text-align: center; font-size: var(--fs-12); color: var(--muted); } .emote { font-style: italic; } /* 图片缩略图:固定尺寸 + 裁切,防止长图撑破布局(规则 18 §12) */ /* * 图片卡片:与文件卡同款圆角与描边,图区固定高度裁切(cover)。 * 悬停时轻微上浮 + 描边加深,给出"可点开"的反馈。 */ .thumb-card { display: grid; gap: 0; width: 240px; max-width: 100%; padding: 0; border: 1px solid var(--edge); border-radius: var(--r-md); background: var(--surface); overflow: hidden; cursor: pointer; text-align: left; transition: border-color var(--motion-fast) var(--ease-standard), transform var(--motion-fast) var(--ease-standard); } .thumb-card:hover { border-color: var(--edge-strong); transform: translateY(-1px); } .thumb-card:hover { border-color: var(--edge-strong); } .thumb-card:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } .thumb-img { display: block; width: 100%; height: 150px; object-fit: cover; background: var(--surface-2); } /* 图片卡底部信息条:文件名 + 大小,与文件卡的信息层级保持一致 */ .thumb-meta { display: flex; align-items: baseline; gap: var(--sp-2); padding: var(--sp-2); border-top: 1px solid var(--edge); background: var(--surface); font-size: var(--fs-11); color: var(--ink-2); } .thumb-meta span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .thumb-meta small { flex: none; } /* * 文件卡片(2026-10-30 重做): * 卡片本身是容器,内部拆成「主区(可点开)」+「操作组(预览/下载)」两个独立目标 —— * 原来整张卡就是一个按钮,想下载也会先触发预览,得再找下载入口。 */ .file-card { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: var(--sp-2) var(--sp-2) var(--sp-2) var(--sp-3); border: 1px solid var(--edge); border-radius: var(--r-md); background: var(--surface); transition: border-color var(--motion-fast) var(--ease-standard), background-color var(--motion-fast) var(--ease-standard); } .file-card:hover { background: var(--surface-hover); border-color: var(--edge-strong); } .file-main { display: flex; align-items: center; gap: var(--sp-3); flex: 1; min-width: 0; padding: var(--sp-1) 0; border: 0; background: transparent; text-align: left; cursor: pointer; } .file-main:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: var(--r-sm); } /* 图标块:40px 圆角方块,是卡片里最重的视觉锚点 */ .file-icon { display: grid; place-items: center; width: 40px; height: 40px; flex-shrink: 0; border-radius: var(--r-md); background: var(--info-subtle); color: var(--info); font-size: var(--fs-17); } .file-meta { display: grid; gap: 3px; flex: 1; min-width: 0; } .file-meta strong { font-size: var(--fs-13); font-weight: 600; color: var(--ink); } .file-meta small { font-size: var(--fs-11); color: var(--muted); } /* 操作组:悬停才显现,默认保持卡片干净 */ .file-acts { display: flex; align-items: center; gap: 2px; flex-shrink: 0; opacity: 0; transition: opacity var(--motion-fast) var(--ease-standard); } .file-card:hover .file-acts, .file-card:focus-within .file-acts { opacity: 1; } .file-act { display: grid; place-items: center; width: 30px; height: 30px; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--muted); font-size: var(--fs-12); cursor: pointer; text-decoration: none; } .file-act:hover { background: var(--surface-active); color: var(--ink); } .file-act:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; /* 键盘聚焦时操作组必须可见,否则焦点环在透明容器里 */ opacity: 1; } .ref-chip { justify-self: start; display: inline-flex; align-items: center; gap: var(--sp-1); min-height: var(--touch-min); padding: 0 var(--sp-3); border: 1px solid var(--edge); border-radius: var(--r-tag); background: var(--surface); color: var(--info); font-size: var(--fs-12); cursor: pointer; } .bubble-row.own .ref-chip { justify-self: end; } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } `, ], }) export class MessageBubbleComponent { readonly message = input.required(); readonly own = input(false); /** 是否显示发送者名(群聊为 true,DM 为 false) */ readonly showSender = input(false); /** 群成员 Matrix ID → 展示名(用于把 mentions 显示成人能读的名字) */ readonly nameOf = input<(mxId: string) => string>((id) => id.replace(/^@/, '').split(':')[0]); readonly mentionsWho = computed(() => (this.message().mentions ?? []).map((id) => this.nameOf()(id)), ); readonly retry = output(); /** 复制成功反馈(1.4s 后自动复位) */ protected readonly copied = signal(false); private copyTimer: ReturnType | null = null; /** * 复制消息正文。 * * `navigator.clipboard` 在非安全上下文(http 非 localhost)不可用 —— * 此时退回 textarea + execCommand,保证功能在部署环境里不失效。 */ protected copy(): void { const text = this.message().text ?? ''; if (!text) return; const done = () => { this.copied.set(true); if (this.copyTimer) clearTimeout(this.copyTimer); this.copyTimer = setTimeout(() => this.copied.set(false), 1400); }; if (navigator.clipboard?.writeText) { navigator.clipboard.writeText(text).then(done, () => this.copyFallback(text, done)); return; } this.copyFallback(text, done); } private copyFallback(text: string, done: () => void): void { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch { /* 非安全上下文 / 无权限:下面统一给反馈,不抛给调用方 */ } finally { document.body.removeChild(ta); } /* * 🔴 失败时也要给反馈。 * * 无头环境 / 无剪贴板权限 / 非安全上下文下 `execCommand` 会返回 false —— * 早期版本此时什么都不做,用户点了按钮毫无反应,会以为功能坏了。 */ done(); } readonly openTask = output(); readonly preview = output>(); /** 缩略图加载失败 → 退回图标卡片(避免裂图) */ readonly thumbBroken = signal(false); /** 是否图片(按 mimeType 判断;无 mime 时看扩展名) */ isImage(mimeType: string): boolean { if (mimeType?.startsWith('image/')) return true; return /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(mimeType ?? ''); } readonly fmtSize = fmtSize; /** * 拆分结果(仅当**确实有**可折叠过程时才分段渲染)。 * 没有过程行的普通回复仍走整段 markdown,避免把 md 语法拆坏。 */ readonly processy = computed(() => hasProcess(this.message())); readonly blocks = computed(() => this.processy() ? splitMessage(this.message().text ?? '') : [], ); rel(ts: string): string { return fmtRelTime(ts); } fileIcon(mime: string): string { if (mime.startsWith('image/')) return 'fa-file-image'; if (mime.startsWith('video/')) return 'fa-file-video'; if (mime.startsWith('audio/')) return 'fa-file-audio'; if (mime.includes('csv') || mime.includes('sheet')) return 'fa-file-csv'; if (mime.includes('pdf')) return 'fa-file-pdf'; if (mime.includes('zip')) return 'fa-file-zipper'; return 'fa-file-lines'; } }