@walkhi/code-relax 0.1.0-beta.1 → 0.1.0-beta.2

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 (62) hide show
  1. package/README.md +5 -5
  2. package/dist/bin/self-relay-server.mjs +5 -2
  3. package/dist/shared/app-server-events.cjs +45 -19
  4. package/dist/shared/p2p-data-channel.cjs +50 -0
  5. package/dist/src/app-server-tasks.mjs +28 -25
  6. package/dist/src/lan-socket-server.mjs +8 -2
  7. package/dist/src/platform/windows/IsolatedProcess.cs +7 -3
  8. package/dist/src/platform/windows/background-process.mjs +7 -2
  9. package/dist/src/platform/windows/launch-worker.ps1 +13 -6
  10. package/dist/src/platform/windows/start-hidden-console.ps1 +11 -6
  11. package/dist/src/self-relay/admin-state.mjs +61 -0
  12. package/dist/src/self-relay/client.mjs +2 -0
  13. package/dist/src/self-relay/connector.mjs +23 -8
  14. package/dist/src/self-relay/demo.mjs +2 -2
  15. package/dist/src/self-relay/lifecycle.mjs +1 -1
  16. package/dist/src/self-relay/p2p-probe.mjs +123 -83
  17. package/dist/src/self-relay/server.mjs +137 -11
  18. package/dist/src/server.mjs +337 -255
  19. package/dist/src/shared-app-server.mjs +180 -24
  20. package/dist/src/shared-catalog.mjs +4 -14
  21. package/dist/src/thread-catalog.mjs +12 -7
  22. package/dist/web/activity-view.js +283 -0
  23. package/dist/web/capabilities.js +2 -2
  24. package/dist/web/chat-transport.js +15 -9
  25. package/dist/web/chat.css +139 -93
  26. package/dist/web/chat.js +1451 -2770
  27. package/dist/web/community-view.js +20 -0
  28. package/dist/web/community.css +77 -0
  29. package/dist/web/community.html +37 -0
  30. package/dist/web/composer-controller.js +101 -0
  31. package/dist/web/conversation-controller.js +99 -0
  32. package/dist/web/disclosure-state-controller.js +95 -0
  33. package/dist/web/draft-controller.js +103 -0
  34. package/dist/web/harmony-platform.js +3 -2
  35. package/dist/web/history-cache.js +112 -18
  36. package/dist/web/history-controller.js +167 -0
  37. package/dist/web/index.html +141 -38
  38. package/dist/web/link-action-controller.js +212 -0
  39. package/dist/web/message-send-controller.js +177 -0
  40. package/dist/web/message-view.js +98 -0
  41. package/dist/web/p2p-data-channel.js +50 -0
  42. package/dist/web/p2p-probe.js +67 -27
  43. package/dist/web/page-resume.js +28 -0
  44. package/dist/web/pending-message-store.js +108 -0
  45. package/dist/web/queue-controller.js +82 -0
  46. package/dist/web/resources.json +1 -1
  47. package/dist/web/self-relay-session.js +28 -18
  48. package/dist/web/station-connection-controller.js +75 -0
  49. package/dist/web/task-list-view.js +296 -0
  50. package/dist/web/thread-attention-controller.js +124 -0
  51. package/dist/web/thread-context-controller.js +30 -0
  52. package/dist/web/thread-list-controller.js +61 -0
  53. package/dist/web/thread-list-sync.js +86 -0
  54. package/dist/web/thread-title-controller.js +57 -0
  55. package/dist/web/timeline-formatters.js +249 -0
  56. package/dist/web/timeline-reducer.js +81 -0
  57. package/dist/web/timeline-renderer.js +161 -0
  58. package/dist/web/timeline-scroll-controller.js +50 -0
  59. package/dist/web/usage-controller.js +258 -0
  60. package/dist/web/vendor/lucide.LICENSE.txt +17 -0
  61. package/package.json +1 -1
  62. package/tools/postinstall.mjs +66 -2
