| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703 |
- 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') {
- <p class="notice">{{ message().text }}</p>
- } @else if (message().kind === 'emote') {
- <p class="emote">{{ message().text }}</p>
- } @else {
- <div class="bubble-row" [class.own]="own()">
- <!--
- 头像(2026-10-29 精细化):参考 ChatGPT / DSH 的纵向排布 ——
- 左侧头像列固定,右侧为「姓名行 / 正文 / 操作条」。
- 己方消息不显示头像(右边距已足够表达归属,再放头像会挤压正文宽度)。
- -->
- <!--
- 消息头像已移除(2026-10-30 用户要求)。
- 理由:DM 场景下对方只有一个,每行都挂头像属于重复信息,也挤占正文宽度;
- 发言人身份由会话标题栏表达即可。
- -->
- <div class="bubble-col">
- <!--
- 群聊:显示「是谁说的」。
- DM 里对方只有一个,名字在头部已经写了,这里不重复。
- 由调用方用 showSender 控制是否显示。
- -->
- @if (showSender() && !own()) {
- <span class="sender-line">
- @if (message().senderName) {
- <span class="sender-name">{{ message().senderName }}</span>
- }
- <!--
- 被 @ 的标记。Matrix 的 mention 是结构化的(m.mentions),
- 不能用文本正则推断 —— 文本里的 @名字 只是给人看的。
- -->
- @if (mentionsWho().length > 0) {
- <span class="mention-tag" [title]="'@ ' + mentionsWho().join('、')">
- <i class="fa-solid fa-at" aria-hidden="true"></i>{{ mentionsWho().length }}
- </span>
- }
- </span>
- }
- @if (message().file; as file) {
- <!--
- 图片**直接显示缩略图**(用户要求):原来只有一个文件图标,
- 看不到内容。点击仍然打开全图预览。
- 加载失败时退回图标卡片,不出现裂图。
- -->
- @if (isImage(file.mimeType) && !thumbBroken()) {
- <button
- type="button"
- class="thumb-card"
- [attr.aria-label]="'预览图片 ' + file.name"
- (click)="preview.emit(file)"
- >
- <img
- class="thumb-img"
- [src]="file.url"
- [alt]="file.name"
- loading="lazy"
- (error)="thumbBroken.set(true)"
- />
- <span class="thumb-meta">
- <span class="truncate">{{ file.name }}</span>
- <small>{{ fmtSize(file.size) }}</small>
- </span>
- </button>
- } @else {
- <!--
- 文件卡片(2026-10-30 对齐参考图 19):
- 图标块放大到 40、文件名与大小分两行、右侧一组操作按钮。
- 整张卡片可点(预览),下载是独立按钮 —— 不与"打开"共用一个手势。
- -->
- <div class="file-card">
- <button type="button" class="file-main" (click)="preview.emit(file!)">
- <span class="file-icon" aria-hidden="true">
- <i class="fa-solid" [class]="fileIcon(file!.mimeType)"></i>
- </span>
- <span class="file-meta">
- <strong class="truncate" [title]="file!.name">{{ file!.name }}</strong>
- <small>{{ fmtSize(file!.size) }}</small>
- </span>
- </button>
- <span class="file-acts">
- <button
- type="button"
- class="file-act"
- [attr.aria-label]="'预览 ' + file!.name"
- title="预览"
- (click)="preview.emit(file!)"
- >
- <i class="fa-solid fa-expand" aria-hidden="true"></i>
- </button>
- @if (file!.url) {
- <a
- class="file-act"
- [href]="file!.url"
- [attr.download]="file!.name"
- [attr.aria-label]="'下载 ' + file!.name"
- title="下载"
- >
- <i class="fa-solid fa-download" aria-hidden="true"></i>
- </a>
- }
- </span>
- </div>
- }
- } @else {
- <div class="bubble" [class.failed]="message().state === 'failed'">
- <!--
- agent 回复里混着「工具调用 / 过程提示」行(形如 圆点 + 工具名)。
- 这里拆成块:正文照常渲染,**连续的过程行折叠成一行**,
- 用户展开才看细节(对齐 Codex 桌面端的「Worked for」)。
- -->
- @if (processy()) {
- @for (b of blocks(); track $index) {
- @if (b.kind === 'process') {
- <app-tool-activity [block]="b" />
- } @else if (b.kind === 'blank') {
- <span class="seg-gap" aria-hidden="true"></span>
- } @else {
- <app-md-render [text]="b.text" [compact]="true" />
- }
- }
- } @else {
- <!-- markdown 渲染(agent 回复常含 md:加粗/列表/代码块/表格) -->
- <app-md-render [text]="message().text ?? ''" [compact]="true" />
- }
- </div>
- }
- <!--
- 操作条(2026-10-31):时间与复制按钮**常驻显示**,悬停时提亮。
- -->
- <span class="bubble-meta">
- <time [attr.datetime]="message().ts">{{ rel(message().ts) }}</time>
- @if (message().state === 'sending') {
- <span class="state sending"><i class="fa-solid fa-clock" aria-hidden="true"></i>发送中</span>
- }
- @if (message().state === 'failed') {
- <span class="state failed">
- <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
- {{ message().error || '发送失败' }}
- <button type="button" class="retry" (click)="retry.emit(message().id)">重试</button>
- </span>
- }
- @if (message().state !== 'failed' && !message().file) {
- <span class="acts">
- <button
- type="button"
- class="act"
- [attr.aria-label]="copied() ? '已复制' : '复制内容'"
- [title]="copied() ? '已复制' : '复制'"
- (click)="copy()"
- >
- <i class="fa-solid" [class]="copied() ? 'fa-check' : 'fa-copy'" aria-hidden="true"></i>
- </button>
- </span>
- }
- </span>
- @if (message().taskId) {
- <button type="button" class="ref-chip" (click)="openTask.emit(message().taskId!)">
- <i class="fa-solid fa-list-check" aria-hidden="true"></i>查看任务
- </button>
- }
- </div>
- </div>
- }
- `,
- 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<Message>();
- 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<string>();
- /** 复制成功反馈(1.4s 后自动复位) */
- protected readonly copied = signal(false);
- private copyTimer: ReturnType<typeof setTimeout> | 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<string>();
- readonly preview = output<NonNullable<Message['file']>>();
- /** 缩略图加载失败 → 退回图标卡片(避免裂图) */
- 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<Block[]>(() =>
- 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';
- }
- }
|