bothouse-dsh 0.1.48 → 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.
@@ -0,0 +1,159 @@
1
+ // #1020 回复的记忆来源:which pushed memory entries a reply used, shown as a badge under it.
2
+ // The gate pushes entries tagged [#编号] before each turn (bridge/contrib/gate/ctxsvc.py exp_tag) and the
3
+ // agent reports the ones it used with memvote.py (+编号), which appends a by="self" line to votes.jsonl
4
+ // ({t, voter: CC_PROJECT, id: "页#指纹", vote, by, key: CC_SESSION_KEY, agent}). This module tails that file
5
+ // and hands each new reply of a member the entries it cited in that room since its previous reply.
6
+ // Nothing is guessed: no citation, no badge (a member that never runs memvote gets none).
7
+ //
8
+ // Where the file is: MEMVOTE_DIR (memvote.py's own override), else <gate data dir>/gate, where bothouse
9
+ // installs the gate (bridge/core/gate_proc.go). The data dir is found the way the gate itself finds it
10
+ // (bridge/contrib/gate/datadir.py data_dir, Go brand.go dataDir): BOTHOUSE_DATA_DIR, GATE_SETUP_DIR,
11
+ // CC_DATA_DIR, else ~/.bothouse (~/.cc-connect only while that legacy layout is the one that exists).
12
+ // It must not depend on BOTHOUSE_DATA_DIR alone: a `dsh web` started by a script that reuses an old
13
+ // environment has only CC_DATA_DIR, and the badge used to vanish there silently.
14
+ // Page paths are relative to GATE_WIKI (~/wiki) or start with ~/ for a role wiki (memvote.full_id).
15
+ import { closeSync, existsSync, openSync, readSync, readFileSync, statSync } from 'node:fs';
16
+ import { createHash } from 'node:crypto';
17
+ import { homedir } from 'node:os';
18
+ import { join } from 'node:path';
19
+
20
+ const MAX_ITEMS = 12; // a badge lists at most this many entries
21
+ const TITLE_CHARS = 120;
22
+
23
+ /** `~/x` in a config value, as expanduser does for the gate (datadir.guard). */
24
+ const expand = (value, home) => String(value ?? '').trim().replace(/^~(?=\/|$)/, home);
25
+
26
+ /**
27
+ * The bothouse data dirs this machine may have, in the order the gate itself picks one
28
+ * (bridge/contrib/gate/datadir.py data_dir; Go brand.go dataDir for the last one).
29
+ * @param {NodeJS.ProcessEnv} env
30
+ * @param {string} home
31
+ * @returns {string[]}
32
+ */
33
+ export function dataDirCandidates(env = process.env, home = homedir()) {
34
+ const dirs = [];
35
+ for (const key of ['BOTHOUSE_DATA_DIR', 'GATE_SETUP_DIR', 'CC_DATA_DIR']) {
36
+ const dir = expand(env[key], home);
37
+ if (dir !== '') dirs.push(dir);
38
+ }
39
+ // ~/.cc-connect is the legacy layout, used only while ~/.bothouse does not exist and it does.
40
+ const preferred = join(home, '.bothouse');
41
+ const legacy = join(home, '.cc-connect');
42
+ dirs.push(!existsSync(preferred) && existsSync(legacy) ? legacy : preferred);
43
+ return dirs;
44
+ }
45
+
46
+ /**
47
+ * memvote.py's votes.jsonl: MEMVOTE_DIR when set (memvote's own override names the one file it writes),
48
+ * else <the gate's data dir>/gate. Same precedence as the gate reading its own directory, so a `dsh web`
49
+ * that inherited only CC_DATA_DIR reads the file the running gate actually appends to.
50
+ * @param {NodeJS.ProcessEnv} env
51
+ * @param {string} home
52
+ * @returns {string}
53
+ */
54
+ export function defaultVotesFile(env = process.env, home = homedir()) {
55
+ const dir = expand(env.MEMVOTE_DIR, home);
56
+ if (dir !== '') return join(dir, 'votes.jsonl');
57
+ return join(dataDirCandidates(env, home)[0], 'gate', 'votes.jsonl');
58
+ }
59
+
60
+ /** Room id a cite's session key names: bridge cloud-web "…room-<id>…", or the DSH gate key "dsh:<member>:<room>". */
61
+ export function keyRoom(key) {
62
+ const s = String(key ?? '');
63
+ const m = /(?:^|:)room-(\d+)(?::|$)/.exec(s) ?? /^dsh:[^:]+:(\d+)$/.exec(s);
64
+ return m ? Number(m[1]) : undefined;
65
+ }
66
+
67
+ export class MemorySources {
68
+ /** @param options - { file?: votes.jsonl path, wiki?, home?, logger? } */
69
+ constructor({ file = defaultVotesFile(), wiki = process.env.GATE_WIKI || join(homedir(), 'wiki'), home = homedir(), logger } = {}) {
70
+ this.file = file;
71
+ this.wiki = String(wiki).replace(/^~(?=\/|$)/, home);
72
+ this.home = home;
73
+ this.logger = logger;
74
+ // Say which file the badge reads, so a silent one is explained in the log instead of vanishing.
75
+ if (!this.file) this.logger?.warn?.('collab: memory sources: no votes.jsonl path (set BOTHOUSE_DATA_DIR, GATE_SETUP_DIR or CC_DATA_DIR, or MEMVOTE_DIR); replies carry no memory sources');
76
+ else if (existsSync(this.file)) this.logger?.info?.('collab: memory sources: reading %s', this.file);
77
+ else this.logger?.warn?.('collab: memory sources: %s does not exist yet (wrong data dir, or the gate has not recorded a cite); replies carry no memory sources until it appears', this.file);
78
+ // Only citations made after this process started belong to its replies.
79
+ this.offset = this.size();
80
+ this.rest = '';
81
+ /** `${voter}\0${room}` -> ordered unique entry ids cited and not yet shown. */
82
+ this.pending = new Map();
83
+ this.titles = new Map();
84
+ }
85
+
86
+ size() {
87
+ if (!this.file) return 0;
88
+ try { return statSync(this.file).size; } catch { return 0; }
89
+ }
90
+
91
+ /** Read the lines appended since the last call. */
92
+ poll() {
93
+ if (!this.file) return;
94
+ const size = this.size();
95
+ if (size < this.offset) { this.offset = 0; this.rest = ''; } // rotated or truncated
96
+ if (size === this.offset) return;
97
+ let fd;
98
+ try {
99
+ fd = openSync(this.file, 'r');
100
+ const buf = Buffer.alloc(size - this.offset);
101
+ readSync(fd, buf, 0, buf.length, this.offset);
102
+ this.offset = size;
103
+ const lines = (this.rest + buf.toString('utf8')).split('\n');
104
+ this.rest = lines.pop() ?? '';
105
+ for (const line of lines) this.take1(line);
106
+ } catch (error) {
107
+ this.logger?.warn?.('collab: memory sources: %s', error?.message ?? error);
108
+ } finally {
109
+ if (fd !== undefined) closeSync(fd);
110
+ }
111
+ }
112
+
113
+ take1(line) {
114
+ let r;
115
+ try { r = JSON.parse(line); } catch { return; }
116
+ // by="self" vote=1: the agent says it used this entry (memvote.py "+编号"); "-" (entry wrong) and "x" are not sources.
117
+ if (r?.by !== 'self' || r.vote !== 1 || typeof r.id !== 'string' || !r.id.includes('#')) return;
118
+ const room = keyRoom(r.key);
119
+ if (room === undefined || !r.voter) return;
120
+ const k = `${r.voter}\u0000${room}`;
121
+ const list = this.pending.get(k) ?? [];
122
+ if (!list.includes(r.id)) list.push(r.id);
123
+ this.pending.set(k, list);
124
+ }
125
+
126
+ /**
127
+ * The entries `voter` cited in `room` since its previous reply there, as [{ id, ref, page, title }]
128
+ * (ref = the short 编号 shown in the push), and forget them. [] when it cited none.
129
+ */
130
+ take(voter, room) {
131
+ this.poll();
132
+ const k = `${voter}\u0000${room}`;
133
+ const ids = this.pending.get(k);
134
+ if (!ids?.length) return [];
135
+ this.pending.delete(k);
136
+ return ids.slice(0, MAX_ITEMS).map(id => {
137
+ const [page, ref] = [id.slice(0, id.lastIndexOf('#')), id.slice(id.lastIndexOf('#') + 1)];
138
+ return { id, ref, page, title: this.title(page, ref) };
139
+ });
140
+ }
141
+
142
+ /** The wiki line an entry id points at (memvote.full_id: sha1 of the stripped line, first 10 hex), trimmed. */
143
+ title(page, ref) {
144
+ const key = `${page}#${ref}`;
145
+ if (this.titles.has(key)) return this.titles.get(key);
146
+ let out = '';
147
+ const path = page.startsWith('~/') ? join(this.home, page.slice(2)) : join(this.wiki, page);
148
+ try {
149
+ for (const raw of readFileSync(path, 'utf8').split('\n')) {
150
+ if (createHash('sha1').update(raw.trim()).digest('hex').slice(0, 10) !== ref) continue;
151
+ out = raw.trim().replace(/^[-*]\s+/, '').replace(/\s+/g, ' ');
152
+ break;
153
+ }
154
+ } catch { /* page moved or gone: the badge still shows its path */ }
155
+ if (out.length > TITLE_CHARS) out = out.slice(0, TITLE_CHARS - 1) + '…';
156
+ this.titles.set(key, out);
157
+ return out;
158
+ }
159
+ }
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;
@@ -493,6 +495,110 @@
493
495
  .composer { padding-bottom: var(--sp-2); }
