bothouse-dsh 0.1.47 → 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/collab/page.html CHANGED
@@ -243,7 +243,9 @@
243
243
  }
244
244
  .msg:hover { background: var(--hover-soft); }
245
245
  .msg.cont { padding-top: 2px; }
246
- .msg.cont .avatar,
246
+ /* 续行只隐藏头像,不从网格里移除:头像一旦 display:none,正文会顶到第一列,
247
+ 被 40px 的头像列宽挤成一字一行(#112)。 */
248
+ .msg.cont .avatar { visibility: hidden; }
247
249
  .msg.cont .head { display: none; }
248
250
  .msg .stamp {
249
251
  display: none;
@@ -258,6 +260,27 @@
258
260
  font-variant-numeric: tabular-nums;
259
261
  }
260
262
  .msg.cont:hover .stamp { display: block; }
263
+ /* 自己的消息:悬停时右上角出现「撤回」,撤回会连同这条消息发起的回答一起停下。 */
264
+ .msg .ops {
265
+ position: absolute;
266
+ top: 2px;
267
+ right: max(var(--pad-x), env(safe-area-inset-right));
268
+ display: none;
269
+ gap: 4px;
270
+ }
271
+ .msg:hover .ops, .msg:focus-within .ops { display: flex; }
272
+ .msg .ops button {
273
+ font: inherit;
274
+ font-size: var(--fs-xs);
275
+ line-height: 1.4;
276
+ color: var(--muted);
277
+ background: var(--surface);
278
+ border: 1px solid var(--line);
279
+ border-radius: var(--r-sm);
280
+ padding: 1px 6px;
281
+ cursor: pointer;
282
+ }
283
+ .msg .ops button:hover { color: var(--text-strong); background: var(--hover); }
261
284
 
262
285
  .avatar {
263
286
  width: var(--avatar);
@@ -472,6 +495,110 @@
472
495
  .composer { padding-bottom: var(--sp-2); }
473
496
  #log { padding-top: var(--sp-2); }
474
497
  }
