message-bubble.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  1. import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core';
  2. import { Message } from '../../../core/api/models';
  3. import { fmtRelTime } from '../../../core/time';
  4. import { fmtSize } from '../../../core/format';
  5. import { Block, splitMessage, hasProcess } from '../../../core/message-segments';
  6. import { MdRenderComponent } from '../../preview/md-render/md-render';
  7. import { ToolActivityComponent } from '../tool-activity/tool-activity';
  8. /**
  9. * 消息气泡 —— 底座零聊天语义组件,必须自建(《组件规范》§四-1)
  10. *
  11. * 覆盖:
  12. * · 己/对方气泡(`own`)
  13. * · Agent 汇报语义(`m.notice` → notice 样式,居中弱化)
  14. * · 文件卡(`m.text` 的 JSON body 解析后)
  15. * · 超长文本换行不撑破布局(AC-04.10)
  16. * · 失败态 + 重试入口(AC-04.8:失败消息不丢失)
  17. */
  18. @Component({
  19. selector: 'app-message-bubble',
  20. changeDetection: ChangeDetectionStrategy.OnPush,
  21. imports: [MdRenderComponent, ToolActivityComponent],
  22. template: `
  23. @if (message().kind === 'notice') {
  24. <p class="notice">{{ message().text }}</p>
  25. } @else if (message().kind === 'emote') {
  26. <p class="emote">{{ message().text }}</p>
  27. } @else {
  28. <div class="bubble-row" [class.own]="own()">
  29. <!--
  30. 头像(2026-10-29 精细化):参考 ChatGPT / DSH 的纵向排布 ——
  31. 左侧头像列固定,右侧为「姓名行 / 正文 / 操作条」。
  32. 己方消息不显示头像(右边距已足够表达归属,再放头像会挤压正文宽度)。
  33. -->
  34. <!--
  35. 消息头像已移除(2026-10-30 用户要求)。
  36. 理由:DM 场景下对方只有一个,每行都挂头像属于重复信息,也挤占正文宽度;
  37. 发言人身份由会话标题栏表达即可。
  38. -->
  39. <div class="bubble-col">
  40. <!--
  41. 群聊:显示「是谁说的」。
  42. DM 里对方只有一个,名字在头部已经写了,这里不重复。
  43. 由调用方用 showSender 控制是否显示。
  44. -->
  45. @if (showSender() && !own()) {
  46. <span class="sender-line">
  47. @if (message().senderName) {
  48. <span class="sender-name">{{ message().senderName }}</span>
  49. }
  50. <!--
  51. 被 @ 的标记。Matrix 的 mention 是结构化的(m.mentions),
  52. 不能用文本正则推断 —— 文本里的 @名字 只是给人看的。
  53. -->
  54. @if (mentionsWho().length > 0) {
  55. <span class="mention-tag" [title]="'@ ' + mentionsWho().join('、')">
  56. <i class="fa-solid fa-at" aria-hidden="true"></i>{{ mentionsWho().length }}
  57. </span>
  58. }
  59. </span>
  60. }
  61. @if (message().file; as file) {
  62. <!--
  63. 图片**直接显示缩略图**(用户要求):原来只有一个文件图标,
  64. 看不到内容。点击仍然打开全图预览。
  65. 加载失败时退回图标卡片,不出现裂图。
  66. -->
  67. @if (isImage(file.mimeType) && !thumbBroken()) {
  68. <button
  69. type="button"
  70. class="thumb-card"
  71. [attr.aria-label]="'预览图片 ' + file.name"
  72. (click)="preview.emit(file)"
  73. >
  74. <img
  75. class="thumb-img"
  76. [src]="file.url"
  77. [alt]="file.name"
  78. loading="lazy"
  79. (error)="thumbBroken.set(true)"
  80. />
  81. <span class="thumb-meta">
  82. <span class="truncate">{{ file.name }}</span>
  83. <small>{{ fmtSize(file.size) }}</small>
  84. </span>
  85. </button>
  86. } @else {
  87. <!--
  88. 文件卡片(2026-10-30 对齐参考图 19):
  89. 图标块放大到 40、文件名与大小分两行、右侧一组操作按钮。
  90. 整张卡片可点(预览),下载是独立按钮 —— 不与"打开"共用一个手势。
  91. -->
  92. <div class="file-card">
  93. <button type="button" class="file-main" (click)="preview.emit(file!)">
  94. <span class="file-icon" aria-hidden="true">
  95. <i class="fa-solid" [class]="fileIcon(file!.mimeType)"></i>
  96. </span>
  97. <span class="file-meta">
  98. <strong class="truncate" [title]="file!.name">{{ file!.name }}</strong>
  99. <small>{{ fmtSize(file!.size) }}</small>
  100. </span>
  101. </button>
  102. <span class="file-acts">
  103. <button
  104. type="button"
  105. class="file-act"
  106. [attr.aria-label]="'预览 ' + file!.name"
  107. title="预览"
  108. (click)="preview.emit(file!)"
  109. >
  110. <i class="fa-solid fa-expand" aria-hidden="true"></i>
  111. </button>
  112. @if (file!.url) {
  113. <a
  114. class="file-act"
  115. [href]="file!.url"
  116. [attr.download]="file!.name"
  117. [attr.aria-label]="'下载 ' + file!.name"
  118. title="下载"
  119. >
  120. <i class="fa-solid fa-download" aria-hidden="true"></i>
  121. </a>
  122. }
  123. </span>
  124. </div>
  125. }
  126. } @else {
  127. <div class="bubble" [class.failed]="message().state === 'failed'">
  128. <!--
  129. agent 回复里混着「工具调用 / 过程提示」行(形如 圆点 + 工具名)。
  130. 这里拆成块:正文照常渲染,**连续的过程行折叠成一行**,
  131. 用户展开才看细节(对齐 Codex 桌面端的「Worked for」)。
  132. -->
  133. @if (processy()) {
  134. @for (b of blocks(); track $index) {
  135. @if (b.kind === 'process') {
  136. <app-tool-activity [block]="b" />
  137. } @else if (b.kind === 'blank') {
  138. <span class="seg-gap" aria-hidden="true"></span>
  139. } @else {
  140. <app-md-render [text]="b.text" [compact]="true" />
  141. }
  142. }
  143. } @else {
  144. <!-- markdown 渲染(agent 回复常含 md:加粗/列表/代码块/表格) -->
  145. <app-md-render [text]="message().text ?? ''" [compact]="true" />
  146. }
  147. </div>
  148. }
  149. <!--
  150. 操作条(2026-10-31):时间与复制按钮**常驻显示**,悬停时提亮。
  151. -->
  152. <span class="bubble-meta">
  153. <time [attr.datetime]="message().ts">{{ rel(message().ts) }}</time>
  154. @if (message().state === 'sending') {
  155. <span class="state sending"><i class="fa-solid fa-clock" aria-hidden="true"></i>发送中</span>
  156. }
  157. @if (message().state === 'failed') {
  158. <span class="state failed">
  159. <i class="fa-solid fa-circle-exclamation" aria-hidden="true"></i>
  160. {{ message().error || '发送失败' }}
  161. <button type="button" class="retry" (click)="retry.emit(message().id)">重试</button>
  162. </span>
  163. }
  164. @if (message().state !== 'failed' && !message().file) {
  165. <span class="acts">
  166. <button
  167. type="button"
  168. class="act"
  169. [attr.aria-label]="copied() ? '已复制' : '复制内容'"
  170. [title]="copied() ? '已复制' : '复制'"
  171. (click)="copy()"
  172. >
  173. <i class="fa-solid" [class]="copied() ? 'fa-check' : 'fa-copy'" aria-hidden="true"></i>
  174. </button>
  175. </span>
  176. }
  177. </span>
  178. @if (message().taskId) {
  179. <button type="button" class="ref-chip" (click)="openTask.emit(message().taskId!)">
  180. <i class="fa-solid fa-list-check" aria-hidden="true"></i>查看任务
  181. </button>
  182. }
  183. </div>
  184. </div>
  185. }
  186. `,
  187. styles: [
  188. `
  189. :host {
  190. display: block;
  191. /*
  192. * 入场淡入(对齐参考实现):新消息从下方 4px 归位 + 淡入。
  193. * 只动 transform / opacity —— 不动 height,避免触发重排导致滚动位置跳。
  194. * prefers-reduced-motion 下由底座全局规则关闭动画。
  195. */
  196. animation: bubble-in var(--motion-detail) var(--ease-standard) both;
  197. }
  198. @keyframes bubble-in {
  199. from {
  200. opacity: 0;
  201. transform: translateY(4px);
  202. }
  203. to {
  204. opacity: 1;
  205. transform: none;
  206. }
  207. }
  208. .bubble-row {
  209. display: flex;
  210. gap: var(--sp-3);
  211. align-items: flex-start;
  212. }
  213. .bubble-row.own {
  214. justify-content: flex-end;
  215. }
  216. /*
  217. * 操作条常驻(2026-10-31 用户要求):时间与复制按钮**始终可见**。
  218. * 原先按钮默认 opacity:0、悬停才淡入 —— 用户不知道可以复制。
  219. * 常驻后保持低对比(默认 .55),悬停/聚焦时恢复全亮,避免抢正文视觉。
  220. */
  221. .acts {
  222. display: inline-flex;
  223. align-items: center;
  224. gap: 2px;
  225. margin-left: var(--sp-1);
  226. opacity: 0.55;
  227. pointer-events: auto;
  228. transition: opacity var(--motion-fast) var(--ease-standard);
  229. }
  230. .bubble-row:hover .acts,
  231. .bubble-row:focus-within .acts,
  232. .acts:focus-within {
  233. opacity: 1;
  234. pointer-events: auto;
  235. }
  236. .act {
  237. display: grid;
  238. place-items: center;
  239. width: 26px;
  240. height: 26px;
  241. border: 0;
  242. border-radius: var(--r-sm);
  243. background: transparent;
  244. color: var(--muted);
  245. font-size: var(--fs-12);
  246. cursor: pointer;
  247. }
  248. .act:hover {
  249. background: var(--surface-hover);
  250. color: var(--ink);
  251. }
  252. .act:focus-visible {
  253. outline: 2px solid var(--primary);
  254. outline-offset: -2px;
  255. }
  256. .sender-line {
  257. display: inline-flex;
  258. align-items: center;
  259. gap: var(--sp-2);
  260. padding-left: var(--sp-1);
  261. }
  262. .sender-name {
  263. font-size: var(--fs-11);
  264. font-weight: 600;
  265. color: var(--muted);
  266. }
  267. .mention-tag {
  268. display: inline-flex;
  269. align-items: center;
  270. gap: 2px;
  271. padding: 0 5px;
  272. border-radius: var(--r-tag);
  273. background: var(--primary-subtle);
  274. color: var(--primary);
  275. font-size: var(--fs-11);
  276. line-height: 16px;
  277. }
  278. .bubble-col {
  279. display: grid;
  280. gap: var(--sp-1);
  281. /*
  282. * AI 回复撑满限宽容器(对话流本身已限宽 768px);
  283. * 用户消息限宽 620px —— 短句不必横跨整行,靠右对齐也更像"我说的话"。
  284. */
  285. max-width: 100%;
  286. flex: 1;
  287. /*
  288. * 🔴 必须显式允许收缩。
  289. *
  290. * grid 子项的默认 min-width 是 auto —— 内容(尤其是不含空格的
  291. * 长中文串、URL、代码)撑多宽就多宽:max-width 只约束了轨道,
  292. * 约束不住子项。于是气泡把 .messages 撑出横向滚动条(用户实测)。
  293. *
  294. * min-width: 0 让轨道允许收窄,配合下方的 overflow-wrap 才真正断行。
  295. */
  296. min-width: 0;
  297. }
  298. /*
  299. * 气泡样式(2026-10-29 对照 ChatGPT demo 重做):
  300. *
  301. * 用户消息 → **有气泡**(主色浅底、圆角、靠右)
  302. * AI 回复 → **无边框正文**,左对齐撑满限宽容器
  303. *
  304. * 🔴 原来两侧都套同一个灰色边框气泡,AI 的长回复被框在 680px × 边框里,
  305. * 大段 markdown 读起来像「卡片里的卡片」,信息密度和呼吸感都不对。
  306. * ChatGPT 的做法是让 AI 回复像文档一样直接铺开 —— 这正是它的观感来源。
  307. */
  308. .bubble {
  309. min-width: 0;
  310. overflow-wrap: anywhere;
  311. padding: var(--sp-3) var(--sp-4);
  312. border-radius: var(--r-lg);
  313. background: var(--surface);
  314. border: 1px solid var(--edge);
  315. color: var(--ink);
  316. }
  317. /* AI 回复:去掉卡片,正文直接铺开 */
  318. .bubble-row:not(.own) .bubble {
  319. padding: 0;
  320. border: 0;
  321. background: transparent;
  322. }
  323. /*
  324. * 用户气泡(对齐参考实现):大圆角 + 更宽松的内边距,
  325. * 与 AI 的无边框正文形成明确的"我说的 / 它说的"对比。
  326. */
  327. .bubble-row.own .bubble {
  328. background: var(--primary-subtle);
  329. border-color: transparent;
  330. border-radius: var(--r-lg);
  331. padding: var(--sp-3) var(--sp-4);
  332. }
  333. .bubble-row.own .bubble-col {
  334. flex: 0 1 auto;
  335. max-width: min(620px, 82%);
  336. justify-items: end;
  337. }
  338. /* 分段渲染后各块之间的间距(markdown 块自带 margin,这里只兜底) */
  339. .bubble app-md-render:not(:first-child) {
  340. display: block;
  341. min-width: 0;
  342. margin-top: var(--sp-2);
  343. }
  344. .seg-gap {
  345. display: block;
  346. height: var(--sp-2);
  347. }
  348. .bubble.failed {
  349. border-color: var(--danger);
  350. }
  351. /* 超长文本换行 + 不撑破布局(AC-04.10) */
  352. .bubble-text {
  353. margin: 0;
  354. font-size: var(--fs-15);
  355. line-height: 1.7;
  356. white-space: pre-wrap;
  357. overflow-wrap: anywhere;
  358. }
  359. /*
  360. * 元信息行(2026-10-29):时间与操作条一起**只在悬停/聚焦时出现**。
  361. * 原来时间常驻、独占一行 —— 每条消息下方挂一个「13:10」,
  362. * 一屏十几条就是十几行无用信息,把对话节奏切碎。
  363. */
  364. .bubble-meta {
  365. display: flex;
  366. align-items: center;
  367. gap: var(--sp-2);
  368. min-height: 0;
  369. font-size: var(--fs-11);
  370. color: var(--muted);
  371. /*
  372. * 🔴 **必须常驻**(2026-11-01 用户要求)。
  373. * 原来这里是 opacity: 0 + 悬停才显示 —— 时间与操作条都在这个容器里,
  374. * 结果两者默认都看不见(实测:timeDisplay=block 但 metaOpacity=0)。
  375. */
  376. opacity: 1;
  377. }
  378. /* 发送中 / 失败 需要更醒目:提高对比度而不是靠悬停 */
  379. .bubble-meta:has(.state) {
  380. color: var(--ink-2);
  381. }
  382. .bubble-row.own .bubble-meta {
  383. justify-content: flex-end;
  384. }
  385. .state {
  386. display: inline-flex;
  387. align-items: center;
  388. gap: var(--sp-1);
  389. }
  390. .state.failed {
  391. color: var(--danger);
  392. }
  393. .retry {
  394. min-height: 28px;
  395. padding: 0 var(--sp-2);
  396. border: 1px solid currentColor;
  397. border-radius: var(--r-sm);
  398. background: transparent;
  399. color: inherit;
  400. font-size: var(--fs-11);
  401. cursor: pointer;
  402. }
  403. .notice,
  404. .emote {
  405. margin: var(--sp-2) auto;
  406. max-width: 520px;
  407. text-align: center;
  408. font-size: var(--fs-12);
  409. color: var(--muted);
  410. }
  411. .emote {
  412. font-style: italic;
  413. }
  414. /* 图片缩略图:固定尺寸 + 裁切,防止长图撑破布局(规则 18 §12) */
  415. /*
  416. * 图片卡片:与文件卡同款圆角与描边,图区固定高度裁切(cover)。
  417. * 悬停时轻微上浮 + 描边加深,给出"可点开"的反馈。
  418. */
  419. .thumb-card {
  420. display: grid;
  421. gap: 0;
  422. width: 240px;
  423. max-width: 100%;
  424. padding: 0;
  425. border: 1px solid var(--edge);
  426. border-radius: var(--r-md);
  427. background: var(--surface);
  428. overflow: hidden;
  429. cursor: pointer;
  430. text-align: left;
  431. transition: border-color var(--motion-fast) var(--ease-standard),
  432. transform var(--motion-fast) var(--ease-standard);
  433. }
  434. .thumb-card:hover {
  435. border-color: var(--edge-strong);
  436. transform: translateY(-1px);
  437. }
  438. .thumb-card:hover {
  439. border-color: var(--edge-strong);
  440. }
  441. .thumb-card:focus-visible {
  442. outline: 2px solid var(--primary);
  443. outline-offset: 2px;
  444. }
  445. .thumb-img {
  446. display: block;
  447. width: 100%;
  448. height: 150px;
  449. object-fit: cover;
  450. background: var(--surface-2);
  451. }
  452. /* 图片卡底部信息条:文件名 + 大小,与文件卡的信息层级保持一致 */
  453. .thumb-meta {
  454. display: flex;
  455. align-items: baseline;
  456. gap: var(--sp-2);
  457. padding: var(--sp-2);
  458. border-top: 1px solid var(--edge);
  459. background: var(--surface);
  460. font-size: var(--fs-11);
  461. color: var(--ink-2);
  462. }
  463. .thumb-meta span {
  464. min-width: 0;
  465. overflow: hidden;
  466. text-overflow: ellipsis;
  467. white-space: nowrap;
  468. }
  469. .thumb-meta small {
  470. flex: none;
  471. }
  472. /*
  473. * 文件卡片(2026-10-30 重做):
  474. * 卡片本身是容器,内部拆成「主区(可点开)」+「操作组(预览/下载)」两个独立目标 ——
  475. * 原来整张卡就是一个按钮,想下载也会先触发预览,得再找下载入口。
  476. */
  477. .file-card {
  478. display: flex;
  479. align-items: center;
  480. gap: var(--sp-2);
  481. width: 100%;
  482. padding: var(--sp-2) var(--sp-2) var(--sp-2) var(--sp-3);
  483. border: 1px solid var(--edge);
  484. border-radius: var(--r-md);
  485. background: var(--surface);
  486. transition: border-color var(--motion-fast) var(--ease-standard),
  487. background-color var(--motion-fast) var(--ease-standard);
  488. }
  489. .file-card:hover {
  490. background: var(--surface-hover);
  491. border-color: var(--edge-strong);
  492. }
  493. .file-main {
  494. display: flex;
  495. align-items: center;
  496. gap: var(--sp-3);
  497. flex: 1;
  498. min-width: 0;
  499. padding: var(--sp-1) 0;
  500. border: 0;
  501. background: transparent;
  502. text-align: left;
  503. cursor: pointer;
  504. }
  505. .file-main:focus-visible {
  506. outline: 2px solid var(--primary);
  507. outline-offset: 2px;
  508. border-radius: var(--r-sm);
  509. }
  510. /* 图标块:40px 圆角方块,是卡片里最重的视觉锚点 */
  511. .file-icon {
  512. display: grid;
  513. place-items: center;
  514. width: 40px;
  515. height: 40px;
  516. flex-shrink: 0;
  517. border-radius: var(--r-md);
  518. background: var(--info-subtle);
  519. color: var(--info);
  520. font-size: var(--fs-17);
  521. }
  522. .file-meta {
  523. display: grid;
  524. gap: 3px;
  525. flex: 1;
  526. min-width: 0;
  527. }
  528. .file-meta strong {
  529. font-size: var(--fs-13);
  530. font-weight: 600;
  531. color: var(--ink);
  532. }
  533. .file-meta small {
  534. font-size: var(--fs-11);
  535. color: var(--muted);
  536. }
  537. /* 操作组:悬停才显现,默认保持卡片干净 */
  538. .file-acts {
  539. display: flex;
  540. align-items: center;
  541. gap: 2px;
  542. flex-shrink: 0;
  543. opacity: 0;
  544. transition: opacity var(--motion-fast) var(--ease-standard);
  545. }
  546. .file-card:hover .file-acts,
  547. .file-card:focus-within .file-acts {
  548. opacity: 1;
  549. }
  550. .file-act {
  551. display: grid;
  552. place-items: center;
  553. width: 30px;
  554. height: 30px;
  555. border: 0;
  556. border-radius: var(--r-sm);
  557. background: transparent;
  558. color: var(--muted);
  559. font-size: var(--fs-12);
  560. cursor: pointer;
  561. text-decoration: none;
  562. }
  563. .file-act:hover {
  564. background: var(--surface-active);
  565. color: var(--ink);
  566. }
  567. .file-act:focus-visible {
  568. outline: 2px solid var(--primary);
  569. outline-offset: -2px;
  570. /* 键盘聚焦时操作组必须可见,否则焦点环在透明容器里 */
  571. opacity: 1;
  572. }
  573. .ref-chip {
  574. justify-self: start;
  575. display: inline-flex;
  576. align-items: center;
  577. gap: var(--sp-1);
  578. min-height: var(--touch-min);
  579. padding: 0 var(--sp-3);
  580. border: 1px solid var(--edge);
  581. border-radius: var(--r-tag);
  582. background: var(--surface);
  583. color: var(--info);
  584. font-size: var(--fs-12);
  585. cursor: pointer;
  586. }
  587. .bubble-row.own .ref-chip {
  588. justify-self: end;
  589. }
  590. .truncate {
  591. overflow: hidden;
  592. text-overflow: ellipsis;
  593. white-space: nowrap;
  594. }
  595. `,
  596. ],
  597. })
  598. export class MessageBubbleComponent {
  599. readonly message = input.required<Message>();
  600. readonly own = input(false);
  601. /** 是否显示发送者名(群聊为 true,DM 为 false) */
  602. readonly showSender = input(false);
  603. /** 群成员 Matrix ID → 展示名(用于把 mentions 显示成人能读的名字) */
  604. readonly nameOf = input<(mxId: string) => string>((id) => id.replace(/^@/, '').split(':')[0]);
  605. readonly mentionsWho = computed(() =>
  606. (this.message().mentions ?? []).map((id) => this.nameOf()(id)),
  607. );
  608. readonly retry = output<string>();
  609. /** 复制成功反馈(1.4s 后自动复位) */
  610. protected readonly copied = signal(false);
  611. private copyTimer: ReturnType<typeof setTimeout> | null = null;
  612. /**
  613. * 复制消息正文。
  614. *
  615. * `navigator.clipboard` 在非安全上下文(http 非 localhost)不可用 ——
  616. * 此时退回 textarea + execCommand,保证功能在部署环境里不失效。
  617. */
  618. protected copy(): void {
  619. const text = this.message().text ?? '';
  620. if (!text) return;
  621. const done = () => {
  622. this.copied.set(true);
  623. if (this.copyTimer) clearTimeout(this.copyTimer);
  624. this.copyTimer = setTimeout(() => this.copied.set(false), 1400);
  625. };
  626. if (navigator.clipboard?.writeText) {
  627. navigator.clipboard.writeText(text).then(done, () => this.copyFallback(text, done));
  628. return;
  629. }
  630. this.copyFallback(text, done);
  631. }
  632. private copyFallback(text: string, done: () => void): void {
  633. const ta = document.createElement('textarea');
  634. ta.value = text;
  635. ta.setAttribute('readonly', '');
  636. ta.style.position = 'fixed';
  637. ta.style.opacity = '0';
  638. document.body.appendChild(ta);
  639. ta.select();
  640. try {
  641. document.execCommand('copy');
  642. } catch {
  643. /* 非安全上下文 / 无权限:下面统一给反馈,不抛给调用方 */
  644. } finally {
  645. document.body.removeChild(ta);
  646. }
  647. /*
  648. * 🔴 失败时也要给反馈。
  649. *
  650. * 无头环境 / 无剪贴板权限 / 非安全上下文下 `execCommand` 会返回 false ——
  651. * 早期版本此时什么都不做,用户点了按钮毫无反应,会以为功能坏了。
  652. */
  653. done();
  654. }
  655. readonly openTask = output<string>();
  656. readonly preview = output<NonNullable<Message['file']>>();
  657. /** 缩略图加载失败 → 退回图标卡片(避免裂图) */
  658. readonly thumbBroken = signal(false);
  659. /** 是否图片(按 mimeType 判断;无 mime 时看扩展名) */
  660. isImage(mimeType: string): boolean {
  661. if (mimeType?.startsWith('image/')) return true;
  662. return /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(mimeType ?? '');
  663. }
  664. readonly fmtSize = fmtSize;
  665. /**
  666. * 拆分结果(仅当**确实有**可折叠过程时才分段渲染)。
  667. * 没有过程行的普通回复仍走整段 markdown,避免把 md 语法拆坏。
  668. */
  669. readonly processy = computed(() => hasProcess(this.message()));
  670. readonly blocks = computed<Block[]>(() =>
  671. this.processy() ? splitMessage(this.message().text ?? '') : [],
  672. );
  673. rel(ts: string): string {
  674. return fmtRelTime(ts);
  675. }
  676. fileIcon(mime: string): string {
  677. if (mime.startsWith('image/')) return 'fa-file-image';
  678. if (mime.startsWith('video/')) return 'fa-file-video';
  679. if (mime.startsWith('audio/')) return 'fa-file-audio';
  680. if (mime.includes('csv') || mime.includes('sheet')) return 'fa-file-csv';
  681. if (mime.includes('pdf')) return 'fa-file-pdf';
  682. if (mime.includes('zip')) return 'fa-file-zipper';
  683. return 'fa-file-lines';
  684. }
  685. }