@qnote/q-ai-note 1.0.4 → 1.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.
package/dist/web/app.js CHANGED
@@ -19,11 +19,14 @@ const state = {
19
19
  sandboxPresentationMode: false,
20
20
  sandboxFullscreenMode: false,
21
21
  workTreeViewMode: 'full',
22
+ workItemElementPreviewMode: 'none',
22
23
  workItemShowAssignee: false,
23
24
  workItemSearch: '',
24
25
  workItemStatusFilter: 'all',
25
26
  diarySearch: '',
27
+ diarySandboxFilter: '',
26
28
  diaryProcessedFilter: 'all',
29
+ diaryWorkItemNameMap: {},
27
30
  changesSandboxFilter: '',
28
31
  changesTypeFilter: 'all',
29
32
  changesQuickFilter: 'all',
@@ -160,12 +163,14 @@ function applyWorkItemFilters(items) {
160
163
  function applyDiaryFilters(diaries) {
161
164
  const query = state.diarySearch.trim().toLowerCase();
162
165
  const processed = state.diaryProcessedFilter;
166
+ const sandboxId = state.diarySandboxFilter;
163
167
  return diaries.filter((diary) => {
164
168
  const queryMatched = !query || `${diary.content || ''}`.toLowerCase().includes(query);
169
+ const sandboxMatched = !sandboxId || String(diary.sandbox_id || '') === sandboxId;
165
170
  const statusMatched = processed === 'all'
166
171
  || (processed === 'processed' && diary.processed)
167
172
  || (processed === 'unprocessed' && !diary.processed);
168
- return queryMatched && statusMatched;
173
+ return queryMatched && sandboxMatched && statusMatched;
169
174
  });
170
175
  }
171
176
 
@@ -200,6 +205,13 @@ function applyWorkItemAssigneeToggle() {
200
205
  }
201
206
  }
202
207
 
208
+ function applyWorkItemElementPreviewMode() {
209
+ const selector = document.getElementById('work-item-element-preview-mode');
210
+ if (selector instanceof HTMLSelectElement) {
211
+ selector.value = state.workItemElementPreviewMode || 'none';
212
+ }
213
+ }
214
+
203
215
  function renderWorkTree() {
204
216
  const tree = document.getElementById('work-tree');
205
217
  if (!tree || !state.currentSandbox) return;
@@ -207,6 +219,7 @@ function renderWorkTree() {
207
219
  const allItems = state.currentSandbox.items || [];
208
220
  const items = applyWorkItemFilters(allItems);
209
221
  const entitySummaryByNodeId = buildNodeEntitySummaryByNodeId();
222
+ const entityRowsByNodeId = buildNodeEntityRowsByNodeId();
210
223
 
211
224
  if (expandedNodes.size === 0 && allItems.length > 0) {
212
225
  if (state.workTreeViewMode === 'report') {
@@ -244,12 +257,108 @@ function renderWorkTree() {
244
257
  document.getElementById('new-item-parent').value = parentId;
245
258
  document.getElementById('item-dialog').showModal();
246
259
  },
260
+ onAddDiary: (nodeId) => {
261
+ showNodeEntityDrawer(nodeId, 'diary');
262
+ const textarea = document.getElementById('drawer-diary-content');
263
+ if (textarea instanceof HTMLTextAreaElement) {
264
+ textarea.focus();
265
+ }
266
+ },
247
267
  onEdit: (id) => {
248
268
  editWorkItem(id);
249
269
  },
250
270
  onSelect: (id) => {
251
271
  showNodeEntityDrawer(id);
252
272
  },
273
+ onSelectEntity: (nodeId, entityType) => {
274
+ showNodeEntityDrawer(nodeId, entityType || 'all');
275
+ },
276
+ onMoveNode: async (dragNodeId, newParentId) => {
277
+ if (!state.currentSandbox) return;
278
+ if (!dragNodeId || dragNodeId === newParentId) return;
279
+ const byId = new Map((state.currentSandbox.items || []).map((item) => [item.id, item]));
280
+ const dragNode = byId.get(dragNodeId);
281
+ if (!dragNode) return;
282
+ const nextParentId = newParentId || null;
283
+ if (nextParentId && isDescendantNode(nextParentId, dragNodeId, byId)) {
284
+ alert('不能将节点拖拽到其子节点下。');
285
+ return;
286
+ }
287
+ const siblingItems = (state.currentSandbox.items || [])
288
+ .filter((item) => (item.parent_id || null) === nextParentId && item.id !== dragNodeId)
289
+ .sort((a, b) => compareSiblingOrder(a, b, 'order_key'));
290
+ const left = siblingItems[siblingItems.length - 1] || null;
291
+ const nextOrderKey = rankBetween(getNodeOrderKey(left, 'order_key'), '');
292
+ await apiRequest(`${API_BASE}/items/${dragNodeId}`, {
293
+ method: 'PUT',
294
+ body: JSON.stringify({
295
+ parent_id: nextParentId,
296
+ extra_data: {
297
+ ...(dragNode.extra_data || {}),
298
+ order_key: nextOrderKey,
299
+ },
300
+ }),
301
+ });
302
+ await loadSandbox(state.currentSandbox.id);
303
+ },
304
+ onReorderSiblings: async (dragNodeId, targetNodeId, position) => {
305
+ if (!state.currentSandbox) return;
306
+ if (!dragNodeId || !targetNodeId || dragNodeId === targetNodeId) return;
307
+ const byId = new Map((state.currentSandbox.items || []).map((item) => [item.id, item]));
308
+ const dragNode = byId.get(dragNodeId);
309
+ const targetNode = byId.get(targetNodeId);
310
+ if (!dragNode || !targetNode) return;
311
+ if (isDescendantNode(targetNodeId, dragNodeId, byId)) {
312
+ alert('不能将节点排序到其子树内部。');
313
+ return;
314
+ }
315
+ const nextParentId = targetNode.parent_id || null;
316
+ const siblings = (state.currentSandbox.items || [])
317
+ .filter((item) => (item.parent_id || null) === nextParentId && item.id !== dragNodeId)
318
+ .sort((a, b) => compareSiblingOrder(a, b, 'order_key'));
319
+ const targetIndex = siblings.findIndex((item) => item.id === targetNodeId);
320
+ if (targetIndex < 0) return;
321
+ const insertIndex = position === 'after' ? targetIndex + 1 : targetIndex;
322
+ const left = siblings[insertIndex - 1] || null;
323
+ const right = siblings[insertIndex] || null;
324
+ const nextOrderKey = rankBetween(getNodeOrderKey(left, 'order_key'), getNodeOrderKey(right, 'order_key'));
325
+ await apiRequest(`${API_BASE}/items/${dragNodeId}`, {
326
+ method: 'PUT',
327
+ body: JSON.stringify({
328
+ parent_id: nextParentId,
329
+ extra_data: {
330
+ ...(dragNode.extra_data || {}),
331
+ order_key: nextOrderKey,
332
+ },
333
+ }),
334
+ });
335
+ await loadSandbox(state.currentSandbox.id);
336
+ },
337
+ onReorderLanes: async (dragRootId, targetRootId) => {
338
+ if (!state.currentSandbox) return;
339
+ if (!dragRootId || !targetRootId || dragRootId === targetRootId) return;
340
+ const roots = (state.currentSandbox.items || []).filter((item) => !item.parent_id);
341
+ const ordered = [...roots].sort((a, b) => compareSiblingOrder(a, b, 'lane_order_key'));
342
+ const fromIdx = ordered.findIndex((item) => item.id === dragRootId);
343
+ const toIdx = ordered.findIndex((item) => item.id === targetRootId);
344
+ if (fromIdx < 0 || toIdx < 0) return;
345
+ const [moved] = ordered.splice(fromIdx, 1);
346
+ ordered.splice(toIdx, 0, moved);
347
+ const movedIndex = ordered.findIndex((item) => item.id === dragRootId);
348
+ const left = ordered[movedIndex - 1] || null;
349
+ const right = ordered[movedIndex + 1] || null;
350
+ const nextLaneKey = rankBetween(getNodeOrderKey(left, 'lane_order_key'), getNodeOrderKey(right, 'lane_order_key'));
351
+ await apiRequest(`${API_BASE}/items/${dragRootId}`, {
352
+ method: 'PUT',
353
+ body: JSON.stringify({
354
+ extra_data: {
355
+ ...(moved.extra_data || {}),
356
+ lane_order_key: nextLaneKey,
357
+ },
358
+ }),
359
+ });
360
+ await loadSandbox(state.currentSandbox.id);
361
+ },
253
362
  onDelete: async (id) => {
254
363
  if (confirm('确定删除此任务?')) {
255
364
  await apiRequest(`${API_BASE}/items/${id}`, { method: 'DELETE' });
@@ -261,6 +370,8 @@ function renderWorkTree() {
261
370
  },
262
371
  renderMode: state.workTreeViewMode === 'dense' ? 'dense' : 'card',
263
372
  showAssignee: state.workItemShowAssignee,
373
+ elementPreviewMode: state.workItemElementPreviewMode,
374
+ entityRowsByNodeId,
264
375
  });
265
376
 
266
377
  populateParentSelect(allItems);
@@ -289,6 +400,87 @@ function buildNodeEntitySummaryByNodeId() {
289
400
  return summaryByNodeId;
290
401
  }
291
402
 
403
+ function buildNodeEntityRowsByNodeId() {
404
+ const rowsByNodeId = {};
405
+ for (const row of state.nodeEntities || []) {
406
+ const nodeId = String(row.work_item_id || '');
407
+ if (!nodeId) continue;
408
+ if (!rowsByNodeId[nodeId]) rowsByNodeId[nodeId] = [];
409
+ rowsByNodeId[nodeId].push({
410
+ id: row.id,
411
+ entity_type: row.entity_type,
412
+ title: row.title || '',
413
+ status: row.status || '',
414
+ capability_type: row.capability_type || '',
415
+ });
416
+ }
417
+ return rowsByNodeId;
418
+ }
419
+
420
+ function isDescendantNode(candidateNodeId, parentNodeId, byId) {
421
+ let cursor = byId.get(candidateNodeId);
422
+ while (cursor && cursor.parent_id) {
423
+ if (cursor.parent_id === parentNodeId) return true;
424
+ cursor = byId.get(cursor.parent_id);
425
+ }
426
+ return false;
427
+ }
428
+
429
+ const ORDER_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
430
+ const ORDER_BASE = ORDER_ALPHABET.length;
431
+
432
+ function getOrderCharIndex(ch) {
433
+ if (!ch) return -1;
434
+ return ORDER_ALPHABET.indexOf(ch);
435
+ }
436
+
437
+ function rankBetween(left, right) {
438
+ const leftKey = String(left || '');
439
+ const rightKey = String(right || '');
440
+ if (leftKey && rightKey && leftKey >= rightKey) {
441
+ return `${leftKey}${ORDER_ALPHABET[Math.floor(ORDER_BASE / 2)]}`;
442
+ }
443
+ let i = 0;
444
+ let prefix = '';
445
+ while (i < 64) {
446
+ const leftDigit = i < leftKey.length ? getOrderCharIndex(leftKey[i]) : -1;
447
+ const rightDigit = i < rightKey.length ? getOrderCharIndex(rightKey[i]) : ORDER_BASE;
448
+ if (leftDigit < 0 && i < leftKey.length) {
449
+ return `${prefix}${ORDER_ALPHABET[Math.floor(ORDER_BASE / 2)]}`;
450
+ }
451
+ if (rightDigit < 0 && i < rightKey.length) {
452
+ return `${prefix}${ORDER_ALPHABET[Math.floor(ORDER_BASE / 2)]}`;
453
+ }
454
+ if (rightDigit - leftDigit > 1) {
455
+ const mid = Math.floor((leftDigit + rightDigit) / 2);
456
+ return `${prefix}${ORDER_ALPHABET[mid]}`;
457
+ }
458
+ prefix += i < leftKey.length ? leftKey[i] : ORDER_ALPHABET[0];
459
+ i += 1;
460
+ }
461
+ return `${prefix}${ORDER_ALPHABET[Math.floor(ORDER_BASE / 2)]}`;
462
+ }
463
+
464
+ function getNodeOrderKey(item, keyName = 'order_key') {
465
+ return String(item?.extra_data?.[keyName] || '').trim();
466
+ }
467
+
468
+ function compareSiblingOrder(a, b, keyName = 'order_key') {
469
+ const keyA = getNodeOrderKey(a, keyName);
470
+ const keyB = getNodeOrderKey(b, keyName);
471
+ if (keyA && keyB && keyA !== keyB) return keyA.localeCompare(keyB);
472
+ if (keyA && !keyB) return -1;
473
+ if (!keyA && keyB) return 1;
474
+ const numericA = Number(a?.extra_data?.lane_order);
475
+ const numericB = Number(b?.extra_data?.lane_order);
476
+ const validA = Number.isFinite(numericA);
477
+ const validB = Number.isFinite(numericB);
478
+ if (keyName === 'lane_order_key' && validA && validB && numericA !== numericB) return numericA - numericB;
479
+ if (keyName === 'lane_order_key' && validA && !validB) return -1;
480
+ if (keyName === 'lane_order_key' && !validA && validB) return 1;
481
+ return String(a?.created_at || '').localeCompare(String(b?.created_at || ''));
482
+ }
483
+
292
484
  function populateParentSelect(items) {
293
485
  const select = document.getElementById('new-item-parent');
294
486
  if (!select) return;
@@ -298,22 +490,182 @@ function populateParentSelect(items) {
298
490
  }
299
491
 
300
492
  function renderMarkdownSnippet(text) {
301
- const escaped = safeText(String(text || ''));
302
- return escaped.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, label, url) => {
303
- const safeUrl = safeText(url);
304
- const safeLabel = safeText(label);
305
- return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`;
306
- });
493
+ const source = String(text || '').replace(/\r\n/g, '\n');
494
+ const lines = source.split('\n');
495
+ const blocks = [];
496
+ let listItems = [];
497
+
498
+ const flushList = () => {
499
+ if (!listItems.length) return;
500
+ blocks.push(`<ul>${listItems.map((item) => `<li>${item}</li>`).join('')}</ul>`);
501
+ listItems = [];
502
+ };
503
+
504
+ const renderInline = (raw) => {
505
+ let html = safeText(String(raw || ''));
506
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
507
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
508
+ html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
509
+ html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, label, url) => {
510
+ const safeUrl = safeText(url);
511
+ const safeLabel = safeText(label);
512
+ return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`;
513
+ });
514
+ return html;
515
+ };
516
+
517
+ for (const line of lines) {
518
+ const trimmed = line.trim();
519
+ if (!trimmed) {
520
+ flushList();
521
+ continue;
522
+ }
523
+ if (/^[-*]\s+/.test(trimmed)) {
524
+ listItems.push(renderInline(trimmed.replace(/^[-*]\s+/, '')));
525
+ continue;
526
+ }
527
+ flushList();
528
+ const heading = trimmed.match(/^(#{1,6})\s+(.+)$/);
529
+ if (heading) {
530
+ const level = Math.min(6, heading[1].length);
531
+ blocks.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
532
+ continue;
533
+ }
534
+ blocks.push(`<p>${renderInline(trimmed)}</p>`);
535
+ }
536
+ flushList();
537
+ return blocks.join('');
307
538
  }
308
539
 
309
540
  function getNodeById(nodeId) {
310
541
  return state.currentSandbox?.items?.find((item) => item.id === nodeId) || null;
311
542
  }
312
543
 
544
+ function getWorkItemNameById(nodeId) {
545
+ const row = getNodeById(nodeId);
546
+ return row?.name || String(nodeId || '');
547
+ }
548
+
549
+ function renderQuickDiaryTargetLabel() {
550
+ const el = document.getElementById('sandbox-diary-target-label');
551
+ if (!(el instanceof HTMLElement)) return;
552
+ if (!state.currentSandbox) {
553
+ el.textContent = '快速日记:未进入沙盘';
554
+ return;
555
+ }
556
+ const nodeId = state.selectedNodeId;
557
+ if (!nodeId) {
558
+ el.textContent = `快速日记:关联沙盘 ${state.currentSandbox.name}`;
559
+ return;
560
+ }
561
+ el.textContent = `快速日记:${state.currentSandbox.name} / ${getWorkItemNameById(nodeId)}`;
562
+ }
563
+
564
+ async function saveDiaryEntry({ content, sandboxId = null, workItemId = null }) {
565
+ const payload = {
566
+ sandbox_id: sandboxId,
567
+ work_item_id: workItemId,
568
+ content: String(content || '').trim(),
569
+ };
570
+ if (!payload.content) return null;
571
+ return apiRequest(`${API_BASE}/diaries`, {
572
+ method: 'POST',
573
+ body: JSON.stringify(payload),
574
+ });
575
+ }
576
+
577
+ function shouldAutoCaptureDiary(text) {
578
+ const normalized = String(text || '').trim().toLowerCase();
579
+ if (!normalized) return false;
580
+ if (normalized.length < 2) return false;
581
+ const patterns = [
582
+ /记录/,
583
+ /进展/,
584
+ /日志/,
585
+ /汇报/,
586
+ /同步/,
587
+ /今日.*完成/,
588
+ /今天.*完成/,
589
+ /progress/,
590
+ /update/,
591
+ ];
592
+ return patterns.some((pattern) => pattern.test(normalized));
593
+ }
594
+
595
+ function resolveDiaryCaptureIntent(text) {
596
+ const raw = String(text || '').trim();
597
+ const auto = shouldAutoCaptureDiary(raw);
598
+ const directPrefix = /^(请\s*)?(帮我\s*)?(记录(日记|日志)|写(日记|日志)|日记|日志)\s*[::]?\s*/;
599
+ const isDirectDiaryCommand = directPrefix.test(raw);
600
+ const stripped = isDirectDiaryCommand ? raw.replace(directPrefix, '').trim() : raw;
601
+ return {
602
+ shouldCapture: auto,
603
+ isDirectDiaryCommand,
604
+ diaryContent: stripped || raw,
605
+ };
606
+ }
607
+
608
+ function parseNodeIdFromNodeContext(payloadContent) {
609
+ const text = String(payloadContent || '');
610
+ const match = text.match(/node_id=([^\n]+)/);
611
+ if (!match) return null;
612
+ const nodeId = String(match[1] || '').trim();
613
+ return nodeId || null;
614
+ }
615
+
616
+ function openDiaryEditDialog(diary) {
617
+ const dialog = document.getElementById('diary-edit-dialog');
618
+ const textarea = document.getElementById('diary-edit-content');
619
+ const preview = document.getElementById('diary-edit-preview');
620
+ if (!(dialog instanceof HTMLDialogElement) || !(textarea instanceof HTMLTextAreaElement)) return;
621
+ dialog.dataset.editDiaryId = String(diary?.id || '');
622
+ textarea.value = String(diary?.content || '');
623
+ if (preview instanceof HTMLElement) {
624
+ const rendered = renderMarkdownSnippet(textarea.value);
625
+ preview.innerHTML = rendered || '<p class="is-empty">在左侧输入 Markdown,这里会实时预览。</p>';
626
+ preview.classList.toggle('is-empty', !rendered);
627
+ }
628
+ dialog.showModal();
629
+ textarea.focus();
630
+ }
631
+
632
+ function closeDiaryEditDialog() {
633
+ const dialog = document.getElementById('diary-edit-dialog');
634
+ if (!(dialog instanceof HTMLDialogElement)) return;
635
+ dialog.close();
636
+ }
637
+
638
+ async function submitDiaryEditDialog() {
639
+ const dialog = document.getElementById('diary-edit-dialog');
640
+ const textarea = document.getElementById('diary-edit-content');
641
+ if (!(dialog instanceof HTMLDialogElement) || !(textarea instanceof HTMLTextAreaElement)) return;
642
+ const diaryId = String(dialog.dataset.editDiaryId || '').trim();
643
+ const content = textarea.value.trim();
644
+ if (!diaryId || !content) return;
645
+ await apiRequest(`${API_BASE}/diaries/${diaryId}`, {
646
+ method: 'PUT',
647
+ body: JSON.stringify({ content }),
648
+ });
649
+ const selectedNodeId = state.selectedNodeId;
650
+ const selectedFilter = state.nodeEntityFilter;
651
+ closeDiaryEditDialog();
652
+ await loadDiaries();
653
+ if (state.currentSandbox?.id) {
654
+ await loadSandbox(state.currentSandbox.id);
655
+ if (selectedNodeId) {
656
+ showNodeEntityDrawer(selectedNodeId, selectedFilter);
657
+ }
658
+ }
659
+ }
660
+
313
661
  function getNodeEntitiesByNodeId(nodeId) {
314
662
  return (state.nodeEntities || []).filter((row) => row.work_item_id === nodeId);
315
663
  }
316
664
 
665
+ function getNodeDiariesByNodeId(nodeId) {
666
+ return (state.currentSandbox?.diaries || []).filter((row) => row.work_item_id === nodeId);
667
+ }
668
+
317
669
  function closeNodeEntityDrawer() {
318
670
  const drawer = document.getElementById('node-entity-drawer');
319
671
  if (!drawer) return;
@@ -349,6 +701,7 @@ function renderNodeEntitySummary(nodeId) {
349
701
  const container = document.getElementById('node-entity-summary');
350
702
  if (!container) return;
351
703
  const rows = getNodeEntitiesByNodeId(nodeId);
704
+ const diaries = getNodeDiariesByNodeId(nodeId);
352
705
  const issues = rows.filter((row) => row.entity_type === 'issue');
353
706
  const knowledges = rows.filter((row) => row.entity_type === 'knowledge');
354
707
  const capabilities = rows.filter((row) => row.entity_type === 'capability');
@@ -358,38 +711,88 @@ function renderNodeEntitySummary(nodeId) {
358
711
  <div class="summary-card"><div class="label">Open Issue</div><div class="value">${openIssues}</div></div>
359
712
  <div class="summary-card"><div class="label">Knowledge</div><div class="value">${knowledges.length}</div></div>
360
713
  <div class="summary-card"><div class="label">Capability</div><div class="value">${capabilities.length}</div></div>
714
+ <div class="summary-card"><div class="label">Diary</div><div class="value">${diaries.length}</div></div>
361
715
  `;
362
716
  }
363
717
 
718
+ async function processNodeDiary(diaryId, action) {
719
+ if (!state.currentSandbox) return;
720
+ await apiRequest(`${API_BASE}/diaries/${diaryId}/process`, {
721
+ method: 'PUT',
722
+ body: JSON.stringify({ action }),
723
+ });
724
+ const selectedNodeId = state.selectedNodeId;
725
+ const selectedFilter = state.nodeEntityFilter;
726
+ await loadSandbox(state.currentSandbox.id);
727
+ await loadDiaries();
728
+ if (selectedNodeId) {
729
+ showNodeEntityDrawer(selectedNodeId, selectedFilter);
730
+ }
731
+ }
732
+
364
733
  function renderNodeEntityList(nodeId) {
365
734
  const container = document.getElementById('node-entity-list');
366
735
  if (!container) return;
367
- const allRows = getNodeEntitiesByNodeId(nodeId);
736
+ const allEntityRows = getNodeEntitiesByNodeId(nodeId);
737
+ const allDiaryRows = getNodeDiariesByNodeId(nodeId);
738
+ const timelineRows = [
739
+ ...allEntityRows.map((row) => ({ ...row, timeline_type: row.entity_type || 'issue' })),
740
+ ...allDiaryRows.map((row) => ({ ...row, timeline_type: 'diary' })),
741
+ ].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
368
742
  const rows = state.nodeEntityFilter === 'all'
369
- ? allRows
370
- : allRows.filter((row) => row.entity_type === state.nodeEntityFilter);
743
+ ? timelineRows
744
+ : timelineRows.filter((row) => row.timeline_type === state.nodeEntityFilter);
371
745
  if (!rows.length) {
372
- container.innerHTML = '<div class="empty-state"><p>当前节点还没有 issue/knowledge/capability</p></div>';
746
+ container.innerHTML = '<div class="empty-state"><p>当前筛选下暂无记录</p></div>';
373
747
  return;
374
748
  }
375
- container.innerHTML = rows.map((row) => `
376
- <div class="entity-card">
377
- <div class="entity-card-header">
378
- <div>
379
- <span class="entity-type-pill">${safeText(row.entity_type)}</span>
380
- <strong>${safeText(row.title || '-')}</strong>
749
+ container.innerHTML = rows.map((row) => {
750
+ if (row.timeline_type === 'diary') {
751
+ return `
752
+ <div class="entity-card diary-card ${row.processed ? 'processed' : ''}">
753
+ <div class="entity-card-header">
754
+ <div>
755
+ <span class="entity-type-pill">diary</span>
756
+ <strong>${safeText('日志记录')}</strong>
757
+ </div>
758
+ <div class="entity-card-actions">
759
+ <button class="btn btn-secondary btn-sm" data-diary-edit-id="${safeText(row.id)}">编辑</button>
760
+ ${row.processed ? '' : `
761
+ <button class="btn btn-secondary btn-sm" data-diary-confirm-id="${safeText(row.id)}">采纳</button>
762
+ <button class="btn btn-secondary btn-sm" data-diary-ignore-id="${safeText(row.id)}">忽略</button>
763
+ `}
764
+ </div>
765
+ </div>
766
+ <div class="entity-meta">
767
+ ${safeText(new Date(row.created_at).toLocaleString())}
768
+ ${row.processed ? ' · 已处理' : ' · 未处理'}
769
+ </div>
770
+ <div class="entity-content">${renderMarkdownSnippet(row.content || '')}</div>
381
771
  </div>
382
- <div class="entity-card-actions">
383
- <button class="btn btn-secondary btn-sm" data-entity-edit-id="${safeText(row.id)}">编辑</button>
384
- <button class="btn btn-secondary btn-sm" data-entity-delete-id="${safeText(row.id)}">删除</button>
772
+ `;
773
+ }
774
+ return `
775
+ <div class="entity-card">
776
+ <div class="entity-card-header">
777
+ <div>
778
+ <span class="entity-type-pill">${safeText(row.entity_type)}</span>
779
+ <strong>${safeText(row.title || '-')}</strong>
780
+ </div>
781
+ <div class="entity-card-actions">
782
+ <button class="btn btn-secondary btn-sm" data-entity-edit-id="${safeText(row.id)}">编辑</button>
783
+ <button class="btn btn-secondary btn-sm" data-entity-delete-id="${safeText(row.id)}">删除</button>
784
+ </div>
385
785
  </div>
786
+ <div class="entity-meta">
787
+ ${safeText(new Date(row.created_at).toLocaleString())}
788
+ ${row.status ? ` · <span class="entity-status-pill ${safeText(row.status)}">${safeText(row.status)}</span>` : ''}
789
+ ${row.priority ? ` · ${safeText(row.priority)}` : ''}
790
+ ${row.assignee ? ` · @${safeText(row.assignee)}` : ''}
791
+ </div>
792
+ <div class="entity-content">${renderMarkdownSnippet(row.content_md || '')}</div>
386
793
  </div>
387
- <div class="entity-meta">
388
- ${row.status ? `<span class="entity-status-pill ${safeText(row.status)}">${safeText(row.status)}</span>` : ''}${row.priority ? `· ${safeText(row.priority)}` : ''} ${row.assignee ? `· @${safeText(row.assignee)}` : ''}
389
- </div>
390
- <div class="entity-content">${renderMarkdownSnippet(row.content_md || '')}</div>
391
- </div>
392
- `).join('');
794
+ `;
795
+ }).join('');
393
796
 
394
797
  container.querySelectorAll('[data-entity-delete-id]').forEach((el) => {
395
798
  el.addEventListener('click', async (e) => {
@@ -406,11 +809,40 @@ function renderNodeEntityList(nodeId) {
406
809
  e.preventDefault();
407
810
  const id = el.getAttribute('data-entity-edit-id');
408
811
  if (!id) return;
409
- const row = allRows.find((item) => item.id === id);
812
+ const row = allEntityRows.find((item) => item.id === id);
410
813
  if (!row) return;
411
814
  startEditNodeEntity(row);
412
815
  });
413
816
  });
817
+
818
+ container.querySelectorAll('[data-diary-edit-id]').forEach((el) => {
819
+ el.addEventListener('click', (e) => {
820
+ e.preventDefault();
821
+ const id = el.getAttribute('data-diary-edit-id');
822
+ if (!id) return;
823
+ const row = allDiaryRows.find((item) => item.id === id);
824
+ if (!row) return;
825
+ openDiaryEditDialog(row);
826
+ });
827
+ });
828
+
829
+ container.querySelectorAll('[data-diary-confirm-id]').forEach((el) => {
830
+ el.addEventListener('click', async (e) => {
831
+ e.preventDefault();
832
+ const id = el.getAttribute('data-diary-confirm-id');
833
+ if (!id) return;
834
+ await processNodeDiary(id, 'confirm');
835
+ });
836
+ });
837
+
838
+ container.querySelectorAll('[data-diary-ignore-id]').forEach((el) => {
839
+ el.addEventListener('click', async (e) => {
840
+ e.preventDefault();
841
+ const id = el.getAttribute('data-diary-ignore-id');
842
+ if (!id) return;
843
+ await processNodeDiary(id, 'ignore');
844
+ });
845
+ });
414
846
  }
415
847
 
416
848
  function resetNodeEntityForm() {
@@ -438,6 +870,19 @@ function resetNodeEntityForm() {
438
870
  setNodeEntityFormExpanded(false);
439
871
  }
440
872
 
873
+ function ensureCapabilityTypeOption(value) {
874
+ const capabilityTypeInput = document.getElementById('entity-capability-type-input');
875
+ if (!(capabilityTypeInput instanceof HTMLSelectElement)) return;
876
+ const normalized = String(value || '').trim();
877
+ if (!normalized) return;
878
+ const hasOption = Array.from(capabilityTypeInput.options).some((option) => option.value === normalized);
879
+ if (hasOption) return;
880
+ const option = document.createElement('option');
881
+ option.value = normalized;
882
+ option.textContent = `${normalized}(历史值)`;
883
+ capabilityTypeInput.appendChild(option);
884
+ }
885
+
441
886
  function startEditNodeEntity(row) {
442
887
  state.editingNodeEntityId = row.id;
443
888
  setNodeEntityFormExpanded(true);
@@ -455,6 +900,7 @@ function startEditNodeEntity(row) {
455
900
  if (assigneeInput) assigneeInput.value = row.assignee || '';
456
901
  if (statusInput) statusInput.value = row.status || '';
457
902
  if (priorityInput) priorityInput.value = row.priority || '';
903
+ ensureCapabilityTypeOption(row.capability_type || '');
458
904
  if (capabilityTypeInput) capabilityTypeInput.value = row.capability_type || '';
459
905
 
460
906
  const submitBtn = document.getElementById('create-node-entity-btn');
@@ -464,15 +910,17 @@ function startEditNodeEntity(row) {
464
910
  titleInput?.focus();
465
911
  }
466
912
 
467
- function showNodeEntityDrawer(nodeId) {
913
+ function showNodeEntityDrawer(nodeId, preferredFilter = 'all') {
468
914
  const drawer = document.getElementById('node-entity-drawer');
469
915
  const title = document.getElementById('drawer-node-title');
470
916
  const node = getNodeById(nodeId);
471
917
  if (!drawer || !title || !node) return;
472
918
  state.selectedNodeId = nodeId;
473
- state.nodeEntityFilter = 'all';
919
+ const filter = ['all', 'issue', 'knowledge', 'capability', 'diary'].includes(preferredFilter) ? preferredFilter : 'all';
920
+ state.nodeEntityFilter = filter;
474
921
  title.textContent = node.name || nodeId;
475
922
  renderNodeEntitySummary(nodeId);
923
+ renderQuickDiaryTargetLabel();
476
924
  renderNodeEntityFilterTabs();
477
925
  renderNodeEntityList(nodeId);
478
926
  resetNodeEntityForm();
@@ -590,6 +1038,8 @@ async function sendSandboxChatMessage(content, options = {}) {
590
1038
 
591
1039
  const displayContent = options.displayContent || content;
592
1040
  const payloadContent = options.payloadContent || content;
1041
+ const autoDiaryWorkItemId = options.workItemId || parseNodeIdFromNodeContext(payloadContent);
1042
+ const diaryIntent = resolveDiaryCaptureIntent(displayContent || content);
593
1043
  messages.insertAdjacentHTML('beforeend', `<div class="chat-message user">${escapeHtml(displayContent)}</div>`);
594
1044
  const loadingMessage = appendLoadingMessage(messages);
595
1045
  messages.scrollTop = messages.scrollHeight;
@@ -598,6 +1048,31 @@ async function sendSandboxChatMessage(content, options = {}) {
598
1048
  setButtonState(btn, { disabled: true, text: '思考中' });
599
1049
 
600
1050
  try {
1051
+ let createdDiary = null;
1052
+ if (diaryIntent.shouldCapture) {
1053
+ createdDiary = await saveDiaryEntry({
1054
+ content: diaryIntent.diaryContent,
1055
+ sandboxId: state.currentSandbox.id,
1056
+ workItemId: autoDiaryWorkItemId || null,
1057
+ });
1058
+ }
1059
+ if (diaryIntent.isDirectDiaryCommand) {
1060
+ loadingMessage?.remove();
1061
+ if (createdDiary && state.currentSandbox) {
1062
+ state.currentSandbox.diaries = [createdDiary, ...(state.currentSandbox.diaries || [])];
1063
+ renderSandboxOverview();
1064
+ if (state.selectedNodeId && String(createdDiary.work_item_id || '') === String(state.selectedNodeId)) {
1065
+ renderNodeEntitySummary(state.selectedNodeId);
1066
+ renderNodeEntityList(state.selectedNodeId);
1067
+ }
1068
+ }
1069
+ messages.insertAdjacentHTML('beforeend', '<div class="chat-message assistant">📝 已记录日记。</div>');
1070
+ messages.scrollTop = messages.scrollHeight;
1071
+ if (state.currentSandbox) {
1072
+ await loadDiaries();
1073
+ }
1074
+ return;
1075
+ }
601
1076
  const history = state.chats
602
1077
  .filter(c => c.role)
603
1078
  .slice(-10)
@@ -693,7 +1168,7 @@ function openQuickChatPopover(nodeId, anchorEl) {
693
1168
  closeQuickChatPopover();
694
1169
  const payloadContent = composeQuickChatContent(nodeId, question);
695
1170
  const displayContent = `【快捷】${question}`;
696
- await sendSandboxChatMessage(question, { payloadContent, displayContent });
1171
+ await sendSandboxChatMessage(question, { payloadContent, displayContent, workItemId: nodeId });
697
1172
  });
698
1173
  textarea?.addEventListener('input', updateSubmitState);
699
1174
  textarea?.addEventListener('keydown', async (event) => {
@@ -771,8 +1246,10 @@ async function loadSandbox(id) {
771
1246
  document.getElementById('sandbox-title').textContent = sandbox.name;
772
1247
  applyWorkTreeViewMode(state.workTreeViewMode || 'full');
773
1248
  applyWorkItemAssigneeToggle();
1249
+ applyWorkItemElementPreviewMode();
774
1250
  applySandboxLayoutMode();
775
1251
  applySandboxFullscreenState();
1252
+ renderQuickDiaryTargetLabel();
776
1253
  renderSandboxOverview();
777
1254
  renderWorkTree();
778
1255
  loadSandboxChats(id);
@@ -886,8 +1363,63 @@ window.undoOperation = async function(operationId, btn) {
886
1363
  }
887
1364
  };
888
1365
 
1366
+ async function hydrateDiaryWorkItemNames(diaries) {
1367
+ const map = {};
1368
+ const sandboxIds = Array.from(
1369
+ new Set(
1370
+ (diaries || [])
1371
+ .filter((row) => row?.sandbox_id && row?.work_item_id)
1372
+ .map((row) => String(row.sandbox_id)),
1373
+ ),
1374
+ );
1375
+ await Promise.all(sandboxIds.map(async (sandboxId) => {
1376
+ try {
1377
+ const items = await apiRequest(`${API_BASE}/sandboxes/${sandboxId}/items`);
1378
+ (items || []).forEach((item) => {
1379
+ const key = `${sandboxId}:${item.id}`;
1380
+ map[key] = item.name || item.id;
1381
+ });
1382
+ } catch {
1383
+ // Ignore per-sandbox fetch failure to keep diary page available.
1384
+ }
1385
+ }));
1386
+ state.diaryWorkItemNameMap = map;
1387
+ }
1388
+
1389
+ function getDiaryWorkItemName(sandboxId, workItemId) {
1390
+ if (!workItemId) return '';
1391
+ const sandboxKey = String(sandboxId || '').trim();
1392
+ const itemKey = String(workItemId || '').trim();
1393
+ if (!itemKey) return '';
1394
+ if (sandboxKey) {
1395
+ const key = `${sandboxKey}:${itemKey}`;
1396
+ if (state.diaryWorkItemNameMap[key]) {
1397
+ return state.diaryWorkItemNameMap[key];
1398
+ }
1399
+ }
1400
+ if (state.currentSandbox?.id === sandboxKey) {
1401
+ const node = (state.currentSandbox.items || []).find((item) => item.id === itemKey);
1402
+ if (node?.name) return node.name;
1403
+ }
1404
+ return itemKey;
1405
+ }
1406
+
1407
+ function openDiaryWorkItemInSandbox(sandboxId, workItemId) {
1408
+ const targetSandboxId = String(sandboxId || '').trim();
1409
+ const targetWorkItemId = String(workItemId || '').trim();
1410
+ if (!targetSandboxId || !targetWorkItemId) return;
1411
+ const nextHash = `/sandbox/${encodeURIComponent(targetSandboxId)}?node_id=${encodeURIComponent(targetWorkItemId)}&open_drawer=1`;
1412
+ const currentHash = window.location.hash.slice(1);
1413
+ if (currentHash === nextHash && state.currentSandbox?.id === targetSandboxId) {
1414
+ showNodeEntityDrawer(targetWorkItemId);
1415
+ return;
1416
+ }
1417
+ window.location.hash = nextHash;
1418
+ }
1419
+
889
1420
  async function loadDiaries() {
890
1421
  state.diaries = await apiRequest(`${API_BASE}/diaries`);
1422
+ await hydrateDiaryWorkItemNames(state.diaries);
891
1423
  renderDiaries();
892
1424
  }
893
1425
 
@@ -899,6 +1431,11 @@ function renderDiaries() {
899
1431
  mountDiaryTimeline('diary-timeline', {
900
1432
  diaries: filtered,
901
1433
  getSandboxName,
1434
+ getWorkItemName: getDiaryWorkItemName,
1435
+ onOpenWorkItem: (sandboxId, workItemId) => {
1436
+ openDiaryWorkItemInSandbox(sandboxId, workItemId);
1437
+ },
1438
+ renderContent: (content) => renderMarkdownSnippet(content),
902
1439
  onConfirm: async (id) => {
903
1440
  await apiRequest(`${API_BASE}/diaries/${id}/process`, {
904
1441
  method: 'PUT',
@@ -912,7 +1449,12 @@ function renderDiaries() {
912
1449
  body: JSON.stringify({ action: 'ignore' }),
913
1450
  });
914
1451
  await loadDiaries();
915
- }
1452
+ },
1453
+ onEdit: async (id) => {
1454
+ const diary = state.diaries.find((row) => row.id === id);
1455
+ if (!diary) return;
1456
+ openDiaryEditDialog(diary);
1457
+ },
916
1458
  });
917
1459
  }
918
1460
 
@@ -922,10 +1464,12 @@ function getSandboxName(id) {
922
1464
  }
923
1465
 
924
1466
  function updateSandboxSelect() {
925
- const diarySelect = document.getElementById('diary-sandbox-select');
926
- if (diarySelect) {
927
- diarySelect.innerHTML = '<option value="">选择沙盘(可选)</option>' +
1467
+ const diaryFilterSelect = document.getElementById('diary-sandbox-filter');
1468
+ if (diaryFilterSelect) {
1469
+ const current = state.diarySandboxFilter || '';
1470
+ diaryFilterSelect.innerHTML = '<option value="">全部关联沙盘</option>' +
928
1471
  state.sandboxes.map(s => `<option value="${s.id}">${safeText(s.name)}</option>`).join('');
1472
+ diaryFilterSelect.value = current;
929
1473
  }
930
1474
 
931
1475
  const changesSelect = document.getElementById('changes-sandbox-filter');
@@ -1241,15 +1785,6 @@ function collectSettingHeadersFromUI() {
1241
1785
  return headers;
1242
1786
  }
1243
1787
 
1244
- async function loadChats() {
1245
- const messages = document.getElementById('chat-messages');
1246
- if (!messages) return;
1247
-
1248
- const chats = await apiRequest(`${API_BASE}/chats`);
1249
- mountHtmlList('chat-messages', chats.map((chat) => renderChatEntry(chat, { safeText, renderAIActionMessage })));
1250
- messages.scrollTop = messages.scrollHeight;
1251
- }
1252
-
1253
1788
  function showPage(pageId) {
1254
1789
  document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
1255
1790
  const page = document.getElementById(`page-${pageId}`);
@@ -1280,6 +1815,7 @@ function editWorkItem(id) {
1280
1815
  function initApp() {
1281
1816
  const hash = window.location.hash;
1282
1817
  loadWorkItemAssigneePreference();
1818
+ renderQuickDiaryTargetLabel();
1283
1819
 
1284
1820
  document.querySelectorAll('.nav-list a').forEach(link => {
1285
1821
  link.addEventListener('click', (e) => {
@@ -1532,43 +2068,6 @@ function initApp() {
1532
2068
  await loadSandbox(state.currentSandbox.id);
1533
2069
  });
1534
2070
 
1535
- document.getElementById('send-btn')?.addEventListener('click', async () => {
1536
- const input = document.getElementById('chat-input');
1537
- const content = input.value.trim();
1538
- if (!content) return;
1539
-
1540
- const messages = document.getElementById('chat-messages');
1541
- messages.insertAdjacentHTML('beforeend', `<div class="chat-message user">${escapeHtml(content)}</div>`);
1542
- const loadingMessage = appendLoadingMessage(messages);
1543
- messages.scrollTop = messages.scrollHeight;
1544
- input.value = '';
1545
-
1546
- const btn = document.getElementById('send-btn');
1547
- setButtonState(btn, { disabled: true });
1548
-
1549
- try {
1550
- await apiRequest(`${API_BASE}/chats`, {
1551
- method: 'POST',
1552
- body: JSON.stringify({ content }),
1553
- });
1554
-
1555
- loadingMessage?.remove();
1556
- await loadChats();
1557
- } catch (error) {
1558
- loadingMessage?.remove();
1559
- messages.insertAdjacentHTML('beforeend', `<div class="chat-message assistant error">❌ 错误: ${escapeHtml(error.message)}</div>`);
1560
- messages.scrollTop = messages.scrollHeight;
1561
- } finally {
1562
- setButtonState(btn, { disabled: false });
1563
- }
1564
- });
1565
-
1566
- document.getElementById('chat-input')?.addEventListener('keypress', (e) => {
1567
- if (e.key === 'Enter') {
1568
- document.getElementById('send-btn').click();
1569
- }
1570
- });
1571
-
1572
2071
  document.getElementById('sandbox-send-btn')?.addEventListener('click', async () => {
1573
2072
  const input = document.getElementById('sandbox-chat-input');
1574
2073
  const content = input.value.trim();
@@ -1693,18 +2192,53 @@ function initApp() {
1693
2192
  });
1694
2193
 
1695
2194
  document.getElementById('save-diary-btn')?.addEventListener('click', async () => {
1696
- const sandboxId = document.getElementById('diary-sandbox-select').value || null;
1697
2195
  const content = document.getElementById('diary-content').value.trim();
1698
2196
  if (!content) return;
1699
-
1700
- await apiRequest(`${API_BASE}/diaries`, {
1701
- method: 'POST',
1702
- body: JSON.stringify({ sandbox_id: sandboxId, content }),
1703
- });
1704
-
2197
+ await saveDiaryEntry({ content, sandboxId: null, workItemId: null });
1705
2198
  document.getElementById('diary-content').value = '';
1706
2199
  await loadDiaries();
1707
2200
  });
2201
+
2202
+ document.getElementById('drawer-diary-save-btn')?.addEventListener('click', async () => {
2203
+ if (!state.currentSandbox || !state.selectedNodeId) return;
2204
+ const textarea = document.getElementById('drawer-diary-content');
2205
+ if (!(textarea instanceof HTMLTextAreaElement)) return;
2206
+ const content = textarea.value.trim();
2207
+ if (!content) return;
2208
+ const selectedNodeId = state.selectedNodeId;
2209
+ const selectedFilter = state.nodeEntityFilter;
2210
+ await saveDiaryEntry({
2211
+ content,
2212
+ sandboxId: state.currentSandbox.id,
2213
+ workItemId: selectedNodeId,
2214
+ });
2215
+ textarea.value = '';
2216
+ await loadSandbox(state.currentSandbox.id);
2217
+ await loadDiaries();
2218
+ showNodeEntityDrawer(selectedNodeId, selectedFilter);
2219
+ });
2220
+
2221
+ document.getElementById('cancel-edit-diary-btn')?.addEventListener('click', () => {
2222
+ closeDiaryEditDialog();
2223
+ });
2224
+ document.getElementById('confirm-edit-diary-btn')?.addEventListener('click', async () => {
2225
+ await submitDiaryEditDialog();
2226
+ });
2227
+ document.getElementById('diary-edit-content')?.addEventListener('keydown', async (event) => {
2228
+ const isSubmit = event.key === 'Enter' && (event.metaKey || event.ctrlKey);
2229
+ if (!isSubmit) return;
2230
+ event.preventDefault();
2231
+ await submitDiaryEditDialog();
2232
+ });
2233
+ document.getElementById('diary-edit-content')?.addEventListener('input', (event) => {
2234
+ const preview = document.getElementById('diary-edit-preview');
2235
+ if (!(preview instanceof HTMLElement)) return;
2236
+ const target = event.target;
2237
+ const value = target instanceof HTMLTextAreaElement ? target.value : '';
2238
+ const rendered = renderMarkdownSnippet(value);
2239
+ preview.innerHTML = rendered || '<p class="is-empty">在左侧输入 Markdown,这里会实时预览。</p>';
2240
+ preview.classList.toggle('is-empty', !rendered);
2241
+ });
1708
2242
 
1709
2243
  document.getElementById('save-settings-btn')?.addEventListener('click', async () => {
1710
2244
  const api_url = document.getElementById('setting-api-url').value;
@@ -1757,6 +2291,11 @@ function initApp() {
1757
2291
  renderWorkTree();
1758
2292
  });
1759
2293
 
2294
+ document.getElementById('work-item-element-preview-mode')?.addEventListener('change', (e) => {
2295
+ state.workItemElementPreviewMode = e.target.value || 'none';
2296
+ renderWorkTree();
2297
+ });
2298
+
1760
2299
  document.getElementById('work-item-show-assignee-toggle')?.addEventListener('change', (e) => {
1761
2300
  state.workItemShowAssignee = Boolean(e.target.checked);
1762
2301
  persistWorkItemAssigneePreference();
@@ -1773,6 +2312,11 @@ function initApp() {
1773
2312
  renderDiaries();
1774
2313
  });
1775
2314
 
2315
+ document.getElementById('diary-sandbox-filter')?.addEventListener('change', (e) => {
2316
+ state.diarySandboxFilter = e.target.value || '';
2317
+ renderDiaries();
2318
+ });
2319
+
1776
2320
  document.getElementById('generate-report-btn')?.addEventListener('click', () => {
1777
2321
  if (!state.currentSandbox) return;
1778
2322
  const mode = document.getElementById('report-template')?.value || 'management';
@@ -1800,16 +2344,26 @@ function initApp() {
1800
2344
  document.getElementById('generate-insight-btn')?.addEventListener('click', async () => {
1801
2345
  if (!state.currentSandbox) return;
1802
2346
  const output = document.getElementById('sandbox-insight-output');
2347
+ const contentEl = document.getElementById('sandbox-insight-content');
2348
+ if (!(output instanceof HTMLElement) || !(contentEl instanceof HTMLElement)) return;
1803
2349
  output.classList.remove('hidden');
1804
- output.textContent = '生成中...';
2350
+ output.removeAttribute('hidden');
2351
+ contentEl.textContent = '生成中...';
1805
2352
  try {
1806
2353
  const result = await apiRequest(`${API_BASE}/chats/sandbox/${state.currentSandbox.id}/insight`);
1807
- output.textContent = result.insight;
2354
+ contentEl.textContent = result.insight;
1808
2355
  } catch (error) {
1809
- output.textContent = `生成失败:${error.message}`;
2356
+ contentEl.textContent = `生成失败:${error.message}`;
1810
2357
  }
1811
2358
  });
1812
2359
 
2360
+ document.getElementById('close-sandbox-insight-btn')?.addEventListener('click', () => {
2361
+ const output = document.getElementById('sandbox-insight-output');
2362
+ if (!(output instanceof HTMLElement)) return;
2363
+ output.classList.add('hidden');
2364
+ output.setAttribute('hidden', 'hidden');
2365
+ });
2366
+
1813
2367
  document.getElementById('changes-sandbox-filter')?.addEventListener('change', async (e) => {
1814
2368
  state.changesSandboxFilter = e.target.value || '';
1815
2369
  await loadChanges();
@@ -1866,30 +2420,38 @@ function initApp() {
1866
2420
 
1867
2421
  async function handleRoute() {
1868
2422
  const hash = window.location.hash.slice(1) || '/';
2423
+ const [pathHash, queryString = ''] = hash.split('?');
2424
+ const query = new URLSearchParams(queryString);
1869
2425
  const fullscreenRoot = getSandboxFullscreenElement();
1870
- if (!hash.startsWith('/sandbox/') && fullscreenRoot && document.fullscreenElement === fullscreenRoot) {
2426
+ if (!pathHash.startsWith('/sandbox/') && fullscreenRoot && document.fullscreenElement === fullscreenRoot) {
1871
2427
  await document.exitFullscreen();
1872
2428
  }
1873
2429
 
1874
- if (hash === '/') {
1875
- showPage('home');
1876
- await loadChats();
1877
- } else if (hash === '/sandboxes') {
2430
+ if (pathHash === '/') {
1878
2431
  showPage('sandboxes');
1879
2432
  await loadSandboxes();
1880
- } else if (hash.startsWith('/sandbox/')) {
1881
- const id = hash.split('/')[2];
2433
+ } else if (pathHash === '/sandboxes') {
2434
+ showPage('sandboxes');
2435
+ await loadSandboxes();
2436
+ } else if (pathHash.startsWith('/sandbox/')) {
2437
+ const id = decodeURIComponent(pathHash.split('/')[2] || '');
1882
2438
  showPage('sandbox-detail');
1883
2439
  await loadSandbox(id);
1884
- } else if (hash === '/diaries') {
2440
+ const targetNodeId = String(query.get('node_id') || '').trim();
2441
+ const shouldOpenDrawer = query.get('open_drawer') === '1' || Boolean(targetNodeId);
2442
+ const preferredFilter = query.get('entity_filter') || 'all';
2443
+ if (shouldOpenDrawer && targetNodeId) {
2444
+ showNodeEntityDrawer(targetNodeId, preferredFilter);
2445
+ }
2446
+ } else if (pathHash === '/diaries') {
1885
2447
  showPage('diaries');
1886
2448
  await loadDiaries();
1887
2449
  await loadSandboxes();
1888
- } else if (hash === '/changes') {
2450
+ } else if (pathHash === '/changes') {
1889
2451
  showPage('changes');
1890
2452
  await loadSandboxes();
1891
2453
  await loadChanges();
1892
- } else if (hash === '/settings') {
2454
+ } else if (pathHash === '/settings') {
1893
2455
  showPage('settings');
1894
2456
  await loadSettings();
1895
2457
  }