@qnote/q-ai-note 1.0.3 → 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>
771
+ </div>
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>
381
785
  </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>
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)}` : ''}
385
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');
@@ -1196,15 +1740,49 @@ async function loadSettings() {
1196
1740
  apiKeyInput.value = '';
1197
1741
  apiKeyInput.placeholder = state.settings.has_api_key ? '已配置,留空表示不修改' : 'sk-...';
1198
1742
  document.getElementById('setting-model').value = state.settings.model || '';
1743
+ renderSettingHeadersRows(state.settings.headers || {});
1199
1744
  }
1200
1745
 
1201
- async function loadChats() {
1202
- const messages = document.getElementById('chat-messages');
1203
- if (!messages) return;
1204
-
1205
- const chats = await apiRequest(`${API_BASE}/chats`);
1206
- mountHtmlList('chat-messages', chats.map((chat) => renderChatEntry(chat, { safeText, renderAIActionMessage })));
1207
- messages.scrollTop = messages.scrollHeight;
1746
+ function createSettingHeaderRow(key = '', value = '') {
1747
+ const row = document.createElement('div');
1748
+ row.className = 'settings-kv-row';
1749
+ row.innerHTML = `
1750
+ <input type="text" class="setting-header-key" placeholder="Header 名称" value="${escapeHtml(String(key || ''))}">
1751
+ <input type="text" class="setting-header-value" placeholder="Header 值" value="${escapeHtml(String(value || ''))}">
1752
+ <button class="btn btn-secondary btn-sm remove-setting-header-btn" type="button">删除</button>
1753
+ `;
1754
+ row.querySelector('.remove-setting-header-btn')?.addEventListener('click', () => {
1755
+ row.remove();
1756
+ });
1757
+ return row;
1758
+ }
1759
+
1760
+ function renderSettingHeadersRows(headers) {
1761
+ const list = document.getElementById('setting-headers-list');
1762
+ if (!(list instanceof HTMLElement)) return;
1763
+ list.innerHTML = '';
1764
+ const entries = Object.entries(headers || {}).filter(([k, v]) => String(k || '').trim() && String(v || '').trim());
1765
+ if (!entries.length) {
1766
+ list.appendChild(createSettingHeaderRow('', ''));
1767
+ return;
1768
+ }
1769
+ entries.forEach(([k, v]) => list.appendChild(createSettingHeaderRow(String(k), String(v))));
1770
+ }
1771
+
1772
+ function collectSettingHeadersFromUI() {
1773
+ const list = document.getElementById('setting-headers-list');
1774
+ if (!(list instanceof HTMLElement)) return {};
1775
+ const rows = Array.from(list.querySelectorAll('.settings-kv-row'));
1776
+ const headers = {};
1777
+ rows.forEach((row) => {
1778
+ const keyInput = row.querySelector('.setting-header-key');
1779
+ const valueInput = row.querySelector('.setting-header-value');
1780
+ const key = String(keyInput?.value || '').trim();
1781
+ const value = String(valueInput?.value || '').trim();
1782
+ if (!key || !value) return;
1783
+ headers[key] = value;
1784
+ });
1785
+ return headers;
1208
1786
  }
1209
1787
 
1210
1788
  function showPage(pageId) {
@@ -1237,6 +1815,7 @@ function editWorkItem(id) {
1237
1815
  function initApp() {
1238
1816
  const hash = window.location.hash;
1239
1817
  loadWorkItemAssigneePreference();
1818
+ renderQuickDiaryTargetLabel();
1240
1819
 
1241
1820
  document.querySelectorAll('.nav-list a').forEach(link => {
1242
1821
  link.addEventListener('click', (e) => {
@@ -1489,43 +2068,6 @@ function initApp() {
1489
2068
  await loadSandbox(state.currentSandbox.id);
1490
2069
  });
1491
2070
 
1492
- document.getElementById('send-btn')?.addEventListener('click', async () => {
1493
- const input = document.getElementById('chat-input');
1494
- const content = input.value.trim();
1495
- if (!content) return;
1496
-
1497
- const messages = document.getElementById('chat-messages');
1498
- messages.insertAdjacentHTML('beforeend', `<div class="chat-message user">${escapeHtml(content)}</div>`);
1499
- const loadingMessage = appendLoadingMessage(messages);
1500
- messages.scrollTop = messages.scrollHeight;
1501
- input.value = '';
1502
-
1503
- const btn = document.getElementById('send-btn');
1504
- setButtonState(btn, { disabled: true });
1505
-
1506
- try {
1507
- await apiRequest(`${API_BASE}/chats`, {
1508
- method: 'POST',
1509
- body: JSON.stringify({ content }),
1510
- });
1511
-
1512
- loadingMessage?.remove();
1513
- await loadChats();
1514
- } catch (error) {
1515
- loadingMessage?.remove();
1516
- messages.insertAdjacentHTML('beforeend', `<div class="chat-message assistant error">❌ 错误: ${escapeHtml(error.message)}</div>`);
1517
- messages.scrollTop = messages.scrollHeight;
1518
- } finally {
1519
- setButtonState(btn, { disabled: false });
1520
- }
1521
- });
1522
-
1523
- document.getElementById('chat-input')?.addEventListener('keypress', (e) => {
1524
- if (e.key === 'Enter') {
1525
- document.getElementById('send-btn').click();
1526
- }
1527
- });
1528
-
1529
2071
  document.getElementById('sandbox-send-btn')?.addEventListener('click', async () => {
1530
2072
  const input = document.getElementById('sandbox-chat-input');
1531
2073
  const content = input.value.trim();
@@ -1650,23 +2192,59 @@ function initApp() {
1650
2192
  });
1651
2193
 
1652
2194
  document.getElementById('save-diary-btn')?.addEventListener('click', async () => {
1653
- const sandboxId = document.getElementById('diary-sandbox-select').value || null;
1654
2195
  const content = document.getElementById('diary-content').value.trim();
1655
2196
  if (!content) return;
1656
-
1657
- await apiRequest(`${API_BASE}/diaries`, {
1658
- method: 'POST',
1659
- body: JSON.stringify({ sandbox_id: sandboxId, content }),
1660
- });
1661
-
2197
+ await saveDiaryEntry({ content, sandboxId: null, workItemId: null });
1662
2198
  document.getElementById('diary-content').value = '';
1663
2199
  await loadDiaries();
1664
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
+ });
1665
2242
 
1666
2243
  document.getElementById('save-settings-btn')?.addEventListener('click', async () => {
1667
2244
  const api_url = document.getElementById('setting-api-url').value;
1668
2245
  const api_key = document.getElementById('setting-api-key').value;
1669
2246
  const model = document.getElementById('setting-model').value;
2247
+ const headers = collectSettingHeadersFromUI();
1670
2248
 
1671
2249
  await apiRequest(`${API_BASE}/settings/api_url`, {
1672
2250
  method: 'PUT',
@@ -1682,11 +2260,21 @@ function initApp() {
1682
2260
  method: 'PUT',
1683
2261
  body: JSON.stringify({ value: model }),
1684
2262
  });
2263
+ await apiRequest(`${API_BASE}/settings/headers`, {
2264
+ method: 'PUT',
2265
+ body: JSON.stringify({ value: headers }),
2266
+ });
1685
2267
 
1686
2268
  alert('设置已保存');
1687
2269
  await loadSettings();
1688
2270
  });
1689
2271
 
2272
+ document.getElementById('add-setting-header-btn')?.addEventListener('click', () => {
2273
+ const list = document.getElementById('setting-headers-list');
2274
+ if (!(list instanceof HTMLElement)) return;
2275
+ list.appendChild(createSettingHeaderRow('', ''));
2276
+ });
2277
+
1690
2278
  document.getElementById('work-item-search')?.addEventListener('input', (e) => {
1691
2279
  state.workItemSearch = e.target.value || '';
1692
2280
  renderWorkTree();
@@ -1703,6 +2291,11 @@ function initApp() {
1703
2291
  renderWorkTree();
1704
2292
  });
1705
2293
 
2294
+ document.getElementById('work-item-element-preview-mode')?.addEventListener('change', (e) => {
2295
+ state.workItemElementPreviewMode = e.target.value || 'none';
2296
+ renderWorkTree();
2297
+ });
2298
+
1706
2299
  document.getElementById('work-item-show-assignee-toggle')?.addEventListener('change', (e) => {
1707
2300
  state.workItemShowAssignee = Boolean(e.target.checked);
1708
2301
  persistWorkItemAssigneePreference();
@@ -1719,6 +2312,11 @@ function initApp() {
1719
2312
  renderDiaries();
1720
2313
  });
1721
2314
 
2315
+ document.getElementById('diary-sandbox-filter')?.addEventListener('change', (e) => {
2316
+ state.diarySandboxFilter = e.target.value || '';
2317
+ renderDiaries();
2318
+ });
2319
+
1722
2320
  document.getElementById('generate-report-btn')?.addEventListener('click', () => {
1723
2321
  if (!state.currentSandbox) return;
1724
2322
  const mode = document.getElementById('report-template')?.value || 'management';
@@ -1746,16 +2344,26 @@ function initApp() {
1746
2344
  document.getElementById('generate-insight-btn')?.addEventListener('click', async () => {
1747
2345
  if (!state.currentSandbox) return;
1748
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;
1749
2349
  output.classList.remove('hidden');
1750
- output.textContent = '生成中...';
2350
+ output.removeAttribute('hidden');
2351
+ contentEl.textContent = '生成中...';
1751
2352
  try {
1752
2353
  const result = await apiRequest(`${API_BASE}/chats/sandbox/${state.currentSandbox.id}/insight`);
1753
- output.textContent = result.insight;
2354
+ contentEl.textContent = result.insight;
1754
2355
  } catch (error) {
1755
- output.textContent = `生成失败:${error.message}`;
2356
+ contentEl.textContent = `生成失败:${error.message}`;
1756
2357
  }
1757
2358
  });
1758
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
+
1759
2367
  document.getElementById('changes-sandbox-filter')?.addEventListener('change', async (e) => {
1760
2368
  state.changesSandboxFilter = e.target.value || '';
1761
2369
  await loadChanges();
@@ -1812,30 +2420,38 @@ function initApp() {
1812
2420
 
1813
2421
  async function handleRoute() {
1814
2422
  const hash = window.location.hash.slice(1) || '/';
2423
+ const [pathHash, queryString = ''] = hash.split('?');
2424
+ const query = new URLSearchParams(queryString);
1815
2425
  const fullscreenRoot = getSandboxFullscreenElement();
1816
- if (!hash.startsWith('/sandbox/') && fullscreenRoot && document.fullscreenElement === fullscreenRoot) {
2426
+ if (!pathHash.startsWith('/sandbox/') && fullscreenRoot && document.fullscreenElement === fullscreenRoot) {
1817
2427
  await document.exitFullscreen();
1818
2428
  }
1819
2429
 
1820
- if (hash === '/') {
1821
- showPage('home');
1822
- await loadChats();
1823
- } else if (hash === '/sandboxes') {
2430
+ if (pathHash === '/') {
1824
2431
  showPage('sandboxes');
1825
2432
  await loadSandboxes();
1826
- } else if (hash.startsWith('/sandbox/')) {
1827
- 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] || '');
1828
2438
  showPage('sandbox-detail');
1829
2439
  await loadSandbox(id);
1830
- } 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') {
1831
2447
  showPage('diaries');
1832
2448
  await loadDiaries();
1833
2449
  await loadSandboxes();
1834
- } else if (hash === '/changes') {
2450
+ } else if (pathHash === '/changes') {
1835
2451
  showPage('changes');
1836
2452
  await loadSandboxes();
1837
2453
  await loadChanges();
1838
- } else if (hash === '/settings') {
2454
+ } else if (pathHash === '/settings') {
1839
2455
  showPage('settings');
1840
2456
  await loadSettings();
1841
2457
  }