agent-loft 0.2.31 → 0.2.33

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.
Files changed (48) hide show
  1. package/README.md +14 -5
  2. package/package.json +1 -1
  3. package/src/agents/claude/driver.ts +90 -8
  4. package/src/agents/claude/session.ts +81 -12
  5. package/src/agents/codex/session.ts +70 -13
  6. package/src/channels/feishu/bot.ts +2 -2
  7. package/src/channels/web/api.ts +72 -2
  8. package/src/channels/web/conv-pages.ts +68 -0
  9. package/src/channels/web/http.ts +2 -2
  10. package/src/channels/web/server.ts +20 -3
  11. package/src/channels/web/skills-api.ts +225 -0
  12. package/src/core/chat-mode.ts +3 -3
  13. package/src/core/command-catalog.ts +8 -1
  14. package/src/core/commands.ts +3 -0
  15. package/src/core/hub.ts +168 -18
  16. package/src/core/personal.ts +3 -1
  17. package/src/core/skill-runtime.ts +359 -0
  18. package/src/core/skills-bridge.ts +288 -0
  19. package/src/core/skills.ts +676 -0
  20. package/src/core/tasks-bridge.ts +5 -1
  21. package/src/core/tasks-mcp.ts +11 -0
  22. package/src/core/types.ts +44 -3
  23. package/src/core/usage.ts +70 -0
  24. package/src/core/zip.ts +98 -0
  25. package/src/main.ts +4 -0
  26. package/src/mcp/tasks-server.ts +125 -4
  27. package/web/css/composer.css +0 -25
  28. package/web/css/messages.css +169 -0
  29. package/web/css/sidebar.css +5 -5
  30. package/web/css/skills.css +385 -0
  31. package/web/css/topbar.css +131 -0
  32. package/web/index.html +1 -0
  33. package/web/lib/dom.js +3 -1
  34. package/web/lib/markdown.js +84 -14
  35. package/web/lib/ws.js +49 -5
  36. package/web/settings/index.js +7 -3
  37. package/web/settings/skills.js +617 -0
  38. package/web/state.js +4 -3
  39. package/web/views/composer.js +16 -3
  40. package/web/views/interaction.js +12 -4
  41. package/web/views/layout.js +22 -15
  42. package/web/views/mention.js +193 -0
  43. package/web/views/messages.js +239 -81
  44. package/web/views/msg-nav.js +120 -0
  45. package/web/views/quota.js +1 -0
  46. package/web/views/sidebar.js +114 -16
  47. package/web/views/tasks.js +11 -2
  48. package/web/views/topbar.js +168 -40
@@ -324,13 +324,13 @@ button.conv-group:hover {
324
324
  min-height: 48px;
325
325
  }
326
326
 
