backtrace-console 0.0.3 → 0.0.5

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,635 @@
1
+ const chatContainer = document.getElementById('chat-container');
2
+ const chatInput = document.getElementById('chat-input');
3
+ const sendBtn = document.getElementById('send-btn');
4
+ const composerStatus = document.getElementById('composer-status');
5
+ const infoPanel = document.getElementById('info-panel');
6
+ const infoPanelContent = document.getElementById('info-panel-content');
7
+ const sessionList = document.getElementById('session-list');
8
+ const imageBtn = document.getElementById('image-btn');
9
+ const imageInput = document.getElementById('image-input');
10
+ const imagePreviewBar = document.getElementById('image-preview-bar');
11
+
12
+ // 待发送的图片列表 [{ data: base64, mimeType, name, previewUrl }]
13
+ let pendingImages = [];
14
+
15
+ imageBtn.addEventListener('click', function() { imageInput.click(); });
16
+
17
+ imageInput.addEventListener('change', function() {
18
+ const files = Array.from(imageInput.files || []);
19
+ files.forEach(function(file) {
20
+ const reader = new FileReader();
21
+ reader.onload = function(e) {
22
+ const dataUrl = e.target.result;
23
+ const base64 = dataUrl.split(',')[1];
24
+ pendingImages.push({ data: base64, mimeType: file.type, name: file.name, previewUrl: dataUrl });
25
+ renderImagePreviews();
26
+ };
27
+ reader.readAsDataURL(file);
28
+ });
29
+ imageInput.value = '';
30
+ });
31
+
32
+ chatInput.addEventListener('paste', function(e) {
33
+ const items = Array.from((e.clipboardData || e.originalEvent && e.originalEvent.clipboardData || {}).items || []);
34
+ const imageItems = items.filter(function(item) { return item.type.startsWith('image/'); });
35
+ if (imageItems.length === 0) return;
36
+ e.preventDefault();
37
+ imageItems.forEach(function(item) {
38
+ const file = item.getAsFile();
39
+ if (!file) return;
40
+ const reader = new FileReader();
41
+ reader.onload = function(ev) {
42
+ const dataUrl = ev.target.result;
43
+ const base64 = dataUrl.split(',')[1];
44
+ pendingImages.push({ data: base64, mimeType: file.type, name: 'pasted-image.' + (file.type.split('/')[1] || 'png'), previewUrl: dataUrl });
45
+ renderImagePreviews();
46
+ };
47
+ reader.readAsDataURL(file);
48
+ });
49
+ });
50
+
51
+ function renderImagePreviews() {
52
+ if (pendingImages.length === 0) {
53
+ imagePreviewBar.style.display = 'none';
54
+ imagePreviewBar.innerHTML = '';
55
+ return;
56
+ }
57
+ imagePreviewBar.style.display = 'flex';
58
+ imagePreviewBar.innerHTML = pendingImages.map(function(img, idx) {
59
+ return `<div class="image-preview-item" data-idx="${idx}">
60
+ <img src="${img.previewUrl}" alt="${escapeHtml(img.name)}">
61
+ <button class="image-preview-remove" data-idx="${idx}">×</button>
62
+ </div>`;
63
+ }).join('');
64
+ imagePreviewBar.querySelectorAll('.image-preview-remove').forEach(function(btn) {
65
+ btn.addEventListener('click', function() {
66
+ pendingImages.splice(parseInt(btn.dataset.idx), 1);
67
+ renderImagePreviews();
68
+ });
69
+ });
70
+ }
71
+
72
+ // 获取 URL 中的 fingerprint 参数,后续可用于 API 请求
73
+ const urlParams = new URLSearchParams(window.location.search);
74
+ const fingerprint = urlParams.get('fingerprint');
75
+
76
+ let currentThreadId = null;
77
+ let currentFingerprintData = null;
78
+ let currentSessionId = null;
79
+ let currentSessionMessages = [];
80
+
81
+ if (fingerprint) {
82
+ console.log('当前对话关联的 fingerprint:', fingerprint);
83
+ infoPanel.style.display = 'flex';
84
+ fetchFingerprintInfo(fingerprint);
85
+ loadSessionList(fingerprint);
86
+ }
87
+
88
+ function restoreWelcomeMessage() {
89
+ chatContainer.innerHTML = '';
90
+ if (currentFingerprintData) {
91
+ chatContainer.appendChild(createSystemMessageElement(buildFingerprintContextMessage(currentFingerprintData)));
92
+ } else {
93
+ chatContainer.appendChild(createSystemMessageElement('你是一个非常专业并资深的C++开发工程师和UE工程师。当前崩溃的关键信息如下:\nError Message: -\nClassifiers: -\n\n请结合这些上下文,帮助用户定位问题并给出修复建议。'));
94
+ }
95
+ scrollToBottom();
96
+ }
97
+
98
+ function buildFingerprintContextMessage(meta) {
99
+ const errorMessage = meta && meta.errorMessage ? meta.errorMessage : '-';
100
+ const classifiers = meta && meta.classifiers ? meta.classifiers : '-';
101
+ return `你是一个非常专业并资深的C++开发工程师和UE工程师。当前崩溃的关键信息如下:\nError Message: ${errorMessage}\nClassifiers: ${classifiers}\n\n请结合这些上下文,帮助用户定位问题并给出修复建议。`;
102
+ }
103
+
104
+ function setActiveSession(sessionId) {
105
+ currentSessionId = sessionId || null;
106
+ const items = Array.from(sessionList.querySelectorAll('.session-item'));
107
+ items.forEach(item => {
108
+ const isActive = String(item.dataset.sessionId || '') === String(currentSessionId || '');
109
+ item.classList.toggle('active', isActive);
110
+ item.style.background = isActive ? 'rgba(16,163,127,0.16)' : 'transparent';
111
+ item.style.borderColor = isActive ? 'rgba(16,163,127,0.55)' : 'rgba(255,255,255,0.08)';
112
+ item.style.boxShadow = isActive ? 'inset 0 0 0 1px rgba(16,163,127,0.25)' : 'none';
113
+ });
114
+ }
115
+
116
+ function renderSessionList(sessions, activeSessionId) {
117
+ sessionList.innerHTML = '';
118
+ if (!Array.isArray(sessions) || sessions.length === 0) {
119
+ sessionList.innerHTML = '<div class="session-empty">暂无历史会话</div>';
120
+ return;
121
+ }
122
+ sessions.forEach(session => {
123
+ const item = document.createElement('button');
124
+ item.type = 'button';
125
+ item.dataset.sessionId = session.sessionId;
126
+ item.className = `session-item${session.sessionId === activeSessionId ? ' active' : ''}`;
127
+ item.innerHTML = `<div class="session-title">${session.title || session.sessionId}</div><div class="session-time">${session.updatedAt || session.createdAt || ''}</div>`;
128
+ item.addEventListener('click', () => {
129
+ if (session.sessionId !== currentSessionId) {
130
+ setActiveSession(session.sessionId);
131
+ loadSession(session.sessionId);
132
+ }
133
+ });
134
+ sessionList.appendChild(item);
135
+ });
136
+ }
137
+
138
+ function loadSessionList(fp) {
139
+ fetch(`/api/backtrace/chat-sessions?fingerprint=${encodeURIComponent(fp)}`)
140
+ .then(res => res.json())
141
+ .then(data => {
142
+ if (!data.ok) {
143
+ throw new Error(data.error || '加载会话列表失败');
144
+ }
145
+ const sessions = data.sessions || [];
146
+ const selectedSessionId = data.activeSessionId || currentSessionId || (sessions[0] ? sessions[0].sessionId : null);
147
+ renderSessionList(sessions, selectedSessionId);
148
+ if (selectedSessionId) {
149
+ if (selectedSessionId !== currentSessionId || currentSessionMessages.length === 0) {
150
+ loadSession(selectedSessionId, { skipRefreshList: true });
151
+ } else {
152
+ setActiveSession(selectedSessionId);
153
+ }
154
+ } else {
155
+ createNewSession();
156
+ }
157
+ })
158
+ .catch(err => {
159
+ console.error('加载会话列表失败:', err);
160
+ restoreWelcomeMessage();
161
+ });
162
+ }
163
+
164
+ function loadSession(sessionId, options = {}) {
165
+ if (!fingerprint || !sessionId) return;
166
+ fetch(`/api/backtrace/chat-session?fingerprint=${encodeURIComponent(fingerprint)}&sessionId=${encodeURIComponent(sessionId)}`)
167
+ .then(res => res.json())
168
+ .then(data => {
169
+ if (!data.ok) {
170
+ throw new Error(data.error || '加载会话失败');
171
+ }
172
+ setActiveSession(data.session.sessionId);
173
+ currentThreadId = data.session.threadId || null;
174
+ currentSessionMessages = Array.isArray(data.session.messages) ? data.session.messages : [];
175
+ renderSessionMessages(currentSessionMessages);
176
+ if (!options.skipRefreshList) {
177
+ //loadSessionList(fingerprint);
178
+ }
179
+ })
180
+ .catch(err => {
181
+ console.error('加载会话失败:', err);
182
+ });
183
+ }
184
+
185
+ function createNewSession() {
186
+ if (!fingerprint) return;
187
+ fetch('/api/backtrace/chat-session/create', {
188
+ method: 'POST',
189
+ headers: {
190
+ 'Content-Type': 'application/json'
191
+ },
192
+ body: JSON.stringify({ fingerprint })
193
+ })
194
+ .then(res => res.json())
195
+ .then(data => {
196
+ if (!data.ok) {
197
+ throw new Error(data.error || '创建会话失败');
198
+ }
199
+ setActiveSession(data.session.sessionId);
200
+ currentThreadId = data.session.threadId || null;
201
+ currentSessionMessages = Array.isArray(data.session.messages) ? data.session.messages : [];
202
+ renderSessionMessages(currentSessionMessages);
203
+ loadSessionList(fingerprint);
204
+ })
205
+ .catch(err => {
206
+ console.error('创建会话失败:', err);
207
+ });
208
+ }
209
+
210
+ function renderSessionMessages(messages) {
211
+ if (!Array.isArray(messages) || messages.length === 0) {
212
+ restoreWelcomeMessage();
213
+ return;
214
+ }
215
+ chatContainer.innerHTML = '';
216
+ let toolPanel = null;
217
+ let currentTurn = null;
218
+ messages.forEach(message => {
219
+ if (!message) return;
220
+ const role = message.role || 'agent';
221
+ const text = typeof message.text === 'string' ? message.text : '';
222
+ let msgEl = null;
223
+
224
+ if (role === 'user') {
225
+ toolPanel = null;
226
+ currentTurn = document.createElement('div');
227
+ currentTurn.className = 'turn-container';
228
+ msgEl = createMessageElement(text, true);
229
+ currentTurn.appendChild(msgEl);
230
+ chatContainer.appendChild(currentTurn);
231
+ return;
232
+ }
233
+
234
+ if (!currentTurn) {
235
+ currentTurn = document.createElement('div');
236
+ currentTurn.className = 'turn-container';
237
+ chatContainer.appendChild(currentTurn);
238
+ }
239
+
240
+ if (message.kind && message.kind.startsWith('tool_')) {
241
+ const payload = {
242
+ toolName: message.meta?.itemType || message.text || 'tool',
243
+ toolId: message.meta?.toolId || '',
244
+ args: message.meta?.args || null,
245
+ output: message.kind === 'tool_call_output' ? text : '',
246
+ result: message.kind === 'tool_call_completed' ? text : '',
247
+ text,
248
+ meta: message.meta || null,
249
+ };
250
+ if (!toolPanel) {
251
+ toolPanel = createToolMessageElement(message.kind, payload);
252
+ msgEl = toolPanel;
253
+ } else {
254
+ updateToolMessageElement(toolPanel, message.kind, payload);
255
+ }
256
+ } else if (role === 'system') {
257
+ toolPanel = null;
258
+ msgEl = createSystemMessageElement(text);
259
+ } else if (role === 'agent') {
260
+ toolPanel = null;
261
+ msgEl = createMessageElement('', false);
262
+ const content = msgEl.querySelector('.message-content');
263
+ renderAgentBlocks(content, [message.kind === 'agent_draft' && text ? text + '\n\n_回复中断或仍在生成中_' : text]);
264
+ } else {
265
+ toolPanel = null;
266
+ msgEl = createMessageElement(text, false);
267
+ }
268
+
269
+ if (msgEl) {
270
+ currentTurn.appendChild(msgEl);
271
+ }
272
+ });
273
+ scrollToBottom();
274
+ }
275
+
276
+ function fetchFingerprintInfo(fp) {
277
+ fetch(`/api/backtrace/fingerprint?fingerprint=${encodeURIComponent(fp)}`)
278
+ .then(res => res.json())
279
+ .then(data => {
280
+ if (data.ok && data.result) {
281
+ const resData = data.result;
282
+ // 保存给聊天请求提供上下文
283
+ currentFingerprintData = resData;
284
+
285
+ let objectsHtml = (resData.objects || []).slice(0, 5).map(o =>
286
+ `<div style="font-size: 0.85rem; margin-bottom: 0.25rem;">- ${escapeHtml(o.objectIdHex)} <span style="color: var(--text-muted)">(${new Date(o.timestamp * 1000).toLocaleString()})</span></div>`
287
+ ).join('');
288
+
289
+ const firstSeenValue = String(resData.firstSeen || '').trim();
290
+ const firstSeenDate = /^\d+$/.test(firstSeenValue)
291
+ ? new Date(Number(firstSeenValue) * 1000)
292
+ : (firstSeenValue ? new Date(firstSeenValue) : null);
293
+
294
+ infoPanelContent.innerHTML = `
295
+ <div class="info-item">
296
+ <div class="info-label">Fingerprint</div>
297
+ <div class="info-value" style="font-family: monospace;">${escapeHtml(resData.fingerprint || '-')}</div>
298
+ </div>
299
+ <div class="info-item">
300
+ <div class="info-label">First Seen</div>
301
+ <div class="info-value">${firstSeenDate && !Number.isNaN(firstSeenDate.getTime()) ? firstSeenDate.toLocaleString() : '-'}</div>
302
+ </div>
303
+ <div class="info-item">
304
+ <div class="info-label">Issue State</div>
305
+ <div class="info-value">${escapeHtml(resData.issueState || '-')}</div>
306
+ </div>
307
+ <div class="info-item">
308
+ <div class="info-label">Classifiers</div>
309
+ <div class="info-value">${escapeHtml(resData.classifiers || '-')}</div>
310
+ </div>
311
+ <div class="info-item">
312
+ <div class="info-label">Error Message</div>
313
+ <div class="info-value error-message">${escapeHtml(resData.errorMessage || '-')}</div>
314
+ </div>
315
+ <div class="info-item">
316
+ <button class="logs-btn" onclick="openLogsModal()">查看日志</button>
317
+ </div>
318
+ `;
319
+ } else {
320
+ infoPanelContent.innerHTML = `<div style="color: red; padding: 1rem;">加载失败: ${data.error || '未知错误'}</div>`;
321
+ }
322
+ })
323
+ .catch(err => {
324
+ infoPanelContent.innerHTML = `<div style="color: red; padding: 1rem;">请求错误: ${err.message}</div>`;
325
+ });
326
+ }
327
+
328
+ // 自动调整 textarea 高度
329
+ chatInput.addEventListener('input', function() {
330
+ this.style.height = 'auto';
331
+ this.style.height = (this.scrollHeight) + 'px';
332
+
333
+ if(this.value.trim().length > 0) {
334
+ sendBtn.disabled = false;
335
+ } else {
336
+ sendBtn.disabled = true;
337
+ }
338
+ });
339
+
340
+ // 处理回车发送 (Shift+Enter 换行)
341
+ chatInput.addEventListener('keydown', function(e) {
342
+ if (e.key === 'Enter' && !e.shiftKey) {
343
+ e.preventDefault();
344
+ sendMessage();
345
+ }
346
+ });
347
+
348
+ sendBtn.addEventListener('click', sendMessage);
349
+
350
+ function isFixIntent(text) {
351
+ const normalized = String(text || '').trim();
352
+ if (!normalized) return false;
353
+ return /(修复|修改|改下|改成|apply fix|fix this|patch|改代码|修改代码|直接修改|帮我改)/i.test(normalized);
354
+ }
355
+
356
+ function getDefaultReportPath() {
357
+ if (!currentFingerprintData) return '';
358
+ return currentFingerprintData.defaultReportPath || currentFingerprintData.reportPath || '';
359
+ }
360
+
361
+ // ── 日志模态框 ──────────────────────────────────────────────
362
+
363
+ function openLogsModal() {
364
+ if (!fingerprint) return;
365
+
366
+ const overlay = document.createElement('div');
367
+ overlay.className = 'modal-overlay';
368
+ overlay.id = 'logs-modal';
369
+ overlay.innerHTML = `
370
+ <div class="modal-box">
371
+ <div class="modal-header">
372
+ <span>日志列表</span>
373
+ <button class="modal-close" onclick="closeLogsModal()">×</button>
374
+ </div>
375
+ <div class="modal-status" id="modal-status">加载中...</div>
376
+ <div class="modal-body" id="modal-body"></div>
377
+ </div>
378
+ `;
379
+ overlay.addEventListener('click', function(e) {
380
+ if (e.target === overlay) closeLogsModal();
381
+ });
382
+ document.body.appendChild(overlay);
383
+
384
+ loadLogsModalData();
385
+ }
386
+
387
+ function closeLogsModal() {
388
+ const el = document.getElementById('logs-modal');
389
+ if (el) el.remove();
390
+ }
391
+
392
+ async function loadLogsModalData() {
393
+ const statusEl = document.getElementById('modal-status');
394
+ const bodyEl = document.getElementById('modal-body');
395
+
396
+ try {
397
+ // 先读本地目录
398
+ const localRes = await fetch(`/api/backtrace/files/list?path=${encodeURIComponent(fingerprint)}`)
399
+ .then(r => r.json()).catch(() => ({ ok: false }));
400
+
401
+ const localDirs = (localRes.ok && localRes.logDirectories) ? localRes.logDirectories : [];
402
+
403
+ let remoteObjects = [];
404
+
405
+ if (localDirs.length === 0) {
406
+ // 本地无缓存,请求远端并触发目录创建
407
+ statusEl.textContent = '本地无缓存,正在从远端获取...';
408
+ const remoteRes = await fetch('/api/backtrace/run', {
409
+ method: 'POST',
410
+ headers: { 'Content-Type': 'application/json' },
411
+ body: JSON.stringify({ command: 'fingerprint', fingerprint }),
412
+ }).then(r => r.json()).catch(() => ({ ok: false }));
413
+
414
+ remoteObjects = (remoteRes.ok && remoteRes.result && remoteRes.result.objects) ? remoteRes.result.objects : [];
415
+
416
+ // 重新读本地(远端请求已创建目录)
417
+ const localRes2 = await fetch(`/api/backtrace/files/list?path=${encodeURIComponent(fingerprint)}`)
418
+ .then(r => r.json()).catch(() => ({ ok: false }));
419
+ localDirs.push(...((localRes2.ok && localRes2.logDirectories) ? localRes2.logDirectories : []));
420
+ } else {
421
+ // 本地有缓存,读取每个目录下的 object-meta.json 获取 timestamp
422
+ const metaResults = await Promise.all(localDirs.map(d =>
423
+ fetch(`/api/backtrace/files/content?kind=logs&path=${encodeURIComponent(d.path + '/object-meta.json')}`)
424
+ .then(r => r.json()).catch(() => ({ ok: false }))
425
+ ));
426
+ metaResults.forEach((r, i) => {
427
+ if (r.ok && r.content) {
428
+ try {
429
+ const m = JSON.parse(r.content);
430
+ remoteObjects.push({ objectIdHex: localDirs[i].name, timestamp: m.timestamp || '' });
431
+ } catch (_) {
432
+ remoteObjects.push({ objectIdHex: localDirs[i].name, timestamp: '' });
433
+ }
434
+ } else {
435
+ remoteObjects.push({ objectIdHex: localDirs[i].name, timestamp: '' });
436
+ }
437
+ });
438
+ }
439
+
440
+ const localDirMap = {};
441
+ localDirs.forEach(d => { localDirMap[d.name] = d; });
442
+
443
+ const remoteMap = {};
444
+ remoteObjects.forEach(o => { remoteMap[o.objectIdHex] = o; });
445
+
446
+ const allKeys = Array.from(new Set([
447
+ ...remoteObjects.map(o => o.objectIdHex),
448
+ ...localDirs.map(d => d.name),
449
+ ])).sort(function(a, b) {
450
+ const tsA = Number((remoteMap[a] && remoteMap[a].timestamp) || 0);
451
+ const tsB = Number((remoteMap[b] && remoteMap[b].timestamp) || 0);
452
+ return tsB - tsA;
453
+ });
454
+
455
+ if (allKeys.length === 0) {
456
+ statusEl.textContent = '暂无 object 数据';
457
+ return;
458
+ }
459
+
460
+ // 区分有实际日志文件的目录(排除只有 object-meta.json 的)
461
+ const hasRealFiles = {};
462
+ await Promise.all(localDirs.map(async d => {
463
+ const r = await fetch(`/api/backtrace/files/list?path=${encodeURIComponent(fingerprint)}&logDir=${encodeURIComponent(d.name)}`)
464
+ .then(r => r.json()).catch(() => ({ ok: false }));
465
+ const files = (r.ok && r.logFiles) ? r.logFiles.filter(f => f.name !== 'object-meta.json') : [];
466
+ hasRealFiles[d.name] = files.length > 0;
467
+ }));
468
+
469
+ const downloadedCount = Object.values(hasRealFiles).filter(Boolean).length;
470
+ statusEl.textContent = `共 ${allKeys.length} 个 object(已下载日志 ${downloadedCount} 个)`;
471
+
472
+ bodyEl.innerHTML = '';
473
+ allKeys.forEach(hex => {
474
+ const remote = remoteMap[hex];
475
+ const local = localDirMap[hex];
476
+ const ts = remote && remote.timestamp ? new Date(Number(remote.timestamp) * 1000).toLocaleString() : '';
477
+ const downloaded = hasRealFiles[hex] || false;
478
+
479
+ const row = document.createElement('div');
480
+ row.className = 'object-row';
481
+
482
+ const header = document.createElement('div');
483
+ header.className = 'object-row-header';
484
+
485
+ const badgeClass = downloaded ? 'object-badge downloaded' : 'object-badge';
486
+ const badgeText = downloaded ? '已下载' : (local ? '未下载' : '仅本地');
487
+ header.innerHTML = `
488
+ <span class="object-arrow">▶</span>
489
+ <span class="object-hex">${escapeHtml(hex)}</span>
490
+ ${ts ? `<span class="object-time">${escapeHtml(ts)}</span>` : ''}
491
+ <span class="${badgeClass}">${badgeText}</span>
492
+ `;
493
+
494
+ const filesPanel = document.createElement('div');
495
+ filesPanel.className = 'object-files';
496
+ filesPanel.dataset.loaded = 'false';
497
+ filesPanel.dataset.hex = hex;
498
+
499
+ if (!downloaded) {
500
+ const dlBtn = document.createElement('button');
501
+ dlBtn.className = 'download-btn';
502
+ dlBtn.textContent = '下载';
503
+ dlBtn.style.marginLeft = '0.4rem';
504
+ dlBtn.addEventListener('click', function(e) {
505
+ e.stopPropagation();
506
+ downloadObject(hex, dlBtn, header, filesPanel, row);
507
+ });
508
+ header.appendChild(dlBtn);
509
+ }
510
+
511
+ header.addEventListener('click', function() {
512
+ const isExpanded = header.classList.toggle('expanded');
513
+ filesPanel.classList.toggle('open', isExpanded);
514
+ if (isExpanded && filesPanel.dataset.loaded === 'false') {
515
+ loadObjectFiles(filesPanel, hex);
516
+ }
517
+ });
518
+
519
+ row.appendChild(header);
520
+ row.appendChild(filesPanel);
521
+ bodyEl.appendChild(row);
522
+ });
523
+ } catch (err) {
524
+ statusEl.textContent = '加载失败: ' + err.message;
525
+ }
526
+ }
527
+
528
+ async function downloadObject(hex, btn, header, filesPanel, row) {
529
+ btn.disabled = true;
530
+ btn.textContent = '下载中...';
531
+ try {
532
+ const res = await fetch('/api/backtrace/download-object', {
533
+ method: 'POST',
534
+ headers: { 'Content-Type': 'application/json' },
535
+ body: JSON.stringify({ fingerprint, objectIdHex: hex }),
536
+ }).then(r => r.json());
537
+
538
+ if (!res.ok) {
539
+ btn.disabled = false;
540
+ btn.textContent = '下载';
541
+ alert('下载失败: ' + (res.error || '未知错误'));
542
+ return;
543
+ }
544
+
545
+ // 更新 badge 为已下载,移除下载按钮
546
+ const badge = header.querySelector('.object-badge');
547
+ if (badge) {
548
+ badge.textContent = '已下载';
549
+ badge.className = 'object-badge downloaded';
550
+ }
551
+ btn.remove();
552
+
553
+ // 刷新远端附件缓存,再更新文件列表
554
+ await fetch('/api/backtrace/query-object-attachments', {
555
+ method: 'POST',
556
+ headers: { 'Content-Type': 'application/json' },
557
+ body: JSON.stringify({ fingerprint, objectIdHex: hex }),
558
+ }).catch(() => {});
559
+
560
+ filesPanel.dataset.loaded = 'false';
561
+ if (filesPanel.classList.contains('open')) {
562
+ loadObjectFiles(filesPanel, hex);
563
+ }
564
+ } catch (err) {
565
+ btn.disabled = false;
566
+ btn.textContent = '下载';
567
+ alert('下载失败: ' + err.message);
568
+ }
569
+ }
570
+
571
+ async function loadObjectFiles(panel, hex) {
572
+ panel.dataset.loaded = 'true';
573
+ panel.innerHTML = '<div style="color: var(--text-muted); font-size: 0.85rem; padding: 0.4rem 0;">加载中...</div>';
574
+
575
+ try {
576
+ // 先读本地 object-meta.json,有缓存的 attachments 直接用
577
+ let attachments = null;
578
+ const metaRes = await fetch(
579
+ `/api/backtrace/files/content?kind=logs&path=${encodeURIComponent(fingerprint + '/logs/' + hex + '/object-meta.json')}`
580
+ ).then(r => r.json()).catch(() => null);
581
+
582
+ if (metaRes && metaRes.ok && metaRes.content) {
583
+ try {
584
+ const meta = JSON.parse(metaRes.content);
585
+ if (Array.isArray(meta.attachments) && meta.attachments.length > 0) {
586
+ attachments = meta.attachments;
587
+ }
588
+ } catch (_) {}
589
+ }
590
+
591
+ // 没有缓存才请求远端
592
+ if (!attachments) {
593
+ const queryResult = await fetch('/api/backtrace/query-object-attachments', {
594
+ method: 'POST',
595
+ headers: { 'Content-Type': 'application/json' },
596
+ body: JSON.stringify({ fingerprint: fingerprint, objectIdHex: hex })
597
+ }).then(r => r.json());
598
+
599
+ if (!queryResult.ok) {
600
+ throw new Error(queryResult.error || '查询附件列表失败');
601
+ }
602
+ attachments = queryResult.attachments || [];
603
+ }
604
+ if (attachments.length === 0) {
605
+ panel.innerHTML = '<div style="color: var(--text-muted); font-size: 0.85rem; padding: 0.4rem 0;">无附件</div>';
606
+ return;
607
+ }
608
+
609
+ // 根据 downloaded 状态渲染:已下载显示文件名链接,未下载仅显示文件名和大小
610
+ panel.innerHTML = attachments.map(att => {
611
+ const fileName = escapeHtml(att.name);
612
+ const filePath = `${fingerprint}/logs/${hex}/${att.name}`;
613
+ const sizeStr = att.size ? formatFileSize(att.size) : '';
614
+ const viewUrl = `/api/backtrace/files/view?kind=logs&path=${encodeURIComponent(filePath)}`;
615
+
616
+ if (att.downloaded) {
617
+ return `
618
+ <div class="file-row">
619
+ <a class="file-name file-link" href="${viewUrl}" target="_blank">${fileName}</a>
620
+ ${sizeStr ? `<span class="file-size">${sizeStr}</span>` : ''}
621
+ </div>
622
+ `;
623
+ } else {
624
+ return `
625
+ <div class="file-row">
626
+ <span class="file-name">${fileName}</span>
627
+ ${sizeStr ? `<span class="file-size">${sizeStr}</span>` : ''}
628
+ </div>
629
+ `;
630
+ }
631
+ }).join('');
632
+ } catch (err) {
633
+ panel.innerHTML = `<div style="color: red; font-size: 0.85rem; padding: 0.4rem 0;">加载失败: ${escapeHtml(err.message)}</div>`;
634
+ }
635
+ }