@@ -0,0 +1,57 @@
1
+ (function (root, factory) {
2
+ const create = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = create;
4
+ else root.createThreadTitleController = create;
5
+ })(globalThis, function () {
6
+ function createThreadTitleController() {
7
+ const provisional = new Map();
8
+
9
+ function normalize(value) {
10
+ return String(value || '').replace(/\s+/g, ' ').trim();
11
+ }
12
+
13
+ function abbreviate(value) {
14
+ const title = normalize(value);
15
+ const characters = Array.from(title);
16
+ return characters.length > 24 ? `${characters.slice(0, 24).join('')}…` : title;
17
+ }
18
+
19
+ function candidate(thread) {
20
+ const id = String(thread?.id || '');
21
+ return [thread?.name, thread?.title, thread?.preview]
22
+ .map(value => String(value || '').trim())
23
+ .find(value => value && value !== id && value !== '新对话') || '';
24
+ }
25
+
26
+ function preview(prompt) {
27
+ return abbreviate(prompt) || '图片';
28
+ }
29
+
30
+ function remember(threadId, prompt) {
31
+ provisional.set(threadId, {
32
+ source: normalize(prompt) || '图片',
33
+ title: preview(prompt),
34
+ });
35
+ }
36
+
37
+ function resolve(thread) {
38
+ const id = String(thread?.id || '');
39
+ const temporary = provisional.get(id);
40
+ const temporaryTitle = temporary?.title || '';
41
+ const serverTitle = candidate(thread);
42
+ if (serverTitle && serverTitle !== temporaryTitle && serverTitle !== temporary?.source) {
43
+ provisional.delete(id);
44
+ return abbreviate(serverTitle);
45
+ }
46
+ return temporaryTitle || abbreviate(serverTitle) || '新对话';
47
+ }
48
+
49
+ function shouldSync(thread) {
50
+ return Boolean(thread && (candidate(thread) || provisional.has(thread.id)));
51
+ }
52
+
53
+ return { normalize, preview, remember, resolve, shouldSync };
54
+ }
55
+
56
+ return createThreadTitleController;
57
+ });
@@ -0,0 +1,249 @@
1
+ (function (root, factory) {
2
+ const formatters = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = formatters;
4
+ else root.CodexTimelineFormatters = formatters;
5
+ })(globalThis, function () {
6
+ function statusText(value) {
7
+ if (typeof value === 'string') return value;
8
+ if (value && typeof value === 'object') return value.type || value.status || 'unknown';
9
+ return value == null ? 'unknown' : String(value);
10
+ }
11
+
12
+ function turnErrorMessage(error) {
13
+ if (typeof error === 'string') return error.trim();
14
+ return typeof error?.message === 'string' ? error.message.trim() : '';
15
+ }
16
+
17
+ function commandStatusText(status) {
18
+ status = statusText(status);
19
+ if (status === 'completed' || status === 'failed') return '已运行';
20
+ if (status === 'interrupted') return '已停止';
21
+ return '正在运行';
22
+ }
23
+
24
+ function toolStatusText(status) {
25
+ status = statusText(status);
26
+ if (status === 'completed' || status === 'failed') return '已调用';
27
+ return '正在调用';
28
+ }
29
+
30
+ function activityPreview(value) {
31
+ let preview = String(value || '').replace(/\s+/g, ' ').trim();
32
+ const powershell = preview.match(/^(?:"[^"]*\\)?(?:pwsh|powershell)(?:\.exe)?"?\s+-Command\s+([\s\S]+)$/i);
33
+ if (powershell) preview = powershell[1].trim();
34
+ if (preview.length >= 2 && preview[0] === preview.at(-1) && (preview[0] === '"' || preview[0] === "'")) {
35
+ preview = preview.slice(1, -1).trim();
36
+ }
37
+ return preview;
38
+ }
39
+
40
+ function diffLineCounts(text) {
41
+ let added = 0;
42
+ let removed = 0;
43
+ for (const line of String(text || '').split(/\r?\n/)) {
44
+ if (line.startsWith('+++') || line.startsWith('---')) continue;
45
+ if (line.startsWith('+')) added += 1;
46
+ else if (line.startsWith('-')) removed += 1;
47
+ }
48
+ return { added, removed };
49
+ }
50
+
51
+ function formatTime(value) {
52
+ if (!value) return '';
53
+ const date = typeof value === 'number'
54
+ ? new Date(value < 10_000_000_000 ? value * 1000 : value)
55
+ : new Date(value);
56
+ return Number.isNaN(date.getTime()) ? '' : date.toLocaleString();
57
+ }
58
+
59
+ function turnLabel(turn) {
60
+ const status = statusText(turn.status);
61
+ const time = formatTime(turn.startedAt);
62
+ return [time, status].filter(Boolean).join(' · ');
63
+ }
64
+
65
+ function timeMilliseconds(value) {
66
+ if (!value) return null;
67
+ const numeric = Number(value);
68
+ if (Number.isFinite(numeric)) return numeric < 10_000_000_000 ? numeric * 1000 : numeric;
69
+ const parsed = new Date(value).getTime();
70
+ return Number.isNaN(parsed) ? null : parsed;
71
+ }
72
+
73
+ function processDurationMs(element, now = Date.now()) {
74
+ const explicit = Number(element.dataset.durationMs);
75
+ if (Number.isFinite(explicit) && explicit >= 0 && element.dataset.durationMs !== '') return explicit;
76
+ const startedAt = timeMilliseconds(element.dataset.startedAt);
77
+ if (startedAt === null) return null;
78
+ const completedAt = timeMilliseconds(element.dataset.completedAt);
79
+ return Math.max(0, (completedAt ?? now) - startedAt);
80
+ }
81
+
82
+ function formatDuration(milliseconds) {
83
+ const totalSeconds = Math.max(0, Math.round(milliseconds / 1000));
84
+ const hours = Math.floor(totalSeconds / 3600);
85
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
86
+ const seconds = totalSeconds % 60;
87
+ const parts = [];
88
+ if (hours) parts.push(`${hours} 小时`);
89
+ if (minutes) parts.push(`${minutes} 分钟`);
90
+ if (seconds || !parts.length) parts.push(`${seconds} 秒`);
91
+ return parts.join(' ');
92
+ }
93
+
94
+ function webSearchQueryDetail(query, queries) {
95
+ const primary = String(query || '').trim();
96
+ const alternatives = Array.isArray(queries) ? queries : [];
97
+ const first = primary || alternatives.map(value => String(value || '').trim()).find(Boolean) || '';
98
+ if (!first) return '';
99
+ const domains = [];
100
+ const withoutSites = first.replace(/\bsite:([^\s]+)/giu, (match, domain) => {
101
+ try {
102
+ const hostname = new URL(`https://${domain}`).hostname.replace(/^www\./u, '');
103
+ if (!domains.includes(hostname)) domains.push(hostname);
104
+ return '';
105
+ } catch {
106
+ return match;
107
+ }
108
+ });
109
+ if (!domains.length) return alternatives.length > 1 && !primary ? `${first} ...` : first;
110
+ const text = withoutSites.replace(/\bOR\b/gu, ' ').replace(/\s+/gu, ' ').trim();
111
+ const detail = text ? `${text} | ${domains.join(' · ')}` : first;
112
+ return alternatives.length > 1 && !primary ? `${detail} ...` : detail;
113
+ }
114
+
115
+ function webSearchDetail(entry) {
116
+ const action = entry?.action;
117
+ if (action && typeof action === 'object') {
118
+ if (action.type === 'search') return webSearchQueryDetail(action.query, action.queries);
119
+ if (action.type === 'openPage') return String(action.url || '').trim();
120
+ if (action.type === 'findInPage') {
121
+ const pattern = String(action.pattern || '').trim();
122
+ const url = String(action.url || '').trim();
123
+ if (pattern && url) return `'${pattern}' in ${url}`;
124
+ if (pattern) return `'${pattern}'`;
125
+ if (url) return url;
126
+ return '';
127
+ }
128
+ if (action.type === 'other') return '';
129
+ }
130
+ return String(entry?.query || '').trim();
131
+ }
132
+
133
+ function webSearchTitle(entry, active = false) {
134
+ const detail = webSearchDetail(entry);
135
+ const label = active ? '正在搜索网页' : '已搜索网页';
136
+ return detail ? `${label}:${detail}` : label;
137
+ }
138
+
139
+ function isVisibleProcessActivity(entry) {
140
+ if (entry.type !== 'activity' || entry.kind === 'reasoning') return false;
141
+ if (entry.kind === 'webSearch' && !webSearchDetail(entry)) return false;
142
+ return true;
143
+ }
144
+
145
+ function operationSummary(entries) {
146
+ const files = entries
147
+ .filter(entry => entry.kind === 'fileChange' || entry.operationKind === 'fileChange')
148
+ .reduce((count, entry) => count + Math.max(1, entry.changes?.length || 0), 0);
149
+ const commands = entries.filter(entry => entry.kind === 'command' || entry.operationKind === 'command').length;
150
+ const tools = entries.filter(entry => entry.kind === 'tool' || entry.operationKind === 'tool').length;
151
+ const parts = [];
152
+ if (files) parts.push(`编辑了 ${files} 个文件`);
153
+ if (commands) parts.push(`运行了 ${commands} 条命令`);
154
+ if (tools) parts.push(`调用了 ${tools} 个工具`);
155
+ if (entries.some(entry => entry.kind === 'webSearch' && webSearchDetail(entry))) parts.push('搜索了网页');
156
+ if (entries.some(entry => entry.kind === 'imageView')) parts.push('查看了图像');
157
+ if (entries.some(entry => entry.kind === 'subAgent')) parts.push('处理了子任务');
158
+ if (entries.some(entry => entry.kind === 'collaboration')) parts.push('协调了任务');
159
+ const otherTitles = entries
160
+ .filter(isVisibleProcessActivity)
161
+ .map(entry => String(entry.title || '').trim())
162
+ .filter(Boolean);
163
+ return parts.length ? parts.join(' · ') : ([...new Set(otherTitles)].join(' · ') || '完成了操作');
164
+ }
165
+
166
+ function operationIconKind(entries) {
167
+ if (entries.some(entry => entry.kind === 'fileChange' || entry.operationKind === 'fileChange')) return 'edit';
168
+ if (entries.some(entry => entry.kind === 'command' || entry.operationKind === 'command')) return 'command';
169
+ if (entries.some(entry => entry.kind === 'webSearch')) return 'web';
170
+ if (entries.some(entry => entry.kind === 'tool' || entry.operationKind === 'tool')) return 'tool';
171
+ if (entries.some(entry => entry.kind === 'imageView')) return 'image';
172
+ return 'tool';
173
+ }
174
+
175
+ function latestActiveEntry(entries) {
176
+ let newestActivity = true;
177
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
178
+ const entry = entries[index];
179
+ if (entry?.type !== 'activity') continue;
180
+ if (newestActivity) {
181
+ newestActivity = false;
182
+ if (entry.kind === 'contextCompaction') return entry;
183
+ }
184
+ if (entry.status === 'inProgress') return entry;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ function liveStepText(entries, latestReasoning) {
190
+ const entry = latestActiveEntry(entries);
191
+ if (!entry) return latestReasoning || '正在思考';
192
+ if (entry.kind === 'contextCompaction') return '正在自动压缩上下文';
193
+ if (entry.kind === 'command') {
194
+ const command = activityPreview(entry.command);
195
+ return command ? `正在运行 ${command}` : '正在运行命令';
196
+ }
197
+ if (entry.kind === 'tool') {
198
+ const tool = [entry.server || entry.namespace, entry.tool].filter(Boolean).join('/');
199
+ return tool ? `正在调用 ${tool}` : '正在调用工具';
200
+ }
201
+ if (entry.kind === 'fileChange') return '正在编辑文件';
202
+ if (entry.kind === 'webSearch') return webSearchTitle(entry, true);
203
+ if (entry.kind === 'imageView') return '正在查看图像';
204
+ if (entry.kind === 'imageGeneration') return '正在生成图片';
205
+ if (entry.kind === 'subAgent') return '正在等待子任务';
206
+ if (entry.kind === 'collaboration') return '正在协调任务';
207
+ return latestReasoning || '正在处理';
208
+ }
209
+
210
+ function reasoningSummaryLines(detail) {
211
+ return String(detail || '')
212
+ .split(/\r?\n/)
213
+ .map(line => line.trim())
214
+ .filter(Boolean)
215
+ .map(line => line.replace(/^\*\*(.*?)\*\*$/, '$1'))
216
+ .filter(Boolean);
217
+ }
218
+
219
+ function reasoningSummaryTexts(entries) {
220
+ const summaries = entries
221
+ .filter(entry => entry.type === 'activity' && entry.kind === 'reasoning')
222
+ .flatMap(entry => reasoningSummaryLines(entry.detail))
223
+ .filter(Boolean);
224
+ return [...new Set(summaries)];
225
+ }
226
+
227
+ return Object.freeze({
228
+ activityPreview,
229
+ commandStatusText,
230
+ diffLineCounts,
231
+ formatDuration,
232
+ formatTime,
233
+ isVisibleProcessActivity,
234
+ latestActiveEntry,
235
+ liveStepText,
236
+ operationIconKind,
237
+ operationSummary,
238
+ processDurationMs,
239
+ reasoningSummaryLines,
240
+ reasoningSummaryTexts,
241
+ statusText,
242
+ timeMilliseconds,
243
+ toolStatusText,
244
+ turnErrorMessage,
245
+ turnLabel,
246
+ webSearchDetail,
247
+ webSearchTitle,
248
+ });
249
+ });
@@ -0,0 +1,81 @@
1
+ (function (root, factory) {
2
+ const reducer = factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = reducer;
4
+ else root.CodexTimelineReducer = reducer;
5
+ })(globalThis, function () {
6
+ function reduceTimelineUpdate(current, update, options = {}) {
7
+ const sync = options.sync || globalThis.CodexTimelineSync;
8
+ let timeline = current || { schemaVersion: 1, thread: {}, turns: [] };
9
+ let accepted = null;
10
+ if (update.sync) {
11
+ try { accepted = sync.acceptSyncUpdate(timeline.sync, update.sync); }
12
+ catch (error) { error.syncGap = true; throw error; }
13
+ if (accepted.reset) {
14
+ if (update.notModified && Array.isArray(timeline.turns)) {
15
+ const merged = { ...timeline, ...update,
16
+ thread: { ...timeline.thread, ...update.thread }, turns: timeline.turns,
17
+ nextCursor: timeline.nextCursor, sync: accepted.next, cached: false };
18
+ delete merged.notModified;
19
+ return { timeline: merged, reset: true, notModified: true, duplicate: false,
20
+ incomingTurns: [], addedTurns: [], replacedTurns: [], removedTurnIds: [] };
21
+ }
22
+ return { timeline: { ...update, turns: Array.isArray(update.turns) ? update.turns : [] },
23
+ reset: true, notModified: false, duplicate: false,
24
+ incomingTurns: [], addedTurns: [], replacedTurns: [], removedTurnIds: [] };
25
+ }
26
+ if (accepted.duplicate) return { timeline, reset: false, notModified: false, duplicate: true,
27
+ incomingTurns: [], addedTurns: [], replacedTurns: [], removedTurnIds: [] };
28
+ }
29
+
30
+ timeline = { ...timeline, thread: { ...(timeline.thread || {}) }, turns: [...(timeline.turns || [])] };
31
+ if (update.thread) timeline.thread = { ...timeline.thread, ...update.thread };
32
+ const patchedTurns = [];
33
+ try {
34
+ for (const patch of update.turnPatches || []) {
35
+ const turn = timeline.turns.find(currentTurn => currentTurn.id === patch.id);
36
+ patchedTurns.push(sync.applyTurnPatch(turn, patch));
37
+ }
38
+ } catch (error) { error.syncGap = true; throw error; }
39
+
40
+ const incomingTurns = [
41
+ ...(Array.isArray(update.turns) ? update.turns : (update.turn ? [update.turn] : [])),
42
+ ...patchedTurns,
43
+ ];
44
+ const addedTurns = [], replacedTurns = [];
45
+ const wasEmpty = timeline.turns.length === 0;
46
+ for (const incomingTurn of incomingTurns) {
47
+ const index = timeline.turns.findIndex(turn => turn.id === incomingTurn.id);
48
+ if (index >= 0) {
49
+ const replacement = preserveLoadedOperationDetails(timeline.turns[index], incomingTurn);
50
+ if (JSON.stringify(timeline.turns[index]) === JSON.stringify(replacement)) continue;
51
+ timeline.turns[index] = replacement;
52
+ replacedTurns.push(replacement);
53
+ } else {
54
+ timeline.turns.push(incomingTurn);
55
+ addedTurns.push(incomingTurn);
56
+ }
57
+ }
58
+ if (accepted?.next) timeline.sync = accepted.next;
59
+
60
+ const removedTurnIds = [];
61
+ const recentHistoryLimit = Number(options.recentHistoryLimit) || 0;
62
+ if (recentHistoryLimit > 0 && timeline.turns.length > recentHistoryLimit) {
63
+ removedTurnIds.push(...timeline.turns.splice(0, timeline.turns.length - recentHistoryLimit).map(turn => turn.id));
64
+ }
65
+ return { timeline, reset: false, notModified: false, duplicate: false, wasEmpty,
66
+ incomingTurns, addedTurns, replacedTurns, removedTurnIds };
67
+ }
68
+
69
+ function preserveLoadedOperationDetails(current, incoming) {
70
+ const loaded = new Map((current?.entries || [])
71
+ .filter(entry => entry?.kind === 'lazyOperationGroup' && entry.detailsLoaded)
72
+ .map(entry => [entry.id, entry]));
73
+ if (!loaded.size) return incoming;
74
+ return { ...incoming, entries: (incoming.entries || []).map(entry => {
75
+ const previous = loaded.get(entry?.id);
76
+ return previous ? { ...entry, loadedEntries: previous.loadedEntries, detailsLoaded: true } : entry;
77
+ }) };
78
+ }
79
+
80
+ return Object.freeze({ reduceTimelineUpdate });
81
+ });
@@ -0,0 +1,161 @@
1
+ (function (root) {
2
+ 'use strict';
3
+
4
+ function createTimelineRenderer(options) {
5
+ const {
6
+ document,
7
+ getTimeline,
8
+ getSelectedThreadId,
9
+ disclosures,
10
+ pendingMessages,
11
+ historyButton,
12
+ renderTurnEntries,
13
+ renderEmptyMessage,
14
+ renderPendingMessages,
15
+ scrollTimelineToBottom,
16
+ turnLabel,
17
+ formatDuration,
18
+ processDurationMs,
19
+ remoteImageObserver,
20
+ remoteImageLoads,
21
+ onAuthoritativeTimeline,
22
+ } = options;
23
+
24
+ function turnElement(turn) {
25
+ const element = document.createElement('div');
26
+ element.className = 'turn';
27
+ element.dataset.turnId = turn.id || '';
28
+ element.dataset.turnStatus = turn.status || 'unknown';
29
+ if (!['interrupted', 'failed'].includes(turn.status)) {
30
+ const label = document.createElement('div');
31
+ label.className = 'turn-label';
32
+ label.textContent = turnLabel(turn);
33
+ element.append(label);
34
+ }
35
+ renderTurnEntries(element, turn);
36
+ return element;
37
+ }
38
+
39
+ function render(data, initial = false) {
40
+ if (!data.cached) onAuthoritativeTimeline(data.thread);
41
+ pendingMessages.retainImagePreviews(getSelectedThreadId(), data);
42
+ const timeline = getTimeline();
43
+ const nextThreadId = data.thread?.id || getSelectedThreadId() || '';
44
+ if (timeline.dataset.threadId && timeline.dataset.threadId === nextThreadId) {
45
+ disclosures.captureWithin(timeline, nextThreadId);
46
+ }
47
+ timeline.replaceChildren();
48
+ timeline.dataset.threadId = nextThreadId;
49
+ const older = historyButton(data);
50
+ const turns = Array.isArray(data.turns) ? data.turns : [];
51
+ if (older && !data.historyGapBeforeTurnId) timeline.append(older);
52
+ for (const turn of turns) {
53
+ if (older && data.historyGapBeforeTurnId === turn.id) timeline.append(older);
54
+ timeline.append(turnElement(turn));
55
+ }
56
+ if (older && data.historyGapBeforeTurnId && !turns.some(turn => turn.id === data.historyGapBeforeTurnId)) timeline.prepend(older);
57
+ if (!turns.length) timeline.append(renderEmptyMessage());
58
+ renderPendingMessages();
59
+ updateDurations();
60
+ scrollTimelineToBottom(initial);
61
+ }
62
+
63
+ function updateDurations() {
64
+ for (const element of document.querySelectorAll('.process-duration')) {
65
+ const status = element.dataset.status || 'unknown';
66
+ const durationMs = processDurationMs(element);
67
+ const prefix = status === 'inProgress'
68
+ ? '已处理'
69
+ : (status === 'failed'
70
+ ? '处理失败'
71
+ : (status === 'interrupted' ? '已中断' : (status === 'completed' ? '用时' : '已处理')));
72
+ element.textContent = durationMs === null ? prefix : `${prefix} ${formatDuration(durationMs)}`;
73
+ }
74
+ }
75
+
76
+ function replaceTurn(existing, turn, enteredHistory) {
77
+ if (enteredHistory) disclosures.forgetWithin(existing);
78
+ const replacement = turnElement(turn);
79
+ preserveRenderedImages(existing, replacement);
80
+ if (enteredHistory) disclosures.collapseWithin(replacement);
81
+ else disclosures.preserve(existing, replacement);
82
+ reconcileRenderedTurn(existing, replacement);
83
+ }
84
+
85
+ // Keep question forms and their ancestors attached while unrelated streaming content changes.
86
+ function reconcileRenderedTurn(existing, replacement) {
87
+ const formKey = node => node.nodeType === 1
88
+ ? (node.matches('.question-form') ? node.dataset.questionState : node.querySelector('.question-form')?.dataset.questionState)
89
+ : undefined;
90
+ const reconcile = (current, fresh) => {
91
+ if (current.matches('.question-form')) return;
92
+ for (const attr of [...current.attributes]) if (!fresh.hasAttribute(attr.name)) current.removeAttribute(attr.name);
93
+ for (const attr of fresh.attributes) current.setAttribute(attr.name, attr.value);
94
+ let cursor = current.firstChild;
95
+ for (const child of [...fresh.childNodes]) {
96
+ const key = formKey(child);
97
+ const retained = key && [...current.childNodes].find(node =>
98
+ node.nodeName === child.nodeName && formKey(node) === key);
99
+ if (retained) {
100
+ while (cursor && cursor !== retained) {
101
+ const next = cursor.nextSibling;
102
+ cursor.remove();
103
+ cursor = next;
104
+ }
105
+ reconcile(retained, child);
106
+ cursor = retained.nextSibling;
107
+ } else {
108
+ current.insertBefore(child, cursor);
109
+ }
110
+ }
111
+ while (cursor) {
112
+ const next = cursor.nextSibling;
113
+ cursor.remove();
114
+ cursor = next;
115
+ }
116
+ };
117
+ if (existing.querySelector('.question-form') && replacement.querySelector('.question-form')) reconcile(existing, replacement);
118
+ else existing.replaceWith(replacement);
119
+ }
120
+
121
+ function preserveRenderedImages(existing, replacement) {
122
+ const inlineImages = new Map();
123
+ for (const node of existing.querySelectorAll('.remote-inline-image, .remote-attachment-image')) {
124
+ const key = JSON.stringify([node.className, node.dataset.remotePath, node.dataset.imageAlt]);
125
+ if (!inlineImages.has(key)) inlineImages.set(key, []);
126
+ inlineImages.get(key).push(node);
127
+ }
128
+ for (const node of replacement.querySelectorAll('.remote-inline-image, .remote-attachment-image')) {
129
+ const previous = inlineImages.get(JSON.stringify([node.className, node.dataset.remotePath, node.dataset.imageAlt]))?.shift();
130
+ if (!previous) continue;
131
+ for (const button of node.querySelectorAll('button')) {
132
+ remoteImageObserver.unobserve(button);
133
+ remoteImageLoads.delete(button);
134
+ }
135
+ node.replaceWith(previous);
136
+ }
137
+ const currentImages = new Map();
138
+ for (const image of existing.querySelectorAll('img')) {
139
+ const key = renderedImageKey(image);
140
+ if (!key) continue;
141
+ if (!currentImages.has(key)) currentImages.set(key, []);
142
+ currentImages.get(key).push(image);
143
+ }
144
+ for (const freshImage of replacement.querySelectorAll('img')) {
145
+ const matches = currentImages.get(renderedImageKey(freshImage));
146
+ const currentImage = matches?.shift();
147
+ if (currentImage) freshImage.replaceWith(currentImage);
148
+ }
149
+ }
150
+
151
+ function renderedImageKey(image) {
152
+ const source = image.getAttribute('src') || '';
153
+ if (!source) return '';
154
+ return [image.className, source, image.dataset.fullImage || '', image.alt || ''].join('\n');
155
+ }
156
+
157
+ return { render, replaceTurn, turnElement, updateDurations };
158
+ }
159
+
160
+ root.createTimelineRenderer = createTimelineRenderer;
161
+ })(globalThis);
@@ -0,0 +1,50 @@
1
+ (function (root) {
2
+ 'use strict';
3
+
4
+ function createTimelineScrollController(options) {
5
+ const { document, state, getTimeline, getSelected, getScrollButton } = options;
6
+ let viewportSize = { width: 0, height: 0 };
7
+
8
+ function isAtBottom() {
9
+ const timeline = getTimeline();
10
+ return timeline.scrollHeight - timeline.clientHeight - timeline.scrollTop <= 24;
11
+ }
12
+
13
+ function syncPosition() {
14
+ const timeline = getTimeline();
15
+ const width = timeline.clientWidth;
16
+ const height = timeline.clientHeight;
17
+ if (!width || !height) return state.timelineAtBottom;
18
+ const resized = width !== viewportSize.width || height !== viewportSize.height;
19
+ viewportSize = { width, height };
20
+ // Preserve the pre-layout bottom state before a resize-generated scroll overwrites it.
21
+ if (resized && state.timelineAtBottom) scrollToBottom();
22
+ return isAtBottom();
23
+ }
24
+
25
+ function scrollToBottom(force = false) {
26
+ if (document.activeElement?.closest('.question-form') || (!force && !state.timelineAtBottom)) return;
27
+ const timeline = getTimeline();
28
+ timeline.scrollTop = timeline.scrollHeight;
29
+ state.timelineAtBottom = true;
30
+ updateButton();
31
+ requestAnimationFrame(() => {
32
+ if (!state.timelineAtBottom || document.activeElement?.closest('.question-form')) return;
33
+ timeline.scrollTop = timeline.scrollHeight;
34
+ });
35
+ }
36
+
37
+ function updateButton() {
38
+ getScrollButton().hidden = state.timelineAtBottom || getSelected().hidden;
39
+ }
40
+
41
+ function markAwayFromBottom() {
42
+ state.timelineAtBottom = false;
43
+ updateButton();
44
+ }
45
+
46
+ return { isAtBottom, markAwayFromBottom, scrollToBottom, syncPosition, updateButton };
47
+ }
48
+
49
+ root.createTimelineScrollController = createTimelineScrollController;
50
+ })(globalThis);