327
- .side-gear {
328
- margin-left: auto;
327
+ /* 没有额度可显示:连上面的分隔线一起藏起来 */
328
+ .side-bottom[hidden] {
329
+ display: none;
329
330
  }
330
331
 
331
- /* 桌面端里:设置在顶栏「更多」里,侧栏底部只留额度(额度由桌面端统一查好推过来) */
332
- .desktop-settings .side-gear {
333
- display: none;
332
+ .conv-item .chat-mark {
333
+ color: var(--fg-soft);
334
334
  }
335
335
 
336
336
  /* ---- 侧栏底部的额度 ---- */
@@ -0,0 +1,385 @@
1
+ /* ---------- 技能(设置里的一页,卡片沿用定时任务页的 .task-card) ---------- */
2
+
3
+ .skill-head-btns {
4
+ display: flex;
5
+ gap: 8px;
6
+ flex-shrink: 0;
7
+ }
8
+
9
+ .skill-tabs {
10
+ display: flex;
11
+ align-items: center;
12
+ gap: 12px;
13
+ flex-wrap: wrap;
14
+ }
15
+
16
+ .skill-tabs .seg {
17
+ display: inline-flex;
18
+ padding: 3px;
19
+ border-radius: 10px;
20
+ background: var(--bg-soft);
21
+ }
22
+
23
+ .skill-tabs .seg button {
24
+ padding: 6px 12px;
25
+ border-radius: 8px;
26
+ font-size: 13px;
27
+ color: var(--fg-soft);
28
+ white-space: nowrap;
29
+ }
30
+
31
+ .skill-tabs .seg button.on {
32
+ background: var(--bg);
33
+ color: var(--fg);
34
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
35
+ }
36
+
37
+ .skill-search {
38
+ flex: 1;
39
+ min-width: 160px;
40
+ padding: 7px 12px;
41
+ border: 1px solid var(--border);
42
+ border-radius: 10px;
43
+ background: var(--bg-input);
44
+ color: var(--fg);
45
+ font: inherit;
46
+ font-size: 13px;
47
+ }
48
+
49
+ .skill-list-box {
50
+ display: flex;
51
+ flex-direction: column;
52
+ gap: 12px;
53
+ }
54
+
55
+ .skill-card .skill-name {
56
+ font-family: var(--mono);
57
+ font-size: 15px;
58
+ }
59
+
60
+ .skill-card .btn {
61
+ padding: 5px 12px;
62
+ font-size: 13px;
63
+ border-radius: 8px;
64
+ }
65
+
66
+ .skill-warn {
67
+ color: var(--danger);
68
+ }
69
+
70
+ .skill-note {
71
+ margin: 0;
72
+ font-size: 13px;
73
+ line-height: 1.6;
74
+ color: var(--fg-soft);
75
+ }
76
+
77
+ .skill-empty {
78
+ padding: 32px 0;
79
+ text-align: center;
80
+ font-size: 13px;
81
+ color: var(--fg-faint);
82
+ }
83
+
84
+ .skills-page .badge,
85
+ .skill-detail .badge {
86
+ font-size: 11px;
87
+ padding: 1px 6px;
88
+ border-radius: 6px;
89
+ background: var(--bg-soft);
90
+ color: var(--fg-soft);
91
+ white-space: nowrap;
92
+ }
93
+
94
+ .skill-detail .badge.ok {
95
+ color: var(--ok);
96
+ }
97
+
98
+ /* 查看 */
99
+ .skill-detail {
100
+ display: flex;
101
+ flex-direction: column;
102
+ gap: 14px;
103
+ }
104
+
105
+ .skill-desc {
106
+ margin: 0;
107
+ font-size: 14px;
108
+ line-height: 1.6;
109
+ }
110
+
111
+ .skill-section h4 {
112
+ margin: 0 0 8px;
113
+ font-size: 13px;
114
+ font-weight: 600;
115
+ color: var(--fg-soft);
116
+ }
117
+
118
+ .skill-md {
119
+ padding: 12px 14px;
120
+ border: 1px solid var(--border);
121
+ border-radius: 10px;
122
+ font-size: 14px;
123
+ max-height: 50vh;
124
+ overflow: auto;
125
+ }
126
+
127
+ .skill-vars {
128
+ margin: 0 0 8px;
129
+ padding: 0;
130
+ list-style: none;
131
+ display: flex;
132
+ flex-direction: column;
133
+ gap: 6px;
134
+ font-size: 13px;
135
+ }
136
+
137
+ .skill-vars li {
138
+ display: flex;
139
+ flex-wrap: wrap;
140
+ align-items: center;
141
+ gap: 6px;
142
+ }
143
+
144
+ .skill-var-desc {
145
+ color: var(--fg-soft);
146
+ }
147
+
148
+ .skill-files {
149
+ display: flex;
150
+ flex-direction: column;
151
+ gap: 2px;
152
+ }
153
+
154
+ .skill-file {
155
+ display: flex;
156
+ justify-content: space-between;
157
+ gap: 12px;
158
+ padding: 6px 10px;
159
+ border-radius: 8px;
160
+ font-size: 13px;
161
+ text-align: left;
162
+ }
163
+
164
+ .skill-file:hover {
165
+ background: var(--bg-hover);
166
+ }
167
+
168
+ .skill-file span {
169
+ color: var(--fg-faint);
170
+ flex-shrink: 0;
171
+ }
172
+
173
+ .skill-file-preview:empty {
174
+ display: none;
175
+ }
176
+
177
+ .skill-file-name {
178
+ margin: 10px 0 6px;
179
+ font-family: var(--mono);
180
+ font-size: 12px;
181
+ color: var(--fg-soft);
182
+ }
183
+
184
+ .skill-file-preview pre,
185
+ .skill-raw {
186
+ margin: 0;
187
+ padding: 10px 12px;
188
+ border-radius: 10px;
189
+ background: var(--code-bg);
190
+ font-family: var(--mono);
191
+ font-size: 12px;
192
+ line-height: 1.5;
193
+ white-space: pre-wrap;
194
+ word-break: break-word;
195
+ max-height: 50vh;
196
+ overflow: auto;
197
+ }
198
+
199
+ /* 编辑 */
200
+ .skill-body-input {
201
+ font-family: var(--mono) !important;
202
+ font-size: 13px !important;
203
+ }
204
+
205
+ .skill-var-edit {
206
+ display: flex;
207
+ flex-direction: column;
208
+ gap: 6px;
209
+ }
210
+
211
+ .skill-var-edit:empty {
212
+ display: none;
213
+ }
214
+
215
+ .skill-var-item {
216
+ display: flex;
217
+ flex-direction: column;
218
+ gap: 4px;
219
+ }
220
+
221
+ .skill-var-hosts[hidden] {
222
+ display: none;
223
+ }
224
+
225
+ .skill-var-hosts {
226
+ display: flex;
227
+ align-items: center;
228
+ gap: 8px;
229
+ }
230
+
231
+ .skill-var-hosts span {
232
+ flex-shrink: 0;
233
+ font-size: 13px;
234
+ color: var(--fg-soft);
235
+ }
236
+
237
+ .skill-var-hosts input {
238
+ flex: 1;
239
+ min-width: 0;
240
+ font-family: var(--mono);
241
+ font-size: 13px;
242
+ }
243
+
244
+ .skill-guard {
245
+ color: var(--ok) !important;
246
+ }
247
+
248
+ .skill-pending {
249
+ display: flex;
250
+ align-items: center;
251
+ gap: 8px;
252
+ padding: 8px 10px;
253
+ border-radius: 8px;
254
+ border: 1px solid var(--warn-border);
255
+ background: var(--warn-bg);
256
+ font-size: 13px;
257
+ }
258
+
259
+ .skill-pending .btn {
260
+ flex-shrink: 0;
261
+ }
262
+
263
+ .skill-var-line {
264
+ display: flex;
265
+ align-items: center;
266
+ gap: 6px;
267
+ }
268
+
269
+ .skill-var-line input:not([type="checkbox"]) {
270
+ flex: 1;
271
+ min-width: 0;
272
+ }
273
+
274
+ .skill-var-line input:first-child {
275
+ font-family: var(--mono);
276
+ flex: 0 1 220px;
277
+ }
278
+
279
+ .skill-var-line label {
280
+ display: inline-flex;
281
+ align-items: center;
282
+ gap: 3px;
283
+ font-size: 13px;
284
+ white-space: nowrap;
285
+ color: var(--fg-soft);
286
+ }
287
+
288
+ .skill-var-edit + .btn,
289
+ .skill-var-row .btn,
290
+ .skill-upload-btns .btn {
291
+ align-self: flex-start;
292
+ }
293
+
294
+ .task-form .btn.ghost,
295
+ .skill-var-row .btn.ghost {
296
+ border-color: transparent;
297
+ background: var(--bg-soft);
298
+ }
299
+
300
+ .skill-var-row {
301
+ display: flex;
302
+ gap: 6px;
303
+ }
304
+
305
+ .skill-var-row input {
306
+ flex: 1;
307
+ min-width: 0;
308
+ font-family: var(--mono);
309
+ }
310
+
311
+ .skill-visibility {
312
+ display: flex;
313
+ flex-direction: column;
314
+ gap: 8px;
315
+ }
316
+
317
+ .skill-people {
318
+ display: flex;
319
+ flex-wrap: wrap;
320
+ gap: 6px 14px;
321
+ }
322
+
323
+ .skill-people[hidden] {
324
+ display: none;
325
+ }
326
+
327
+ .skill-person {
328
+ display: inline-flex;
329
+ align-items: center;
330
+ gap: 5px;
331
+ font-size: 14px;
332
+ }
333
+
334
+ .skill-upload-btns {
335
+ display: flex;
336
+ gap: 8px;
337
+ margin-top: 14px;
338
+ }
339
+
340
+ /* @ 补全:在弹窗里的输入框下面弹出 */
341
+ .mention-wrap {
342
+ position: relative;
343
+ }
344
+
345
+ .slash-menu.below {
346
+ top: calc(100% + 4px);
347
+ bottom: auto;
348
+ max-height: 240px;
349
+ }
350
+
351
+ @media (max-width: 640px) {
352
+ .skills-page .tasks-head {
353
+ flex-direction: column;
354
+ gap: 12px;
355
+ }
356
+
357
+ .skill-head-btns .btn {
358
+ padding: 7px 10px;
359
+ }
360
+
361
+ .skill-var-line {
362
+ flex-wrap: wrap;
363
+ }
364
+
365
+ .skill-var-line input:first-child {
366
+ flex: 1 1 100%;
367
+ }
368
+ }
369
+
370
+ /* 设置里的技能页 */
371
+ .skills-page {
372
+ display: flex;
373
+ flex-direction: column;
374
+ gap: 14px;
375
+ }
376
+
377
+ .skills-head {
378
+ display: flex;
379
+ align-items: flex-start;
380
+ gap: 16px;
381
+ }
382
+
383
+ .skills-head .skill-note {
384
+ flex: 1;
385
+ }
@@ -155,6 +155,18 @@
155
155
  gap: 6px;
156
156
  }
157
157
 
158
+ .top-title .conv-mode-icon {
159
+ display: inline-grid;
160
+ flex-shrink: 0;
161
+ margin-left: 4px;
162
+ color: var(--fg-soft);
163
+ }
164
+
165
+ .top-title .conv-mode-icon svg {
166
+ width: 16px;
167
+ height: 16px;
168
+ }
169
+
158
170
  .top-title .conv-name {
159
171
  font-size: 15px;
160
172
  font-weight: 500;
@@ -203,3 +215,122 @@
203
215
  .in-desktop .side-top :is(button, a, input, select, textarea, label) {
204
216
  -webkit-app-region: no-drag;
205
217
  }
218
+
219
+ /* ---- 右上角「更多」:菜单项 + 当前会话的 token 用量 ---- */
220
+ .top-menu {
221
+ min-width: 248px;
222
+ }
223
+
224
+ .top-menu .pop-item svg {
225
+ width: 16px;
226
+ height: 16px;
227
+ color: var(--fg-soft);
228
+ }
229
+
230
+ /* 分布的四种颜色:固定顺序(输入、缓存写入、缓存读取、输出),深色模式用为深色底挑的那一档 */
231
+ .usage {
232
+ --usage-input: #2a78d6;
233
+ --usage-cwrite: #eb6834;
234
+ --usage-cread: #1baf7a;
235
+ --usage-output: #eda100;
236
+ padding: 6px 12px 8px;
237
+ font-size: 12px;
238
+ color: var(--fg-soft);
239
+ font-variant-numeric: tabular-nums;
240
+ }
241
+
242
+ @media (prefers-color-scheme: dark) {
243
+ .usage {
244
+ --usage-input: #3987e5;
245
+ --usage-cwrite: #d95926;
246
+ --usage-cread: #199e70;
247
+ --usage-output: #c98500;
248
+ }
249
+ }
250
+
251
+ .usage .u-input {
252
+ background: var(--usage-input);
253
+ }
254
+
255
+ .usage .u-cwrite {
256
+ background: var(--usage-cwrite);
257
+ }
258
+
259
+ .usage .u-cread {
260
+ background: var(--usage-cread);
261
+ }
262
+
263
+ .usage .u-output {
264
+ background: var(--usage-output);
265
+ }
266
+
267
+ .usage-empty {
268
+ color: var(--fg-faint);
269
+ }
270
+
271
+ .usage-head {
272
+ display: flex;
273
+ justify-content: space-between;
274
+ align-items: baseline;
275
+ margin-bottom: 6px;
276
+ }
277
+
278
+ .usage-head b {
279
+ font-size: 14px;
280
+ font-weight: 600;
281
+ color: var(--fg);
282
+ }
283
+
284
+ /* 段与段之间留 2px 底色的缝,两端圆角;很小的一段也至少看得见 */
285
+ .usage-bar {
286
+ display: flex;
287
+ gap: 2px;
288
+ height: 8px;
289
+ border-radius: 4px;
290
+ overflow: hidden;
291
+ margin-bottom: 8px;
292
+ }
293
+
294
+ .usage-bar i {
295
+ flex: 1 1 0;
296
+ min-width: 3px;
297
+ }
298
+
299
+ .usage-row {
300
+ display: grid;
301
+ grid-template-columns: 8px 1fr auto 3.2em;
302
+ align-items: center;
303
+ gap: 8px;
304
+ padding: 2px 0;
305
+ }
306
+
307
+ .usage-dot {
308
+ width: 8px;
309
+ height: 8px;
310
+ border-radius: 2px;
311
+ }
312
+
313
+ .usage-name {
314
+ color: var(--fg);
315
+ min-width: 0;
316
+ }
317
+
318
+ .usage-name small {
319
+ margin-left: 6px;
320
+ color: var(--fg-faint);
321
+ font-size: 11px;
322
+ }
323
+
324
+ .usage-num {
325
+ color: var(--fg);
326
+ }
327
+
328
+ .usage-pct {
329
+ text-align: right;
330
+ color: var(--fg-faint);
331
+ }
332
+
333
+ .usage-foot {
334
+ margin-top: 6px;
335
+ color: var(--fg-faint);
336
+ }
package/web/index.html CHANGED
@@ -25,6 +25,7 @@
25
25
  <link rel="stylesheet" href="/css/settings.css" />
26
26
  <link rel="stylesheet" href="/css/schedule.css" />
27
27
  <link rel="stylesheet" href="/css/tasks.css" />
28
+ <link rel="stylesheet" href="/css/skills.css" />
28
29
  <link rel="stylesheet" href="/css/mobile.css" />
29
30
  </head>
30
31
  <body>
package/web/lib/dom.js CHANGED
@@ -34,10 +34,12 @@ export const ICON = {
34
34
  clip: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.4 11.6-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5"/></svg>',
35
35
  mic: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="2" width="6" height="12" rx="3"/><path d="M5 10a7 7 0 0 0 14 0M12 17v5"/></svg>',
36
36
  more: '<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>',
37
+ spark: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9Z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8Z"/></svg>',
37
38
  clock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
38
39
  down: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="m6 9 6 6 6-6"/></svg>',
39
40
  gear: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/></svg>',
40
41
  fork: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="5" r="2"/><circle cx="6" cy="19" r="2"/><circle cx="18" cy="7" r="2"/><path d="M6 7v10"/><path d="M18 9c0 5-6 4-11.2 8.6"/></svg>',
42
+ chat: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5a8.4 8.4 0 0 1-12.3 7.5L3 20.5l1.6-5A8.4 8.4 0 1 1 21 11.5Z"/></svg>',
41
43
  copy: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
42
44
  x: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>',
43
45
  plus: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
@@ -68,7 +70,7 @@ ICON.codex = ICON.logo;
68
70
  /**
69
71
  * 星星加载动画(回复末尾、打开会话时用)。开屏那个带台面的完整 logo 动画直接写在 index.html 里。
70
72
  * 每个实例的渐变 id 唯一,避免页面里有隐藏的同 id 渐变时画不出来。
71
- * 回复流式输出时整条消息会反复重建,动画按全局时钟设负的起始延迟,重建后接着播、不会一抖一抖地重来。
73
+ * 动画按全局时钟设负的起始延迟:回复被授权卡片分成几段时,新一段的动画接着播、不会一抖一抖地重来。
72
74
  */
73
75
  let loaderSeq = 0;
74
76
 
@@ -8,29 +8,99 @@ import { h, toast } from "./dom.js";
8
8
  marked.setOptions({ gfm: true, breaks: true });
9
9
 
10
10
  export function renderMarkdown(text) {
11
- const html = DOMPurify.sanitize(marked.parse(text ?? ""), { ADD_ATTR: ["target"] });
12
- const div = h("div", { class: "md", html });
13
- for (const a of div.querySelectorAll("a")) {
11
+ const div = h("div", { class: "md" });
12
+ patchMarkdown(div, text);
13
+ return div;
14
+ }
15
+
16
+ /**
17
+ * 把 div 里的 markdown 更新成 text,只重画变了的块(段落、列表、代码块…)。
18
+ * 流式输出时前面的块不再变,每帧只需重画最后一两块;选中的文字、代码块的横向滚动也不会被冲掉。
19
+ * live = 还在输出:最后一块随时会变,先不高亮,等它定下来再高亮。
20
+ * 按块拼起来和整段解析的结果一样(marked 的 parser 本来就是逐块拼接的)。
21
+ */
22
+ export function patchMarkdown(div, text, { live = false } = {}) {
23
+ const tokens = marked.lexer(text ?? "");
24
+ const links = JSON.stringify(tokens.links ?? {});
25
+ // 引用式链接的定义可能在后面才出现(流式输出时地址还会一点点变长),会影响前面的块:定义变了就整个重画
26
+ if (div._links !== links) {
27
+ div._links = links;
28
+ div._blocks = [];
29
+ div.replaceChildren();
30
+ }
31
+ // HTML 块(<details>、<div align> 之类)的开闭标签常常不在同一块里,分开解析会被浏览器提前补上结束标签:
32
+ // 从第一个 HTML 块起到结尾合成一块
33
+ const cut = tokens.findIndex((t) => t.type === "html");
34
+ const chunks = cut < 0 ? tokens.map((t) => [t]) : [...tokens.slice(0, cut).map((t) => [t]), tokens.slice(cut)];
35
+ const raws = chunks.map((c) => c.map((t) => t.raw).join(""));
36
+ const blocks = (div._blocks ??= []);
37
+ const last = chunks.length - 1;
38
+ let i = 0;
39
+ // 还在输出时画的块没高亮,只有它仍是正在输出的最后一块才能接着用
40
+ while (i < blocks.length && i <= last && blocks[i].raw === raws[i] && (blocks[i].final || (live && i === last))) i++;
41
+ for (const b of blocks.splice(i)) {
42
+ for (const n of b.nodes) {
43
+ if (n.nodeType === Node.ELEMENT_NODE) for (const code of n.querySelectorAll("pre code")) lazyHighlight.unobserve(code);
44
+ n.remove();
45
+ }
46
+ }
47
+ for (; i <= last; i++) {
48
+ const final = !(live && i === last);
49
+ const chunk = chunks[i];
50
+ chunk.links = tokens.links;
51
+ // 在本页的元素里解析(DOMPurify 直接返回的节点属于另一个 document,搬过来后 IntersectionObserver 就不灵了)
52
+ const box = document.createElement("div");
53
+ box.innerHTML = DOMPurify.sanitize(marked.parser(chunk), { ADD_ATTR: ["target"] });
54
+ decorate(box, final);
55
+ const nodes = [...box.childNodes];
56
+ div.append(...nodes);
57
+ blocks.push({ raw: raws[i], final, nodes });
58
+ }
59
+ }
60
+
61
+ /** 链接新窗口打开;代码块加上语言 + 复制按钮的头,滚到附近了再高亮 */
62
+ function decorate(root, highlight) {
63
+ for (const a of root.querySelectorAll("a")) {
14
64
  a.target = "_blank";
15
65
  a.rel = "noopener noreferrer";
16
66
  }
17
- for (const pre of div.querySelectorAll("pre")) {
67
+ for (const pre of root.querySelectorAll("pre")) {
18
68
  const code = pre.querySelector("code");
19
69
  if (!code) continue;
20
70
  const lang = /language-([\w+-]+)/.exec(code.className)?.[1] ?? "";
21
- try {
22
- if (lang && hljs.getLanguage(lang)) hljs.highlightElement(code);
23
- else if (code.textContent.length < 20000) {
24
- code.innerHTML = hljs.highlightAuto(code.textContent).value;
25
- code.classList.add("hljs");
26
- }
27
- } catch {
28
- // 高亮失败就原样显示
29
- }
30
71
  const copy = h("button", { class: "copy-btn", onclick: () => copyText(code.textContent, copy) }, "复制");
31
72
  pre.prepend(h("div", { class: "code-head" }, h("span", {}, lang || "code"), copy));
73
+ if (highlight) lazyHighlight.observe(code);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 高亮(尤其是自动识别语言)很慢:长会话一打开几十个代码块会卡住。
79
+ * 只在代码块快滚进视口时才高亮;高亮不改变文字和行高,不会让内容跳。
80
+ */
81
+ const lazyHighlight = new IntersectionObserver(
82
+ (entries) => {
83
+ for (const e of entries) {
84
+ if (!e.isIntersecting) continue;
85
+ lazyHighlight.unobserve(e.target);
86
+ highlight(e.target);
87
+ }
88
+ },
89
+ // 代码块在消息区这种滚动容器里:rootMargin 只放大视口,scrollMargin 才放大滚动容器的裁剪范围(不支持的浏览器忽略它)
90
+ { rootMargin: "800px 0px", scrollMargin: "800px 0px" },
91
+ );
92
+
93
+ function highlight(code) {
94
+ const lang = /language-([\w+-]+)/.exec(code.className)?.[1] ?? "";
95
+ try {
96
+ if (lang && hljs.getLanguage(lang)) hljs.highlightElement(code);
97
+ else if (code.textContent.length < 20000) {
98
+ code.innerHTML = hljs.highlightAuto(code.textContent).value;
99
+ code.classList.add("hljs");
100
+ }
101
+ } catch {
102
+ // 高亮失败就原样显示
32
103
  }
33
- return div;
34
104
  }
35
105
 
36
106
  export async function copyText(text, btn) {