494
496
  #log { padding-top: var(--sp-2); }
495
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
+ }
496
602
  </style>
497
603
  </head>
498
604
  <body>
@@ -628,13 +734,270 @@ function renderMembers() {
628
734
  $('thinking').textContent = names.length ? `${names.join('、')} 正在处理…` : '';
629
735
  }
630
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
+
631
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);
632
985
  const system = m.author_kind === 'system';
633
986
  const row = el('div', 'msg' + (system ? ' system' : ''));
634
987
  row.dataset.id = m.id;
635
988
  row.dataset.author = m.author;
636
989
  row.dataset.ts = m.created_at;
637
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
+ }
638
1001
  const member = state.members.find(x => x.id === m.author);
639
1002
  row.append(avatar(m.author, m.name));
640
1003
  const col = el('div');
@@ -643,6 +1006,7 @@ function messageNode(m) {
643
1006
  if (member && kindLabel[member.kind]) who.append(el('span', 'tag', kindLabel[member.kind]));
644
1007
  head.append(who, el('span', 'when', fmtTime(m.created_at)));
645
1008
  col.append(head);
1009
+ if (m.reply_to) col.append(replyQuote(m.reply_to));
646
1010
  if (m.card) {
647
1011
  let meta = {}; try { meta = JSON.parse(m.card); } catch {}
648
1012
  const card = el('div', 'card');
@@ -651,7 +1015,7 @@ function messageNode(m) {
651
1015
  titleRow.append(el('span', 'title', meta.title || ''));
652
1016
  const label = cardStateLabel[meta.state];
653
1017
  if (label) titleRow.append(el('span', 'chip', label));
654
- card.append(titleRow, el('div', 'body', m.text));
1018
+ card.append(titleRow, isProgressMeta(meta) ? progressBody(m.text, meta.state) : renderBody(m.text, 'body'));
655
1019
  // Pending permission card: one button per action, posting the decision (same route as a text reply).
656
1020
  if (meta.kind === 'permission' && meta.state === 'pending' && Array.isArray(meta.actions)) {
657
1021
  const bar = el('div', 'actions');
@@ -663,7 +1027,8 @@ function messageNode(m) {
663
1027
  card.append(bar);
664
1028
  }
665
1029
  col.append(card);
666
- } 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));
667
1032
  row.append(col);
668
1033
  // 自己发的普通消息:悬停出现「撤回」。撤回后后端广播 deleted,这一行随之消失;
669
1034
  // 这条消息触发的回答也会一起停下(后端 recall 会取消对应 turn)。
@@ -703,18 +1068,71 @@ function regroup() {
703
1068
  }
704
1069
  }
705
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
+
706
1100
  function upsert(m) {
707
1101
  if (m.room_id !== state.room) return;
708
1102
  const log = $('log');
709
- 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;
710
1106
  const existing = log.querySelector(`[data-id="${m.id}"]`);
711
- 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
+ }
712
1130
  else {
713
1131
  const after = [...log.children].find(n => n.dataset.id != null && Number(n.dataset.id) > m.id);
714
1132
  log.insertBefore(messageNode(m), after || null);
715
1133
  }
716
1134
  regroup();
717
- if (stick) log.scrollTop = log.scrollHeight;
1135
+ followBottom(log, stick);
718
1136
  }
719
1137
 
720
1138
  async function api(path, init) {
@@ -752,6 +1170,7 @@ async function openRoom(id) {
752
1170
  if (!log.querySelector('.msg')) log.append(showState('empty', '还没有消息', ''));
753
1171
  $('log').setAttribute('aria-busy', 'false');
754
1172
  $('input').disabled = false;
1173
+ stuck = true;
755
1174
  $('log').scrollTop = $('log').scrollHeight;
756
1175
  } catch (err) {
757
1176
  if (state.room !== id) return;