498
+
499
+ /* ---- 消息正文 Markdown(#114 渲染):间距用现有 --sp-*,颜色全用令牌,亮暗两套自动跟随 ---- */
500
+ .body.md { white-space: normal; }
501
+ .md > :first-child { margin-top: 0; }
502
+ .md > :last-child { margin-bottom: 0; }
503
+ .md-p { margin: 0 0 var(--sp-2); }
504
+ .md-h { margin: var(--sp-3) 0 var(--sp-1); font-weight: 650; color: var(--text-strong); line-height: 1.35; }
505
+ .md-h1, .md-h2 { font-size: 1.15em; }
506
+ .md-h3 { font-size: 1.05em; }
507
+ .md-list { margin: 0 0 var(--sp-2); padding-left: 1.5em; }
508
+ .md-list li { margin: 2px 0; }
509
+ .md-list li::marker { color: var(--faint); }
510
+ .md-sub { padding-left: 1em; position: relative; }
511
+ .md-sub::before { content: "◦"; position: absolute; left: 0; color: var(--faint); }
512
+ .md-quote { margin: 0 0 var(--sp-2); padding: 2px 0 2px var(--sp-3); border-left: 3px solid var(--line); color: var(--muted); }
513
+ .md-hr { border: 0; border-top: 1px solid var(--line); margin: var(--sp-3) 0; }
514
+ .md-link { color: var(--accent); text-decoration: none; overflow-wrap: anywhere; }
515
+ .md-link:hover { text-decoration: underline; text-underline-offset: 2px; }
516
+ .md-code {
517
+ font: .88em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
518
+ background: var(--surface-2); color: var(--text-strong);
519
+ padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere;
520
+ }
521
+ /* 代码块和表格自带横向滚动:长行只滚自己,不把整页撑出横向滚动条 */
522
+ .md-pre { margin: 0 0 var(--sp-2); border: 1px solid var(--line); border-radius: var(--r-md); background: var(--surface); overflow: hidden; max-width: 100%; }
523
+ .md-pre-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); padding: 4px 6px 4px var(--sp-3); border-bottom: 1px solid var(--line); font-size: var(--fs-xs); color: var(--faint); }
524
+ .md-lang { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; text-transform: lowercase; }
525
+ .md-copy { font: inherit; color: var(--muted); background: transparent; border: 0; border-radius: var(--r-sm); padding: 2px 8px; cursor: pointer; transition: background var(--dur) var(--ease), color var(--dur) var(--ease); }
526
+ .md-copy:hover { background: var(--hover); color: var(--text-strong); }
527
+ /* 代码 13px/1.55 沿用 design-spec-channels 正文 14/22 降一档 */
528
+ .md-pre pre { margin: 0; padding: var(--sp-3); overflow-x: auto; font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--text-strong); tab-size: 2; }
529
+ .md-pre pre:focus-visible, .md-table:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
530
+ .md-table { margin: 0 0 var(--sp-2); overflow-x: auto; max-width: 100%; border: 1px solid var(--line); border-radius: var(--r-md); }
531
+ .md-table table { border-collapse: collapse; min-width: 100%; font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
532
+ .md-table th, .md-table td { padding: 6px var(--sp-3); text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; }
533
+ .md-table th { background: var(--surface); color: var(--text-strong); font-weight: 600; }
534
+ .md-table tr:last-child td { border-bottom: 0; }
535
+ .card .md-pre, .card .md-table { background: var(--bg); }
536
+
537
+ /* 回复引用:消息头下方一行,点一下跳到原消息 */
538
+ .reply-quote {
539
+ display: flex; align-items: baseline; gap: 6px; max-width: min(100%, 640px); /* 640 沿用 design-spec-channels 气泡 max-width */ margin: 2px 0 4px;
540
+ padding: 2px var(--sp-2); border: 0; border-left: 2px solid var(--line); border-radius: 0 var(--r-sm) var(--r-sm) 0;
541
+ background: var(--hover-soft); color: var(--muted); font: inherit; font-size: var(--fs-sm); text-align: left; cursor: pointer; min-width: 0;
542
+ }
543
+ .reply-quote:disabled { cursor: default; font-style: italic; }
544
+ .reply-quote:not(:disabled):hover { color: var(--text); }
545
+ .rq-who { font-weight: 600; color: var(--text); flex: none; }
546
+ .rq-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
547
+ .msg.flash { animation: msg-flash 1.2s var(--ease); }
548
+ @keyframes msg-flash { 0%, 30% { background: color-mix(in srgb, var(--accent) 14%, transparent); } 100% { background: transparent; } }
549
+
550
+ /* 附件:图片按原比例缩到消息宽度内,文件是一张小卡。
551
+ 图片上限 320×240 沿用频道 React 页 ChannelsPage.module.css .attachmentImage;
552
+ 64px 占位只在加载前撑住一块,加载后取消,避免空白图塌成 0 高再弹开。 */
553
+ .atts { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-1); }
554
+ .att-img { display: block; max-width: min(100%, 320px); border-radius: var(--r-md); overflow: hidden; border: 1px solid var(--line); background: var(--surface); line-height: 0; min-height: 64px; min-width: 64px; }
555
+ .att-img.loaded { min-height: 0; min-width: 0; }
556
+ .att-img img { display: block; max-width: 100%; max-height: 240px; width: auto; height: auto; object-fit: contain; }
557
+ .att-img.broken { line-height: 1.4; padding: var(--sp-2) var(--sp-3); color: var(--muted); font-size: var(--fs-sm); }
558
+ .att-file { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3) var(--sp-2) var(--sp-2); max-width: min(100%, 320px); border: 1px solid var(--line); border-radius: var(--r-md); background: var(--surface); color: var(--text); text-decoration: none; transition: background var(--dur) var(--ease); }
559
+ .att-file:hover { background: var(--hover); }
560
+ .att-ext { flex: none; width: 36px; height: 36px; display: grid; place-items: center; border-radius: var(--r-sm); background: var(--accent); color: var(--on-accent); font-size: 10px; font-weight: 700; letter-spacing: .02em; }
561
+ .att-info { display: grid; min-width: 0; }
562
+ .att-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--fs-sm); color: var(--text-strong); }
563
+ .att-size { font-size: var(--fs-xs); color: var(--faint); font-variant-numeric: tabular-nums; }
564
+
565
+ /* 系统通知:居中一行灰字,不当成一条对话 */
566
+ .msg.notice { display: block; padding-top: 2px; padding-bottom: 2px; }
567
+ .msg.notice:hover { background: transparent; }
568
+ .notice-line { display: flex; justify-content: center; align-items: baseline; gap: var(--sp-2); color: var(--faint); font-size: var(--fs-xs); text-align: center; }
569
+ .notice-text { font-style: normal; color: var(--muted); }
570
+ .notice-text .md-p { margin: 0; }
571
+
572
+ /* 思考过程:收成一行灰字,点开才看推理。不占头像列、不改对话节奏。 */
573
+ .msg.think-row, .msg.tool-row { display: block; }
574
+ .think { margin: 2px 0 var(--sp-2); }
575
+ .think-toggle {
576
+ all: unset; display: inline-flex; align-items: baseline; gap: 6px; cursor: pointer;
577
+ color: var(--faint); font-size: var(--fs-sm); line-height: 1.5; border-radius: var(--r-sm);
578
+ }
579
+ .think-toggle:hover { color: var(--muted); }
580
+ .think-toggle .chev { font-size: 11px; transition: transform var(--dur) var(--ease); }
581
+ .think-toggle[aria-expanded="true"] .chev { transform: rotate(90deg); }
582
+ .think-body { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 220ms var(--ease); }
583
+ .think[data-open="true"] .think-body { grid-template-rows: 1fr; }
584
+ .think-body > div { overflow: hidden; color: var(--muted); font-size: var(--fs-sm); border-left: 2px solid var(--line); padding-left: var(--sp-3); margin: 4px 0 2px; }
585
+ .think-text .md-p { margin: 0 0 var(--sp-1); }
586
+
587
+ /* 工具事件:居中一行灰字,像分隔提示,不当成一条对话,也不打断阅读。 */
588
+ .msg.tool-row { padding-top: 1px; padding-bottom: 1px; }
589
+ .tool-line { display: flex; justify-content: center; align-items: baseline; gap: 6px; color: var(--faint); font-size: var(--fs-xs); line-height: 1.6; }
590
+ .tool-line .tl-dot { flex: none; width: 4px; height: 4px; border-radius: 50%; background: currentColor; opacity: .6; }
591
+ .tool-line .tl-name { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); }
592
+ .tool-line .tl-sum { max-width: min(70%, 520px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
593
+ .tool-line.failed .tl-name, .tool-line.failed .tl-sum { color: var(--danger); }
594
+ .progress-body .progress-meta { color: var(--muted); font-size: var(--fs-xs); margin-bottom: 2px; }
595
+ .progress-body .tool-line { justify-content: flex-start; }
596
+ .progress-body .tool-line .tl-sum { max-width: 100%; }
597
+ @media (prefers-reduced-motion: reduce) { .think-body { transition: none; } }
598
+
599
+ @media (prefers-reduced-motion: reduce) {
600
+ .msg.flash { animation: none; outline: 2px solid var(--accent); outline-offset: -2px; }
601
+ }
475
602
  </style>
476
603
  </head>
477
604
  <body>
@@ -607,13 +734,270 @@ function renderMembers() {
607
734
  $('thinking').textContent = names.length ? `${names.join('、')} 正在处理…` : '';
608
735
  }
609
736
 
737
+ // ---- 消息正文渲染 ----
738
+ // Markdown 只认常用子集(段落、标题、列表、引用、代码块、表格、分隔线、行内代码/粗斜体/删除线/链接)。
739
+ // 全部用 DOM 节点拼,不走 innerHTML:原文里的 HTML 一律按文字显示,链接只放行 http(s)/mailto。
740
+ const SAFE_HREF = /^(https?:|mailto:)/i;
741
+ const INLINE = /`([^`\n]+)`|\*\*([^*\n]+?)\*\*|(?<!\w)__([^_\n]+?)__(?!\w)|~~([^~\n]+?)~~|(?<![\w*])\*([^*\s][^*\n]*?)\*(?![\w*])|\[([^\]\n]+)\]\(([^)\s]+)\)|(https?:\/\/[^\s<>"'()「」,。;!?]+[^\s<>"'()「」,。;!?.,;:!?)])/g;
742
+ function mdInline(text, parent) {
743
+ let last = 0;
744
+ for (const m of text.matchAll(INLINE)) {
745
+ if (m.index > last) parent.append(text.slice(last, m.index));
746
+ if (m[1] != null) parent.append(el('code', 'md-code', m[1]));
747
+ else if (m[2] != null || m[3] != null) mdInline(m[2] ?? m[3], parent.appendChild(el('strong')));
748
+ else if (m[4] != null) mdInline(m[4], parent.appendChild(el('del')));
749
+ else if (m[5] != null) mdInline(m[5], parent.appendChild(el('em')));
750
+ else {
751
+ const href = m[7] ?? m[8];
752
+ if (SAFE_HREF.test(href)) {
753
+ const a = el('a', 'md-link'); a.href = href; a.target = '_blank'; a.rel = 'noopener noreferrer';
754
+ if (m[6] != null) mdInline(m[6], a); else a.textContent = href;
755
+ parent.append(a);
756
+ } else parent.append(m[0]);
757
+ }
758
+ last = m.index + m[0].length;
759
+ }
760
+ if (last < text.length) parent.append(text.slice(last));
761
+ }
762
+ // 段内单个换行照原样换行(聊天里大家按回车分行,和频道 React 页 softBreaksToHard 一致)。
763
+ function mdLines(lines, parent) {
764
+ lines.forEach((line, i) => { if (i) parent.append(el('br')); mdInline(line, parent); });
765
+ return parent;
766
+ }
767
+ const FENCE = /^\s*(`{3,}|~{3,})\s*([\w#+.-]*)/;
768
+ const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
769
+ const RULE = /^\s{0,3}([-*_])(\s*\1){2,}\s*$/;
770
+ const QUOTE = /^\s{0,3}>\s?(.*)$/;
771
+ const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/;
772
+ const TABLE_SEP = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/;
773
+ const cells = line => line.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(c => c.trim().replace(/\\\|/g, '|'));
774
+ function codeBlock(lang, code) {
775
+ const box = el('div', 'md-pre');
776
+ const bar = el('div', 'md-pre-bar');
777
+ bar.append(el('span', 'md-lang', lang || '代码'));
778
+ const copy = el('button', 'md-copy', '复制'); copy.type = 'button';
779
+ copy.onclick = async () => {
780
+ // 流式更新只换 <code> 里的字(patchBody),复制要取当前内容,不能用建块时的旧字符串。
781
+ try { await navigator.clipboard.writeText(pre.querySelector('code').textContent); copy.textContent = '已复制'; }
782
+ catch { copy.textContent = '复制失败'; }
783
+ setTimeout(() => { copy.textContent = '复制'; }, 1500);
784
+ };
785
+ bar.append(copy);
786
+ const pre = el('pre'); pre.append(el('code', null, code)); pre.tabIndex = 0;
787
+ box.append(bar, pre);
788
+ return box;
789
+ }
790
+ function mdBlocks(lines, parent) {
791
+ let i = 0;
792
+ const isSpecial = (l, next) => FENCE.test(l) || HEADING.test(l) || RULE.test(l) || QUOTE.test(l) || ITEM.test(l) || (l.includes('|') && next !== undefined && TABLE_SEP.test(next));
793
+ while (i < lines.length) {
794
+ const line = lines[i];
795
+ if (!line.trim()) { i++; continue; }
796
+ let m;
797
+ if ((m = FENCE.exec(line))) {
798
+ const close = new RegExp('^\\s*' + m[1][0] + '{' + m[1].length + ',}\\s*$');
799
+ const body = [];
800
+ for (i++; i < lines.length && !close.test(lines[i]); i++) body.push(lines[i]);
801
+ i++;
802
+ parent.append(codeBlock(m[2], body.join('\n')));
803
+ } else if ((m = HEADING.exec(line))) {
804
+ mdInline(m[2], parent.appendChild(el('p', 'md-h md-h' + m[1].length))); i++;
805
+ } else if (RULE.test(line)) {
806
+ parent.append(el('hr', 'md-hr')); i++;
807
+ } else if (QUOTE.test(line)) {
808
+ const body = [];
809
+ for (; i < lines.length && (m = QUOTE.exec(lines[i])); i++) body.push(m[1]);
810
+ mdBlocks(body, parent.appendChild(el('blockquote', 'md-quote')));
811
+ } else if (line.includes('|') && TABLE_SEP.test(lines[i + 1] ?? '')) {
812
+ const wrap = el('div', 'md-table'); wrap.tabIndex = 0;
813
+ const table = wrap.appendChild(el('table'));
814
+ const head = table.appendChild(el('thead')).appendChild(el('tr'));
815
+ for (const c of cells(line)) mdInline(c, head.appendChild(el('th')));
816
+ const body = table.appendChild(el('tbody'));
817
+ for (i += 2; i < lines.length && lines[i].includes('|') && lines[i].trim(); i++) {
818
+ const tr = body.appendChild(el('tr'));
819
+ for (const c of cells(lines[i])) mdInline(c, tr.appendChild(el('td')));
820
+ }
821
+ parent.append(wrap);
822
+ } else if ((m = ITEM.exec(line))) {
823
+ const ordered = /\d/.test(m[2]);
824
+ const list = el(ordered ? 'ol' : 'ul', 'md-list');
825
+ if (ordered && parseInt(m[2], 10) !== 1) list.start = parseInt(m[2], 10);
826
+ const base = m[1].length;
827
+ let li = null, extra = [];
828
+ const flush = () => { if (li && extra.length) { li.append(el('br')); mdLines(extra, li); } extra = []; };
829
+ for (; i < lines.length; i++) {
830
+ const l = lines[i];
831
+ const it = ITEM.exec(l);
832
+ if (it && /\d/.test(it[2]) === ordered && it[1].length <= base + 1) { flush(); li = list.appendChild(el('li')); mdInline(it[3], li); continue; }
833
+ if (it && it[1].length > base + 1) { flush(); mdInline(it[3], li.appendChild(el('div', 'md-sub'))); continue; }
834
+ if (l.trim() && /^\s+/.test(l) && !isSpecial(l.trim(), lines[i + 1])) { extra.push(l.trim()); continue; }
835
+ break;
836
+ }
837
+ flush();
838
+ parent.append(list);
839
+ } else {
840
+ const para = [];
841
+ for (; i < lines.length && lines[i].trim() && !(para.length && isSpecial(lines[i], lines[i + 1])); i++) para.push(lines[i]);
842
+ parent.append(mdLines(para, el('p', 'md-p')));
843
+ }
844
+ }
845
+ return parent;
846
+ }
847
+ function renderBody(text, cls) {
848
+ const body = el('div', (cls ? cls + ' ' : '') + 'md');
849
+ mdBlocks(String(text ?? '').replace(/\r\n?/g, '\n').split('\n'), body);
850
+ return body;
851
+ }
852
+
853
+ const IMAGE_MIME = /^image\/(png|jpeg|gif|webp)$/; // 与 attachments.mjs INLINE_IMAGE 同一组可内联图片
854
+ const fmtSize = n => n < 1024 ? n + ' B' : n < 1048576 ? (n / 1024).toFixed(1) + ' KB' : (n / 1048576).toFixed(1) + ' MB';
855
+ function attachmentList(list) {
856
+ const box = el('div', 'atts');
857
+ for (const a of list) {
858
+ const url = `${ROOT}/collab/api/attachments/${encodeURIComponent(a.id)}`;
859
+ if (IMAGE_MIME.test(a.mime)) {
860
+ const link = el('a', 'att-img'); link.href = url; link.target = '_blank'; link.rel = 'noopener'; link.title = a.name;
861
+ const img = el('img'); img.alt = a.name; img.decoding = 'async'; img.src = url;
862
+ // 图片晚到会撑高消息流:贴底时跟着回到底部,不让读者看到内容被往下顶。
863
+ img.addEventListener('load', () => { link.classList.add('loaded'); keepBottom(); }, { once: true });
864
+ img.addEventListener('error', () => { link.classList.add('broken'); link.replaceChildren(el('span', 'att-broken', '图片加载失败:' + a.name)); }, { once: true });
865
+ link.append(img);
866
+ box.append(link);
867
+ } else {
868
+ const file = el('a', 'att-file'); file.href = url; file.download = a.name;
869
+ file.append(el('span', 'att-ext', (a.name.split('.').pop() || '文件').slice(0, 4).toUpperCase()));
870
+ const info = el('span', 'att-info');
871
+ info.append(el('span', 'att-name', a.name), el('span', 'att-size', fmtSize(Number(a.size) || 0)));
872
+ file.append(info);
873
+ box.append(file);
874
+ }
875
+ }
876
+ return box;
877
+ }
878
+ function replyQuote(q) {
879
+ const box = el('button', 'reply-quote'); box.type = 'button';
880
+ if (q.deleted) { box.append(el('span', 'rq-text', '原消息已撤回')); box.disabled = true; return box; }
881
+ box.append(el('span', 'rq-who', q.name), el('span', 'rq-text', q.text));
882
+ box.onclick = () => {
883
+ const target = $('log').querySelector(`.msg[data-id="${q.id}"]`);
884
+ if (!target) return;
885
+ target.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
886
+ target.classList.remove('flash'); void target.offsetWidth; target.classList.add('flash');
887
+ };
888
+ return box;
889
+ }
890
+ // 贴底跟随:滚动时记住读者是否在底部;之后任何内容变高(图片、卡片更新)都只在贴底时跟随。
891
+ const STICK_PX = 40; // 沿用原 upsert 的贴底判定阈值
892
+ let stuck = true;
893
+ function keepBottom() { const log = $('log'); if (stuck) log.scrollTop = log.scrollHeight; }
894
+ $('log').addEventListener('scroll', () => { const log = $('log'); stuck = log.scrollTop + log.clientHeight >= log.scrollHeight - STICK_PX; }, { passive: true });
895
+ // 刚插进去的这一帧,字体与换行还没算成最终高度:贴底时再跟一帧,底部不留缝(流式输出最常见)。
896
+ function followBottom(log, stick) {
897
+ if (!stick) return;
898
+ log.scrollTop = log.scrollHeight;
899
+ requestAnimationFrame(() => { if (stuck) log.scrollTop = log.scrollHeight; });
900
+ }
901
+ if ('ResizeObserver' in window) new ResizeObserver(keepBottom).observe($('log'));
902
+ const messageSig = m => JSON.stringify([m.text, m.card, m.edited_at, m.attachments, m.reply_to]);
903
+ // 外框签名:附件、引用、作者这些决定「整行长相」的东西。只有正文在变时(流式输出)原地换文字,
904
+ // 不重建整行 —— 图片不重载、引用条不闪、代码块的横向滚动位置不丢。
905
+ const frameSig = m => JSON.stringify([m.attachments, m.reply_to, m.author_kind, m.author]);
906
+
907
+ // 思考块:一行灰字按钮,点开看内容。思考消息和进度卡里的思考行共用。
908
+ function thinkBox(label, content) {
909
+ const box = el('div', 'think');
910
+ const btn = el('button', 'think-toggle');
911
+ btn.type = 'button';
912
+ btn.setAttribute('aria-expanded', 'false');
913
+ btn.append(el('span', 'think-label', label), el('span', 'chev', '›'));
914
+ const body = el('div', 'think-body');
915
+ const inner = el('div');
916
+ inner.append(content);
917
+ body.append(inner);
918
+ btn.onclick = () => {
919
+ const open = box.dataset.open === 'true';
920
+ box.dataset.open = String(!open);
921
+ btn.setAttribute('aria-expanded', String(!open));
922
+ };
923
+ box.append(btn, body);
924
+ return box;
925
+ }
926
+ function toolLine(name, sum, failed) {
927
+ const line = el('div', 'tool-line' + (failed ? ' failed' : ''));
928
+ line.append(el('span', 'tl-dot'), el('span', 'tl-name', name || '工具'));
929
+ if (sum) line.append(el('span', 'tl-sum', sum));
930
+ return line;
931
+ }
932
+ // 进度卡正文(cloudweb.mjs progressCardOf 的 text:首行「第 N 步 · 秒」,其后每行
933
+ // 「• 思考/说明」「🔧 工具 — 摘要」「❌ 出错」)。连续的「•」行收成一个思考块,工具行各占一行。
934
+ // 只读现有 text,不改协议;认不出的行照普通正文显示。
935
+ const isProgressMeta = meta => !meta.kind && Number.isInteger(meta.steps) && typeof meta.brief === 'string';
936
+ function progressBody(text, state) {
937
+ const out = el('div', 'body progress-body');
938
+ const lines = String(text || '').split('\n');
939
+ out.append(el('div', 'progress-meta', lines.shift() || ''));
940
+ let thoughts = [];
941
+ const flush = () => {
942
+ if (!thoughts.length) return;
943
+ out.append(thinkBox(state === 'running' ? '思考中…' : '思考过程', renderBody(thoughts.join('\n\n'), 'think-text')));
944
+ thoughts = [];
945
+ };
946
+ for (const line of lines) {
947
+ let mm;
948
+ if ((mm = /^• (.*)$/.exec(line))) { thoughts.push(mm[1]); continue; }
949
+ flush();
950
+ if ((mm = /^(🔧|❌) (.*)$/u.exec(line))) {
951
+ const [name, ...rest] = mm[2].split(' — ');
952
+ const failed = mm[1] === '❌';
953
+ out.append(rest.length ? toolLine(name, rest.join(' — '), failed) : toolLine(failed ? '出错' : '工具', name, failed));
954
+ } else if (line.trim()) out.append(renderBody(line, 'progress-other'));
955
+ }
956
+ flush();
957
+ return out;
958
+ }
959
+
960
+ // 思考过程:默认一行灰字,点一下展开;运行中显示「思考中…」。
961
+ function thinkingNode(m, meta) {
962
+ const row = el('div', 'msg think-row');
963
+ row.dataset.id = m.id;
964
+ row.dataset.sig = messageSig(m);
965
+ row.dataset.frame = frameSig(m);
966
+ const secs = Number(meta.seconds);
967
+ row.append(thinkBox(meta.state === 'running' ? '思考中…' : (secs > 0 ? `思考了 ${secs} 秒` : '思考过程'), renderBody(meta.detail || m.text || '', 'think-text')));
968
+ return row;
969
+ }
970
+ // 工具事件:居中一行灰字;失败时整行标红,仍然只有一行。
971
+ function toolNode(m, meta) {
972
+ const row = el('div', 'msg tool-row');
973
+ row.dataset.id = m.id;
974
+ row.dataset.sig = messageSig(m);
975
+ row.dataset.frame = frameSig(m);
976
+ row.append(toolLine(meta.title, meta.summary || String(m.text || '').split('\n')[0], meta.state === 'failed'));
977
+ return row;
978
+ }
979
+
610
980
  function messageNode(m) {
981
+ let meta = {}; try { meta = JSON.parse(m.card || '{}'); } catch {}
982
+ // 思考与工具事件不是对话:收成一行,不占头像和昵称。
983
+ if (meta.kind === 'thinking') return thinkingNode(m, meta);
984
+ if (meta.kind === 'tool') return toolNode(m, meta);
611
985
  const system = m.author_kind === 'system';
612
986
  const row = el('div', 'msg' + (system ? ' system' : ''));
613
987
  row.dataset.id = m.id;
614
988
  row.dataset.author = m.author;
615
989
  row.dataset.ts = m.created_at;
616
990
  row.dataset.system = system ? '1' : '0';
991
+ row.dataset.sig = messageSig(m);
992
+ row.dataset.frame = frameSig(m);
993
+ // 系统通知(交接、派活记录等)不是对话:收成居中一行灰字,不占头像和昵称。
994
+ if (system && !m.card) {
995
+ row.classList.add('notice');
996
+ const line = el('div', 'notice-line');
997
+ line.append(renderBody(m.text, 'notice-text'), el('span', 'when', fmtTime(m.created_at)));
998
+ row.append(line);
999
+ return row;
1000
+ }
617
1001
  const member = state.members.find(x => x.id === m.author);
618
1002
  row.append(avatar(m.author, m.name));
619
1003
  const col = el('div');
@@ -622,6 +1006,7 @@ function messageNode(m) {
622
1006
  if (member && kindLabel[member.kind]) who.append(el('span', 'tag', kindLabel[member.kind]));
623
1007
  head.append(who, el('span', 'when', fmtTime(m.created_at)));
624
1008
  col.append(head);
1009
+ if (m.reply_to) col.append(replyQuote(m.reply_to));
625
1010
  if (m.card) {
626
1011
  let meta = {}; try { meta = JSON.parse(m.card); } catch {}
627
1012
  const card = el('div', 'card');
@@ -630,7 +1015,7 @@ function messageNode(m) {
630
1015
  titleRow.append(el('span', 'title', meta.title || ''));
631
1016
  const label = cardStateLabel[meta.state];
632
1017
  if (label) titleRow.append(el('span', 'chip', label));
633
- card.append(titleRow, el('div', 'body', m.text));
1018
+ card.append(titleRow, isProgressMeta(meta) ? progressBody(m.text, meta.state) : renderBody(m.text, 'body'));
634
1019
  // Pending permission card: one button per action, posting the decision (same route as a text reply).
635
1020
  if (meta.kind === 'permission' && meta.state === 'pending' && Array.isArray(meta.actions)) {
636
1021
  const bar = el('div', 'actions');
@@ -642,8 +1027,24 @@ function messageNode(m) {
642
1027
  card.append(bar);
643
1028
  }
644
1029
  col.append(card);
645
- } else col.append(el('div', 'body', m.text));
1030
+ } else if (m.text) col.append(renderBody(m.text, 'body'));
1031
+ if (Array.isArray(m.attachments) && m.attachments.length) col.append(attachmentList(m.attachments));
646
1032
  row.append(col);
1033
+ // 自己发的普通消息:悬停出现「撤回」。撤回后后端广播 deleted,这一行随之消失;
1034
+ // 这条消息触发的回答也会一起停下(后端 recall 会取消对应 turn)。
1035
+ if (!system && !m.card && m.author === state.me) {
1036
+ const ops = el('div', 'ops');
1037
+ const b = el('button', '', '撤回');
1038
+ b.type = 'button';
1039
+ b.title = '撤回这条消息(正在进行的回答也会停下)';
1040
+ b.onclick = async () => {
1041
+ if (!confirm('撤回这条消息?正在进行的回答也会停下。')) return;
1042
+ try { await api(`${ROOT}/collab/api/messages/${m.id}`, { method: 'DELETE' }); }
1043
+ catch (err) { alert('撤回失败:' + err.message); }
1044
+ };
1045
+ ops.append(b);
1046
+ row.append(ops);
1047
+ }
647
1048
  return row;
648
1049
  }
649
1050
 
@@ -667,18 +1068,71 @@ function regroup() {
667
1068
  }
668
1069
  }
669
1070
 
1071
+ // 正文按块对比:同一段文字算同一块,直接留用原来的节点。代码块只比语言和代码本身,
1072
+ // 不比「复制」按钮的字,否则点过复制的代码块会被当成新块重建,横向滚动位置跟着丢。
1073
+ const isCodeBlock = n => n.classList.contains('md-pre');
1074
+ const codeLang = n => n.querySelector('.md-lang')?.textContent || '';
1075
+ const blockSig = n => isCodeBlock(n)
1076
+ ? 'pre|' + codeLang(n) + '|' + (n.querySelector('pre code')?.textContent ?? '')
1077
+ : n.outerHTML; // 结构和属性都比:同字不同链接、粗体改普通字都要换块
1078
+
1079
+ // 正文原地更新:内容没变的块复用,代码块长高时只换里面的字并保住横向滚动位置,其余块才替换。
1080
+ function patchBody(body, fresh) {
1081
+ const oldNodes = [...body.children];
1082
+ const newNodes = [...fresh.children];
1083
+ let i = 0;
1084
+ for (; i < oldNodes.length && i < newNodes.length; i++) {
1085
+ const old = oldNodes[i], next = newNodes[i];
1086
+ if (blockSig(old) === blockSig(next)) continue;
1087
+ if (isCodeBlock(old) && isCodeBlock(next) && codeLang(old) === codeLang(next)) {
1088
+ const pre = old.querySelector('pre');
1089
+ const left = pre.scrollLeft;
1090
+ pre.querySelector('code').textContent = next.querySelector('pre code').textContent;
1091
+ pre.scrollLeft = left;
1092
+ continue;
1093
+ }
1094
+ old.replaceWith(next);
1095
+ }
1096
+ for (; i < newNodes.length; i++) body.append(newNodes[i]);
1097
+ for (; i < oldNodes.length; i++) oldNodes[i].remove();
1098
+ }
1099
+
670
1100
  function upsert(m) {
671
1101
  if (m.room_id !== state.room) return;
672
1102
  const log = $('log');
673
- const stick = log.scrollTop + log.clientHeight >= log.scrollHeight - 40;
1103
+ // 消息一到,空态/加载态就不再成立;否则第一条实时消息会顶着“还没有消息”显示(#112)。
1104
+ log.querySelector('.state')?.remove();
1105
+ const stick = log.scrollTop + log.clientHeight >= log.scrollHeight - STICK_PX;
674
1106
  const existing = log.querySelector(`[data-id="${m.id}"]`);
675
- if (existing) existing.replaceWith(messageNode(m));
1107
+ // 重复推送(重连补拉、同一条事件到两次)内容没变就不重建,避免闪一下、丢掉代码块的横向滚动位置。
1108
+ if (existing && existing.dataset.sig === messageSig(m)) return;
1109
+ // 流式输出:同一条消息只涨正文、外框没变时按块原地更新,不重建整行(图片不重载、代码块不回到最左、不闪、不跳)。
1110
+ if (existing && !m.card && existing.dataset.frame === frameSig(m)) {
1111
+ const body = existing.querySelector(':scope > div > .body');
1112
+ if (body) {
1113
+ patchBody(body, renderBody(m.text, 'body'));
1114
+ existing.dataset.sig = messageSig(m);
1115
+ followBottom(log, stick);
1116
+ return;
1117
+ }
1118
+ }
1119
+ if (existing) {
1120
+ // 进度卡每步整行重建:已展开的思考块保持展开,不因新的一步收回去。
1121
+ const opened = [...existing.querySelectorAll('.think')].map(t => t.dataset.open === 'true');
1122
+ const node = messageNode(m);
1123
+ node.querySelectorAll('.think').forEach((t, i) => {
1124
+ if (!opened[i]) return;
1125
+ t.dataset.open = 'true';
1126
+ t.querySelector('.think-toggle')?.setAttribute('aria-expanded', 'true');
1127
+ });
1128
+ existing.replaceWith(node);
1129
+ }
676
1130
  else {
677
1131
  const after = [...log.children].find(n => n.dataset.id != null && Number(n.dataset.id) > m.id);
678
1132
  log.insertBefore(messageNode(m), after || null);
679
1133
  }
680
1134
  regroup();
681
- if (stick) log.scrollTop = log.scrollHeight;
1135
+ followBottom(log, stick);
682
1136
  }
683
1137
 
684
1138
  async function api(path, init) {
@@ -716,6 +1170,7 @@ async function openRoom(id) {
716
1170
  if (!log.querySelector('.msg')) log.append(showState('empty', '还没有消息', ''));
717
1171
  $('log').setAttribute('aria-busy', 'false');
718
1172
  $('input').disabled = false;
1173
+ stuck = true;
719
1174
  $('log').scrollTop = $('log').scrollHeight;
720
1175
  } catch (err) {
721
1176
  if (state.room !== id) return;