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';
}
}