@qnote/q-ai-note 1.0.4 → 1.0.6

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
@@ -9,6 +9,7 @@ const state = {
9
9
  operations: [],
10
10
  chats: [],
11
11
  settings: {},
12
+ readonly: false,
12
13
  pendingAction: null,
13
14
  nodeEntities: [],
14
15
  nodeEntityStats: null,
@@ -16,14 +17,18 @@ const state = {
16
17
  nodeEntityFilter: 'all',
17
18
  editingNodeEntityId: null,
18
19
  nodeEntityFormExpanded: false,
19
- sandboxPresentationMode: false,
20
+ sandboxChatVisible: false,
21
+ sandboxChatVisibleBeforeFullscreen: false,
20
22
  sandboxFullscreenMode: false,
21
23
  workTreeViewMode: 'full',
24
+ workItemElementPreviewMode: 'none',
22
25
  workItemShowAssignee: false,
23
26
  workItemSearch: '',
24
27
  workItemStatusFilter: 'all',
25
28
  diarySearch: '',
29
+ diarySandboxFilter: '',
26
30
  diaryProcessedFilter: 'all',
31
+ diaryWorkItemNameMap: {},
27
32
  changesSandboxFilter: '',
28
33
  changesTypeFilter: 'all',
29
34
  changesQuickFilter: 'all',
@@ -35,6 +40,7 @@ let sandboxActionHandler = null;
35
40
  let sandboxEscLocked = false;
36
41
  let resizeRenderTimer = null;
37
42
  const WORK_ITEM_SHOW_ASSIGNEE_STORAGE_KEY = 'q-ai-note.work-item.show-assignee';
43
+ const WORK_TREE_VIEW_MODE_STORAGE_KEY = 'q-ai-note.work-tree.view-mode';
38
44
 
39
45
  function loadWorkItemAssigneePreference() {
40
46
  try {
@@ -57,14 +63,78 @@ function persistWorkItemAssigneePreference() {
57
63
  }
58
64
  }
59
65
 
60
- function applySandboxLayoutMode() {
66
+ function loadWorkTreeViewModePreference() {
67
+ try {
68
+ const raw = String(window.localStorage.getItem(WORK_TREE_VIEW_MODE_STORAGE_KEY) || '').trim();
69
+ if (raw === 'full' || raw === 'report' || raw === 'dense') {
70
+ state.workTreeViewMode = raw;
71
+ }
72
+ } catch {
73
+ // Ignore storage failures in restricted environments.
74
+ }
75
+ }
76
+
77
+ function persistWorkTreeViewModePreference() {
78
+ try {
79
+ window.localStorage.setItem(WORK_TREE_VIEW_MODE_STORAGE_KEY, state.workTreeViewMode || 'full');
80
+ } catch {
81
+ // Ignore storage failures in restricted environments.
82
+ }
83
+ }
84
+
85
+ function applySandboxChatVisibility() {
61
86
  const layout = document.getElementById('sandbox-layout');
62
- const toggleBtn = document.getElementById('toggle-sandbox-present-btn');
87
+ const toggleBtn = document.getElementById('toggle-sandbox-chat-btn');
63
88
  if (!layout || !toggleBtn) return;
64
- const isPresentation = Boolean(state.sandboxPresentationMode);
65
- layout.classList.toggle('presentation-mode', isPresentation);
66
- toggleBtn.textContent = isPresentation ? '退出展示态' : '展示态布局';
67
- toggleBtn.setAttribute('aria-pressed', isPresentation ? 'true' : 'false');
89
+ const shouldShow = !state.readonly && Boolean(state.sandboxChatVisible);
90
+ layout.classList.toggle('show-chat', shouldShow);
91
+ toggleBtn.classList.toggle('hidden', state.readonly);
92
+ toggleBtn.innerHTML = shouldShow
93
+ ? '<span class="icon" aria-hidden="true">🤖</span><span>隐藏 AI 助手</span>'
94
+ : '<span class="icon" aria-hidden="true">🤖</span><span>显示 AI 助手</span>';
95
+ toggleBtn.setAttribute('aria-pressed', shouldShow ? 'true' : 'false');
96
+ }
97
+
98
+ async function loadRuntimeMode() {
99
+ try {
100
+ const runtime = await apiRequest(`${API_BASE}/runtime`);
101
+ state.readonly = Boolean(runtime?.readonly);
102
+ } catch {
103
+ state.readonly = false;
104
+ }
105
+ }
106
+
107
+ function setHiddenById(id, hidden = true) {
108
+ const el = document.getElementById(id);
109
+ if (!el) return;
110
+ el.classList.toggle('hidden', hidden);
111
+ }
112
+
113
+ function applyReadonlyMode() {
114
+ document.body.classList.toggle('readonly-mode', state.readonly);
115
+ const settingsNav = document.querySelector('[data-nav="settings"]');
116
+ if (settingsNav instanceof HTMLElement) {
117
+ settingsNav.classList.toggle('hidden', state.readonly);
118
+ }
119
+ setHiddenById('page-settings', state.readonly);
120
+ setHiddenById('add-sandbox-btn', state.readonly);
121
+ setHiddenById('add-item-btn', state.readonly);
122
+ setHiddenById('toggle-node-entity-form-btn', state.readonly);
123
+ setHiddenById('drawer-diary-save-btn', state.readonly);
124
+ setHiddenById('save-diary-btn', state.readonly);
125
+ const diaryForm = document.querySelector('#page-diaries .diary-form');
126
+ if (diaryForm instanceof HTMLElement) {
127
+ diaryForm.classList.toggle('hidden', state.readonly);
128
+ }
129
+ const drawerDiaryForm = document.querySelector('#node-entity-drawer .drawer-diary-quick-form');
130
+ if (drawerDiaryForm instanceof HTMLElement) {
131
+ drawerDiaryForm.classList.toggle('hidden', state.readonly);
132
+ }
133
+ if (state.readonly) {
134
+ state.sandboxChatVisible = false;
135
+ closeQuickChatPopover();
136
+ }
137
+ applySandboxChatVisibility();
68
138
  }
69
139
 
70
140
  function getSandboxLayoutElement() {
@@ -97,7 +167,14 @@ function applySandboxFullscreenState() {
97
167
  const layout = getSandboxLayoutElement();
98
168
  const fullscreenRoot = getSandboxFullscreenElement();
99
169
  const toggleBtn = document.getElementById('toggle-sandbox-fullscreen-btn');
170
+ const wasFullscreen = Boolean(state.sandboxFullscreenMode);
100
171
  const isFullscreen = Boolean(fullscreenRoot && document.fullscreenElement === fullscreenRoot);
172
+ if (!wasFullscreen && isFullscreen) {
173
+ state.sandboxChatVisibleBeforeFullscreen = Boolean(state.sandboxChatVisible);
174
+ state.sandboxChatVisible = false;
175
+ } else if (wasFullscreen && !isFullscreen) {
176
+ state.sandboxChatVisible = Boolean(state.sandboxChatVisibleBeforeFullscreen);
177
+ }
101
178
  state.sandboxFullscreenMode = isFullscreen;
102
179
  if (layout) {
103
180
  layout.classList.toggle('is-fullscreen', isFullscreen);
@@ -109,9 +186,22 @@ function applySandboxFullscreenState() {
109
186
  toggleBtn.textContent = isFullscreen ? '退出全屏' : '全屏';
110
187
  toggleBtn.setAttribute('aria-pressed', isFullscreen ? 'true' : 'false');
111
188
  }
189
+ applySandboxChatVisibility();
190
+ applySandboxLayoutHeight();
112
191
  void syncSandboxEscLock(isFullscreen);
113
192
  }
114
193
 
194
+ function applySandboxLayoutHeight() {
195
+ const layout = getSandboxLayoutElement();
196
+ if (!(layout instanceof HTMLElement)) return;
197
+ const rect = layout.getBoundingClientRect();
198
+ const viewportHeight = Math.floor(window.visualViewport?.height || window.innerHeight || 0);
199
+ if (!Number.isFinite(viewportHeight) || viewportHeight <= 0) return;
200
+ const bottomGap = state.sandboxFullscreenMode ? 8 : 10;
201
+ const nextHeight = Math.max(320, viewportHeight - Math.floor(rect.top) - bottomGap);
202
+ layout.style.height = `${nextHeight}px`;
203
+ }
204
+
115
205
  async function toggleSandboxFullscreen() {
116
206
  const fullscreenRoot = getSandboxFullscreenElement();
117
207
  if (!fullscreenRoot) return;
@@ -160,12 +250,14 @@ function applyWorkItemFilters(items) {
160
250
  function applyDiaryFilters(diaries) {
161
251
  const query = state.diarySearch.trim().toLowerCase();
162
252
  const processed = state.diaryProcessedFilter;
253
+ const sandboxId = state.diarySandboxFilter;
163
254
  return diaries.filter((diary) => {
164
255
  const queryMatched = !query || `${diary.content || ''}`.toLowerCase().includes(query);
256
+ const sandboxMatched = !sandboxId || String(diary.sandbox_id || '') === sandboxId;
165
257
  const statusMatched = processed === 'all'
166
258
  || (processed === 'processed' && diary.processed)
167
259
  || (processed === 'unprocessed' && !diary.processed);
168
- return queryMatched && statusMatched;
260
+ return queryMatched && sandboxMatched && statusMatched;
169
261
  });
170
262
  }
171
263
 
@@ -200,6 +292,13 @@ function applyWorkItemAssigneeToggle() {
200
292
  }
201
293
  }
202
294
 
295
+ function applyWorkItemElementPreviewMode() {
296
+ const selector = document.getElementById('work-item-element-preview-mode');
297
+ if (selector instanceof HTMLSelectElement) {
298
+ selector.value = state.workItemElementPreviewMode || 'none';
299
+ }
300
+ }
301
+
203
302
  function renderWorkTree() {
204
303
  const tree = document.getElementById('work-tree');
205
304
  if (!tree || !state.currentSandbox) return;
@@ -207,6 +306,7 @@ function renderWorkTree() {
207
306
  const allItems = state.currentSandbox.items || [];
208
307
  const items = applyWorkItemFilters(allItems);
209
308
  const entitySummaryByNodeId = buildNodeEntitySummaryByNodeId();
309
+ const entityRowsByNodeId = buildNodeEntityRowsByNodeId();
210
310
 
211
311
  if (expandedNodes.size === 0 && allItems.length > 0) {
212
312
  if (state.workTreeViewMode === 'report') {
@@ -217,7 +317,7 @@ function renderWorkTree() {
217
317
  }
218
318
 
219
319
  if (allItems.length === 0) {
220
- tree.innerHTML = '<div class="empty-state"><p>点击上方"添加"按钮创建第一个任务</p></div>';
320
+ tree.innerHTML = `<div class="empty-state"><p>${state.readonly ? '暂无任务' : '点击上方"添加"按钮创建第一个任务'}</p></div>`;
221
321
  return;
222
322
  }
223
323
 
@@ -233,7 +333,7 @@ function renderWorkTree() {
233
333
  }
234
334
  renderWorkTree();
235
335
  },
236
- onAddChild: (parentId) => {
336
+ onAddChild: state.readonly ? undefined : (parentId) => {
237
337
  document.getElementById('item-dialog-title').textContent = '添加子任务';
238
338
  document.getElementById('item-dialog').dataset.editId = '';
239
339
  document.getElementById('new-item-name').value = '';
@@ -244,23 +344,123 @@ function renderWorkTree() {
244
344
  document.getElementById('new-item-parent').value = parentId;
245
345
  document.getElementById('item-dialog').showModal();
246
346
  },
247
- onEdit: (id) => {
347
+ onAddDiary: state.readonly ? undefined : (nodeId) => {
348
+ showNodeEntityDrawer(nodeId, 'diary');
349
+ const textarea = document.getElementById('drawer-diary-content');
350
+ if (textarea instanceof HTMLTextAreaElement) {
351
+ textarea.focus();
352
+ }
353
+ },
354
+ onEdit: state.readonly ? undefined : (id) => {
248
355
  editWorkItem(id);
249
356
  },
250
357
  onSelect: (id) => {
251
358
  showNodeEntityDrawer(id);
252
359
  },
253
- onDelete: async (id) => {
360
+ onSelectEntity: (nodeId, entityType) => {
361
+ showNodeEntityDrawer(nodeId, entityType || 'all');
362
+ },
363
+ onMoveNode: state.readonly ? undefined : async (dragNodeId, newParentId) => {
364
+ if (!state.currentSandbox) return;
365
+ if (!dragNodeId || dragNodeId === newParentId) return;
366
+ const byId = new Map((state.currentSandbox.items || []).map((item) => [item.id, item]));
367
+ const dragNode = byId.get(dragNodeId);
368
+ if (!dragNode) return;
369
+ const nextParentId = newParentId || null;
370
+ if (nextParentId && isDescendantNode(nextParentId, dragNodeId, byId)) {
371
+ alert('不能将节点拖拽到其子节点下。');
372
+ return;
373
+ }
374
+ const siblingItems = (state.currentSandbox.items || [])
375
+ .filter((item) => (item.parent_id || null) === nextParentId && item.id !== dragNodeId)
376
+ .sort((a, b) => compareSiblingOrder(a, b, 'order_key'));
377
+ const left = siblingItems[siblingItems.length - 1] || null;
378
+ const nextOrderKey = rankBetween(getNodeOrderKey(left, 'order_key'), '');
379
+ await apiRequest(`${API_BASE}/items/${dragNodeId}`, {
380
+ method: 'PUT',
381
+ body: JSON.stringify({
382
+ parent_id: nextParentId,
383
+ extra_data: {
384
+ ...(dragNode.extra_data || {}),
385
+ order_key: nextOrderKey,
386
+ },
387
+ }),
388
+ });
389
+ await loadSandbox(state.currentSandbox.id);
390
+ },
391
+ onReorderSiblings: state.readonly ? undefined : async (dragNodeId, targetNodeId, position) => {
392
+ if (!state.currentSandbox) return;
393
+ if (!dragNodeId || !targetNodeId || dragNodeId === targetNodeId) return;
394
+ const byId = new Map((state.currentSandbox.items || []).map((item) => [item.id, item]));
395
+ const dragNode = byId.get(dragNodeId);
396
+ const targetNode = byId.get(targetNodeId);
397
+ if (!dragNode || !targetNode) return;
398
+ if (isDescendantNode(targetNodeId, dragNodeId, byId)) {
399
+ alert('不能将节点排序到其子树内部。');
400
+ return;
401
+ }
402
+ const nextParentId = targetNode.parent_id || null;
403
+ const siblings = (state.currentSandbox.items || [])
404
+ .filter((item) => (item.parent_id || null) === nextParentId && item.id !== dragNodeId)
405
+ .sort((a, b) => compareSiblingOrder(a, b, 'order_key'));
406
+ const targetIndex = siblings.findIndex((item) => item.id === targetNodeId);
407
+ if (targetIndex < 0) return;
408
+ const insertIndex = position === 'after' ? targetIndex + 1 : targetIndex;
409
+ const left = siblings[insertIndex - 1] || null;
410
+ const right = siblings[insertIndex] || null;
411
+ const nextOrderKey = rankBetween(getNodeOrderKey(left, 'order_key'), getNodeOrderKey(right, 'order_key'));
412
+ await apiRequest(`${API_BASE}/items/${dragNodeId}`, {
413
+ method: 'PUT',
414
+ body: JSON.stringify({
415
+ parent_id: nextParentId,
416
+ extra_data: {
417
+ ...(dragNode.extra_data || {}),
418
+ order_key: nextOrderKey,
419
+ },
420
+ }),
421
+ });
422
+ await loadSandbox(state.currentSandbox.id);
423
+ },
424
+ onReorderLanes: state.readonly ? undefined : async (dragRootId, targetRootId) => {
425
+ if (!state.currentSandbox) return;
426
+ if (!dragRootId || !targetRootId || dragRootId === targetRootId) return;
427
+ const roots = (state.currentSandbox.items || []).filter((item) => !item.parent_id);
428
+ const ordered = [...roots].sort((a, b) => compareSiblingOrder(a, b, 'lane_order_key'));
429
+ const fromIdx = ordered.findIndex((item) => item.id === dragRootId);
430
+ const toIdx = ordered.findIndex((item) => item.id === targetRootId);
431
+ if (fromIdx < 0 || toIdx < 0) return;
432
+ const [moved] = ordered.splice(fromIdx, 1);
433
+ ordered.splice(toIdx, 0, moved);
434
+ const movedIndex = ordered.findIndex((item) => item.id === dragRootId);
435
+ const left = ordered[movedIndex - 1] || null;
436
+ const right = ordered[movedIndex + 1] || null;
437
+ const nextLaneKey = rankBetween(getNodeOrderKey(left, 'lane_order_key'), getNodeOrderKey(right, 'lane_order_key'));
438
+ await apiRequest(`${API_BASE}/items/${dragRootId}`, {
439
+ method: 'PUT',
440
+ body: JSON.stringify({
441
+ extra_data: {
442
+ ...(moved.extra_data || {}),
443
+ lane_order_key: nextLaneKey,
444
+ },
445
+ }),
446
+ });
447
+ await loadSandbox(state.currentSandbox.id);
448
+ },
449
+ onDelete: state.readonly ? undefined : async (id) => {
254
450
  if (confirm('确定删除此任务?')) {
255
451
  await apiRequest(`${API_BASE}/items/${id}`, { method: 'DELETE' });
256
452
  await loadSandbox(state.currentSandbox.id);
257
453
  }
258
454
  },
259
- onQuickChat: (id, el) => {
455
+ onQuickChat: state.readonly ? undefined : (id, el) => {
260
456
  openQuickChatPopover(id, el);
261
457
  },
262
458
  renderMode: state.workTreeViewMode === 'dense' ? 'dense' : 'card',
263
459
  showAssignee: state.workItemShowAssignee,
460
+ elementPreviewMode: state.workItemElementPreviewMode,
461
+ entityRowsByNodeId,
462
+ readonly: state.readonly,
463
+ selectedId: state.selectedNodeId || '',
264
464
  });
265
465
 
266
466
  populateParentSelect(allItems);
@@ -289,6 +489,87 @@ function buildNodeEntitySummaryByNodeId() {
289
489
  return summaryByNodeId;
290
490
  }
291
491
 
492
+ function buildNodeEntityRowsByNodeId() {
493
+ const rowsByNodeId = {};
494
+ for (const row of state.nodeEntities || []) {
495
+ const nodeId = String(row.work_item_id || '');
496
+ if (!nodeId) continue;
497
+ if (!rowsByNodeId[nodeId]) rowsByNodeId[nodeId] = [];
498
+ rowsByNodeId[nodeId].push({
499
+ id: row.id,
500
+ entity_type: row.entity_type,
501
+ title: row.title || '',
502
+ status: row.status || '',
503
+ capability_type: row.capability_type || '',
504
+ });
505
+ }
506
+ return rowsByNodeId;
507
+ }
508
+
509
+ function isDescendantNode(candidateNodeId, parentNodeId, byId) {
510
+ let cursor = byId.get(candidateNodeId);
511
+ while (cursor && cursor.parent_id) {
512
+ if (cursor.parent_id === parentNodeId) return true;
513
+ cursor = byId.get(cursor.parent_id);
514
+ }
515
+ return false;
516
+ }
517
+
518
+ const ORDER_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
519
+ const ORDER_BASE = ORDER_ALPHABET.length;
520
+
521
+ function getOrderCharIndex(ch) {
522
+ if (!ch) return -1;
523
+ return ORDER_ALPHABET.indexOf(ch);
524
+ }
525
+
526
+ function rankBetween(left, right) {
527
+ const leftKey = String(left || '');
528
+ const rightKey = String(right || '');
529
+ if (leftKey && rightKey && leftKey >= rightKey) {
530
+ return `${leftKey}${ORDER_ALPHABET[Math.floor(ORDER_BASE / 2)]}`;
531
+ }
532
+ let i = 0;
533
+ let prefix = '';
534
+ while (i < 64) {
535
+ const leftDigit = i < leftKey.length ? getOrderCharIndex(leftKey[i]) : -1;
536
+ const rightDigit = i < rightKey.length ? getOrderCharIndex(rightKey[i]) : ORDER_BASE;
537
+ if (leftDigit < 0 && i < leftKey.length) {
538
+ return `${prefix}${ORDER_ALPHABET[Math.floor(ORDER_BASE / 2)]}`;
539
+ }
540
+ if (rightDigit < 0 && i < rightKey.length) {
541
+ return `${prefix}${ORDER_ALPHABET[Math.floor(ORDER_BASE / 2)]}`;
542
+ }
543
+ if (rightDigit - leftDigit > 1) {
544
+ const mid = Math.floor((leftDigit + rightDigit) / 2);
545
+ return `${prefix}${ORDER_ALPHABET[mid]}`;
546
+ }
547
+ prefix += i < leftKey.length ? leftKey[i] : ORDER_ALPHABET[0];
548
+ i += 1;
549
+ }
550
+ return `${prefix}${ORDER_ALPHABET[Math.floor(ORDER_BASE / 2)]}`;
551
+ }
552
+
553
+ function getNodeOrderKey(item, keyName = 'order_key') {
554
+ return String(item?.extra_data?.[keyName] || '').trim();
555
+ }
556
+
557
+ function compareSiblingOrder(a, b, keyName = 'order_key') {
558
+ const keyA = getNodeOrderKey(a, keyName);
559
+ const keyB = getNodeOrderKey(b, keyName);
560
+ if (keyA && keyB && keyA !== keyB) return keyA.localeCompare(keyB);
561
+ if (keyA && !keyB) return -1;
562
+ if (!keyA && keyB) return 1;
563
+ const numericA = Number(a?.extra_data?.lane_order);
564
+ const numericB = Number(b?.extra_data?.lane_order);
565
+ const validA = Number.isFinite(numericA);
566
+ const validB = Number.isFinite(numericB);
567
+ if (keyName === 'lane_order_key' && validA && validB && numericA !== numericB) return numericA - numericB;
568
+ if (keyName === 'lane_order_key' && validA && !validB) return -1;
569
+ if (keyName === 'lane_order_key' && !validA && validB) return 1;
570
+ return String(a?.created_at || '').localeCompare(String(b?.created_at || ''));
571
+ }
572
+
292
573
  function populateParentSelect(items) {
293
574
  const select = document.getElementById('new-item-parent');
294
575
  if (!select) return;
@@ -298,22 +579,182 @@ function populateParentSelect(items) {
298
579
  }
299
580
 
300
581
  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
- });
582
+ const source = String(text || '').replace(/\r\n/g, '\n');
583
+ const lines = source.split('\n');
584
+ const blocks = [];
585
+ let listItems = [];
586
+
587
+ const flushList = () => {
588
+ if (!listItems.length) return;
589
+ blocks.push(`<ul>${listItems.map((item) => `<li>${item}</li>`).join('')}</ul>`);
590
+ listItems = [];
591
+ };
592
+
593
+ const renderInline = (raw) => {
594
+ let html = safeText(String(raw || ''));
595
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
596
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
597
+ html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
598
+ html = html.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_m, label, url) => {
599
+ const safeUrl = safeText(url);
600
+ const safeLabel = safeText(label);
601
+ return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`;
602
+ });
603
+ return html;
604
+ };
605
+
606
+ for (const line of lines) {
607
+ const trimmed = line.trim();
608
+ if (!trimmed) {
609
+ flushList();
610
+ continue;
611
+ }
612
+ if (/^[-*]\s+/.test(trimmed)) {
613
+ listItems.push(renderInline(trimmed.replace(/^[-*]\s+/, '')));
614
+ continue;
615
+ }
616
+ flushList();
617
+ const heading = trimmed.match(/^(#{1,6})\s+(.+)$/);
618
+ if (heading) {
619
+ const level = Math.min(6, heading[1].length);
620
+ blocks.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
621
+ continue;
622
+ }
623
+ blocks.push(`<p>${renderInline(trimmed)}</p>`);
624
+ }
625
+ flushList();
626
+ return blocks.join('');
307
627
  }
308
628
 
309
629
  function getNodeById(nodeId) {
310
630
  return state.currentSandbox?.items?.find((item) => item.id === nodeId) || null;
311
631
  }
312
632
 
633
+ function getWorkItemNameById(nodeId) {
634
+ const row = getNodeById(nodeId);
635
+ return row?.name || String(nodeId || '');
636
+ }
637
+
638
+ function renderQuickDiaryTargetLabel() {
639
+ const el = document.getElementById('sandbox-diary-target-label');
640
+ if (!(el instanceof HTMLElement)) return;
641
+ if (!state.currentSandbox) {
642
+ el.textContent = '快速日记:未进入沙盘';
643
+ return;
644
+ }
645
+ const nodeId = state.selectedNodeId;
646
+ if (!nodeId) {
647
+ el.textContent = `快速日记:关联沙盘 ${state.currentSandbox.name}`;
648
+ return;
649
+ }
650
+ el.textContent = `快速日记:${state.currentSandbox.name} / ${getWorkItemNameById(nodeId)}`;
651
+ }
652
+
653
+ async function saveDiaryEntry({ content, sandboxId = null, workItemId = null }) {
654
+ const payload = {
655
+ sandbox_id: sandboxId,
656
+ work_item_id: workItemId,
657
+ content: String(content || '').trim(),
658
+ };
659
+ if (!payload.content) return null;
660
+ return apiRequest(`${API_BASE}/diaries`, {
661
+ method: 'POST',
662
+ body: JSON.stringify(payload),
663
+ });
664
+ }
665
+
666
+ function shouldAutoCaptureDiary(text) {
667
+ const normalized = String(text || '').trim().toLowerCase();
668
+ if (!normalized) return false;
669
+ if (normalized.length < 2) return false;
670
+ const patterns = [
671
+ /记录/,
672
+ /进展/,
673
+ /日志/,
674
+ /汇报/,
675
+ /同步/,
676
+ /今日.*完成/,
677
+ /今天.*完成/,
678
+ /progress/,
679
+ /update/,
680
+ ];
681
+ return patterns.some((pattern) => pattern.test(normalized));
682
+ }
683
+
684
+ function resolveDiaryCaptureIntent(text) {
685
+ const raw = String(text || '').trim();
686
+ const auto = shouldAutoCaptureDiary(raw);
687
+ const directPrefix = /^(请\s*)?(帮我\s*)?(记录(日记|日志)|写(日记|日志)|日记|日志)\s*[::]?\s*/;
688
+ const isDirectDiaryCommand = directPrefix.test(raw);
689
+ const stripped = isDirectDiaryCommand ? raw.replace(directPrefix, '').trim() : raw;
690
+ return {
691
+ shouldCapture: auto,
692
+ isDirectDiaryCommand,
693
+ diaryContent: stripped || raw,
694
+ };
695
+ }
696
+
697
+ function parseNodeIdFromNodeContext(payloadContent) {
698
+ const text = String(payloadContent || '');
699
+ const match = text.match(/node_id=([^\n]+)/);
700
+ if (!match) return null;
701
+ const nodeId = String(match[1] || '').trim();
702
+ return nodeId || null;
703
+ }
704
+
705
+ function openDiaryEditDialog(diary) {
706
+ const dialog = document.getElementById('diary-edit-dialog');
707
+ const textarea = document.getElementById('diary-edit-content');
708
+ const preview = document.getElementById('diary-edit-preview');
709
+ if (!(dialog instanceof HTMLDialogElement) || !(textarea instanceof HTMLTextAreaElement)) return;
710
+ dialog.dataset.editDiaryId = String(diary?.id || '');
711
+ textarea.value = String(diary?.content || '');
712
+ if (preview instanceof HTMLElement) {
713
+ const rendered = renderMarkdownSnippet(textarea.value);
714
+ preview.innerHTML = rendered || '<p class="is-empty">在左侧输入 Markdown,这里会实时预览。</p>';
715
+ preview.classList.toggle('is-empty', !rendered);
716
+ }
717
+ dialog.showModal();
718
+ textarea.focus();
719
+ }
720
+
721
+ function closeDiaryEditDialog() {
722
+ const dialog = document.getElementById('diary-edit-dialog');
723
+ if (!(dialog instanceof HTMLDialogElement)) return;
724
+ dialog.close();
725
+ }
726
+
727
+ async function submitDiaryEditDialog() {
728
+ const dialog = document.getElementById('diary-edit-dialog');
729
+ const textarea = document.getElementById('diary-edit-content');
730
+ if (!(dialog instanceof HTMLDialogElement) || !(textarea instanceof HTMLTextAreaElement)) return;
731
+ const diaryId = String(dialog.dataset.editDiaryId || '').trim();
732
+ const content = textarea.value.trim();
733
+ if (!diaryId || !content) return;
734
+ await apiRequest(`${API_BASE}/diaries/${diaryId}`, {
735
+ method: 'PUT',
736
+ body: JSON.stringify({ content }),
737
+ });
738
+ const selectedNodeId = state.selectedNodeId;
739
+ const selectedFilter = state.nodeEntityFilter;
740
+ closeDiaryEditDialog();
741
+ await loadDiaries();
742
+ if (state.currentSandbox?.id) {
743
+ await loadSandbox(state.currentSandbox.id);
744
+ if (selectedNodeId) {
745
+ showNodeEntityDrawer(selectedNodeId, selectedFilter);
746
+ }
747
+ }
748
+ }
749
+
313
750
  function getNodeEntitiesByNodeId(nodeId) {
314
751
  return (state.nodeEntities || []).filter((row) => row.work_item_id === nodeId);
315
752
  }
316
753
 
754
+ function getNodeDiariesByNodeId(nodeId) {
755
+ return (state.currentSandbox?.diaries || []).filter((row) => row.work_item_id === nodeId);
756
+ }
757
+
317
758
  function closeNodeEntityDrawer() {
318
759
  const drawer = document.getElementById('node-entity-drawer');
319
760
  if (!drawer) return;
@@ -322,6 +763,7 @@ function closeNodeEntityDrawer() {
322
763
  state.nodeEntityFilter = 'all';
323
764
  state.editingNodeEntityId = null;
324
765
  state.nodeEntityFormExpanded = false;
766
+ renderWorkTree();
325
767
  }
326
768
 
327
769
  function setNodeEntityFormExpanded(expanded) {
@@ -349,6 +791,7 @@ function renderNodeEntitySummary(nodeId) {
349
791
  const container = document.getElementById('node-entity-summary');
350
792
  if (!container) return;
351
793
  const rows = getNodeEntitiesByNodeId(nodeId);
794
+ const diaries = getNodeDiariesByNodeId(nodeId);
352
795
  const issues = rows.filter((row) => row.entity_type === 'issue');
353
796
  const knowledges = rows.filter((row) => row.entity_type === 'knowledge');
354
797
  const capabilities = rows.filter((row) => row.entity_type === 'capability');
@@ -358,38 +801,92 @@ function renderNodeEntitySummary(nodeId) {
358
801
  <div class="summary-card"><div class="label">Open Issue</div><div class="value">${openIssues}</div></div>
359
802
  <div class="summary-card"><div class="label">Knowledge</div><div class="value">${knowledges.length}</div></div>
360
803
  <div class="summary-card"><div class="label">Capability</div><div class="value">${capabilities.length}</div></div>
804
+ <div class="summary-card"><div class="label">Diary</div><div class="value">${diaries.length}</div></div>
361
805
  `;
362
806
  }
363
807
 
808
+ async function processNodeDiary(diaryId, action) {
809
+ if (!state.currentSandbox) return;
810
+ await apiRequest(`${API_BASE}/diaries/${diaryId}/process`, {
811
+ method: 'PUT',
812
+ body: JSON.stringify({ action }),
813
+ });
814
+ const selectedNodeId = state.selectedNodeId;
815
+ const selectedFilter = state.nodeEntityFilter;
816
+ await loadSandbox(state.currentSandbox.id);
817
+ await loadDiaries();
818
+ if (selectedNodeId) {
819
+ showNodeEntityDrawer(selectedNodeId, selectedFilter);
820
+ }
821
+ }
822
+
364
823
  function renderNodeEntityList(nodeId) {
365
824
  const container = document.getElementById('node-entity-list');
366
825
  if (!container) return;
367
- const allRows = getNodeEntitiesByNodeId(nodeId);
826
+ const allEntityRows = getNodeEntitiesByNodeId(nodeId);
827
+ const allDiaryRows = getNodeDiariesByNodeId(nodeId);
828
+ const timelineRows = [
829
+ ...allEntityRows.map((row) => ({ ...row, timeline_type: row.entity_type || 'issue' })),
830
+ ...allDiaryRows.map((row) => ({ ...row, timeline_type: 'diary' })),
831
+ ].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
368
832
  const rows = state.nodeEntityFilter === 'all'
369
- ? allRows
370
- : allRows.filter((row) => row.entity_type === state.nodeEntityFilter);
833
+ ? timelineRows
834
+ : timelineRows.filter((row) => row.timeline_type === state.nodeEntityFilter);
371
835
  if (!rows.length) {
372
- container.innerHTML = '<div class="empty-state"><p>当前节点还没有 issue/knowledge/capability</p></div>';
836
+ container.innerHTML = '<div class="empty-state"><p>当前筛选下暂无记录</p></div>';
373
837
  return;
374
838
  }
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>
839
+ container.innerHTML = rows.map((row) => {
840
+ if (row.timeline_type === 'diary') {
841
+ return `
842
+ <div class="entity-card diary-card ${row.processed ? 'processed' : ''}">
843
+ <div class="entity-card-header">
844
+ <div>
845
+ <span class="entity-type-pill">diary</span>
846
+ <strong>${safeText('日志记录')}</strong>
847
+ </div>
848
+ ${state.readonly ? '' : `
849
+ <div class="entity-card-actions">
850
+ <button class="btn btn-secondary btn-sm" data-diary-edit-id="${safeText(row.id)}">编辑</button>
851
+ ${row.processed ? '' : `
852
+ <button class="btn btn-secondary btn-sm" data-diary-confirm-id="${safeText(row.id)}">采纳</button>
853
+ <button class="btn btn-secondary btn-sm" data-diary-ignore-id="${safeText(row.id)}">忽略</button>
854
+ `}
855
+ </div>
856
+ `}
857
+ </div>
858
+ <div class="entity-meta">
859
+ ${safeText(new Date(row.created_at).toLocaleString())}
860
+ ${row.processed ? ' · 已处理' : ' · 未处理'}
861
+ </div>
862
+ <div class="entity-content">${renderMarkdownSnippet(row.content || '')}</div>
863
+ </div>
864
+ `;
865
+ }
866
+ return `
867
+ <div class="entity-card">
868
+ <div class="entity-card-header">
869
+ <div>
870
+ <span class="entity-type-pill">${safeText(row.entity_type)}</span>
871
+ <strong>${safeText(row.title || '-')}</strong>
872
+ </div>
873
+ ${state.readonly ? '' : `
874
+ <div class="entity-card-actions">
875
+ <button class="btn btn-secondary btn-sm" data-entity-edit-id="${safeText(row.id)}">编辑</button>
876
+ <button class="btn btn-secondary btn-sm" data-entity-delete-id="${safeText(row.id)}">删除</button>
877
+ </div>
878
+ `}
381
879
  </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>
880
+ <div class="entity-meta">
881
+ ${safeText(new Date(row.created_at).toLocaleString())}
882
+ ${row.status ? ` · <span class="entity-status-pill ${safeText(row.status)}">${safeText(row.status)}</span>` : ''}
883
+ ${row.priority ? ` · ${safeText(row.priority)}` : ''}
884
+ ${row.assignee ? ` · @${safeText(row.assignee)}` : ''}
385
885
  </div>
886
+ <div class="entity-content">${renderMarkdownSnippet(row.content_md || '')}</div>
386
887
  </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('');
888
+ `;
889
+ }).join('');
393
890
 
394
891
  container.querySelectorAll('[data-entity-delete-id]').forEach((el) => {
395
892
  el.addEventListener('click', async (e) => {
@@ -406,11 +903,40 @@ function renderNodeEntityList(nodeId) {
406
903
  e.preventDefault();
407
904
  const id = el.getAttribute('data-entity-edit-id');
408
905
  if (!id) return;
409
- const row = allRows.find((item) => item.id === id);
906
+ const row = allEntityRows.find((item) => item.id === id);
410
907
  if (!row) return;
411
908
  startEditNodeEntity(row);
412
909
  });
413
910
  });
911
+
912
+ container.querySelectorAll('[data-diary-edit-id]').forEach((el) => {
913
+ el.addEventListener('click', (e) => {
914
+ e.preventDefault();
915
+ const id = el.getAttribute('data-diary-edit-id');
916
+ if (!id) return;
917
+ const row = allDiaryRows.find((item) => item.id === id);
918
+ if (!row) return;
919
+ openDiaryEditDialog(row);
920
+ });
921
+ });
922
+
923
+ container.querySelectorAll('[data-diary-confirm-id]').forEach((el) => {
924
+ el.addEventListener('click', async (e) => {
925
+ e.preventDefault();
926
+ const id = el.getAttribute('data-diary-confirm-id');
927
+ if (!id) return;
928
+ await processNodeDiary(id, 'confirm');
929
+ });
930
+ });
931
+
932
+ container.querySelectorAll('[data-diary-ignore-id]').forEach((el) => {
933
+ el.addEventListener('click', async (e) => {
934
+ e.preventDefault();
935
+ const id = el.getAttribute('data-diary-ignore-id');
936
+ if (!id) return;
937
+ await processNodeDiary(id, 'ignore');
938
+ });
939
+ });
414
940
  }
415
941
 
416
942
  function resetNodeEntityForm() {
@@ -438,6 +964,19 @@ function resetNodeEntityForm() {
438
964
  setNodeEntityFormExpanded(false);
439
965
  }
440
966
 
967
+ function ensureCapabilityTypeOption(value) {
968
+ const capabilityTypeInput = document.getElementById('entity-capability-type-input');
969
+ if (!(capabilityTypeInput instanceof HTMLSelectElement)) return;
970
+ const normalized = String(value || '').trim();
971
+ if (!normalized) return;
972
+ const hasOption = Array.from(capabilityTypeInput.options).some((option) => option.value === normalized);
973
+ if (hasOption) return;
974
+ const option = document.createElement('option');
975
+ option.value = normalized;
976
+ option.textContent = `${normalized}(历史值)`;
977
+ capabilityTypeInput.appendChild(option);
978
+ }
979
+
441
980
  function startEditNodeEntity(row) {
442
981
  state.editingNodeEntityId = row.id;
443
982
  setNodeEntityFormExpanded(true);
@@ -455,6 +994,7 @@ function startEditNodeEntity(row) {
455
994
  if (assigneeInput) assigneeInput.value = row.assignee || '';
456
995
  if (statusInput) statusInput.value = row.status || '';
457
996
  if (priorityInput) priorityInput.value = row.priority || '';
997
+ ensureCapabilityTypeOption(row.capability_type || '');
458
998
  if (capabilityTypeInput) capabilityTypeInput.value = row.capability_type || '';
459
999
 
460
1000
  const submitBtn = document.getElementById('create-node-entity-btn');
@@ -464,15 +1004,18 @@ function startEditNodeEntity(row) {
464
1004
  titleInput?.focus();
465
1005
  }
466
1006
 
467
- function showNodeEntityDrawer(nodeId) {
1007
+ function showNodeEntityDrawer(nodeId, preferredFilter = 'all') {
468
1008
  const drawer = document.getElementById('node-entity-drawer');
469
1009
  const title = document.getElementById('drawer-node-title');
470
1010
  const node = getNodeById(nodeId);
471
1011
  if (!drawer || !title || !node) return;
472
1012
  state.selectedNodeId = nodeId;
473
- state.nodeEntityFilter = 'all';
1013
+ const filter = ['all', 'issue', 'knowledge', 'capability', 'diary'].includes(preferredFilter) ? preferredFilter : 'all';
1014
+ state.nodeEntityFilter = filter;
474
1015
  title.textContent = node.name || nodeId;
475
1016
  renderNodeEntitySummary(nodeId);
1017
+ renderWorkTree();
1018
+ renderQuickDiaryTargetLabel();
476
1019
  renderNodeEntityFilterTabs();
477
1020
  renderNodeEntityList(nodeId);
478
1021
  resetNodeEntityForm();
@@ -583,6 +1126,7 @@ function composeQuickChatContent(nodeId, userQuestion) {
583
1126
  }
584
1127
 
585
1128
  async function sendSandboxChatMessage(content, options = {}) {
1129
+ if (state.readonly) return;
586
1130
  if (!content || !state.currentSandbox) return;
587
1131
  const messages = document.getElementById('sandbox-chat-messages');
588
1132
  const btn = document.getElementById('sandbox-send-btn');
@@ -590,6 +1134,8 @@ async function sendSandboxChatMessage(content, options = {}) {
590
1134
 
591
1135
  const displayContent = options.displayContent || content;
592
1136
  const payloadContent = options.payloadContent || content;
1137
+ const autoDiaryWorkItemId = options.workItemId || parseNodeIdFromNodeContext(payloadContent);
1138
+ const diaryIntent = resolveDiaryCaptureIntent(displayContent || content);
593
1139
  messages.insertAdjacentHTML('beforeend', `<div class="chat-message user">${escapeHtml(displayContent)}</div>`);
594
1140
  const loadingMessage = appendLoadingMessage(messages);
595
1141
  messages.scrollTop = messages.scrollHeight;
@@ -598,6 +1144,31 @@ async function sendSandboxChatMessage(content, options = {}) {
598
1144
  setButtonState(btn, { disabled: true, text: '思考中' });
599
1145
 
600
1146
  try {
1147
+ let createdDiary = null;
1148
+ if (diaryIntent.shouldCapture) {
1149
+ createdDiary = await saveDiaryEntry({
1150
+ content: diaryIntent.diaryContent,
1151
+ sandboxId: state.currentSandbox.id,
1152
+ workItemId: autoDiaryWorkItemId || null,
1153
+ });
1154
+ }
1155
+ if (diaryIntent.isDirectDiaryCommand) {
1156
+ loadingMessage?.remove();
1157
+ if (createdDiary && state.currentSandbox) {
1158
+ state.currentSandbox.diaries = [createdDiary, ...(state.currentSandbox.diaries || [])];
1159
+ renderSandboxOverview();
1160
+ if (state.selectedNodeId && String(createdDiary.work_item_id || '') === String(state.selectedNodeId)) {
1161
+ renderNodeEntitySummary(state.selectedNodeId);
1162
+ renderNodeEntityList(state.selectedNodeId);
1163
+ }
1164
+ }
1165
+ messages.insertAdjacentHTML('beforeend', '<div class="chat-message assistant">📝 已记录日记。</div>');
1166
+ messages.scrollTop = messages.scrollHeight;
1167
+ if (state.currentSandbox) {
1168
+ await loadDiaries();
1169
+ }
1170
+ return;
1171
+ }
601
1172
  const history = state.chats
602
1173
  .filter(c => c.role)
603
1174
  .slice(-10)
@@ -693,7 +1264,7 @@ function openQuickChatPopover(nodeId, anchorEl) {
693
1264
  closeQuickChatPopover();
694
1265
  const payloadContent = composeQuickChatContent(nodeId, question);
695
1266
  const displayContent = `【快捷】${question}`;
696
- await sendSandboxChatMessage(question, { payloadContent, displayContent });
1267
+ await sendSandboxChatMessage(question, { payloadContent, displayContent, workItemId: nodeId });
697
1268
  });
698
1269
  textarea?.addEventListener('input', updateSubmitState);
699
1270
  textarea?.addEventListener('keydown', async (event) => {
@@ -746,12 +1317,13 @@ function renderSandboxes() {
746
1317
  onOpen: (id) => {
747
1318
  window.location.hash = `/sandbox/${id}`;
748
1319
  },
749
- onDelete: async (id) => {
1320
+ onDelete: state.readonly ? undefined : async (id) => {
750
1321
  if (confirm('确定删除此沙盘?')) {
751
1322
  await apiRequest(`${API_BASE}/sandboxes/${id}`, { method: 'DELETE' });
752
1323
  await loadSandboxes();
753
1324
  }
754
- }
1325
+ },
1326
+ readonly: state.readonly,
755
1327
  });
756
1328
  }
757
1329
 
@@ -771,11 +1343,20 @@ async function loadSandbox(id) {
771
1343
  document.getElementById('sandbox-title').textContent = sandbox.name;
772
1344
  applyWorkTreeViewMode(state.workTreeViewMode || 'full');
773
1345
  applyWorkItemAssigneeToggle();
774
- applySandboxLayoutMode();
1346
+ applyWorkItemElementPreviewMode();
1347
+ applySandboxChatVisibility();
775
1348
  applySandboxFullscreenState();
1349
+ renderQuickDiaryTargetLabel();
776
1350
  renderSandboxOverview();
777
1351
  renderWorkTree();
778
- loadSandboxChats(id);
1352
+ applySandboxLayoutHeight();
1353
+ if (state.readonly) {
1354
+ state.chats = [];
1355
+ const messages = document.getElementById('sandbox-chat-messages');
1356
+ if (messages) messages.innerHTML = '';
1357
+ } else {
1358
+ loadSandboxChats(id);
1359
+ }
779
1360
  }
780
1361
 
781
1362
  function renderSandboxOverview() {
@@ -800,6 +1381,7 @@ function renderSandboxOverview() {
800
1381
  }
801
1382
 
802
1383
  async function loadSandboxChats(sandboxId) {
1384
+ if (state.readonly) return;
803
1385
  const messages = document.getElementById('sandbox-chat-messages');
804
1386
  if (!messages) return;
805
1387
 
@@ -886,8 +1468,63 @@ window.undoOperation = async function(operationId, btn) {
886
1468
  }
887
1469
  };
888
1470
 
1471
+ async function hydrateDiaryWorkItemNames(diaries) {
1472
+ const map = {};
1473
+ const sandboxIds = Array.from(
1474
+ new Set(
1475
+ (diaries || [])
1476
+ .filter((row) => row?.sandbox_id && row?.work_item_id)
1477
+ .map((row) => String(row.sandbox_id)),
1478
+ ),
1479
+ );
1480
+ await Promise.all(sandboxIds.map(async (sandboxId) => {
1481
+ try {
1482
+ const items = await apiRequest(`${API_BASE}/sandboxes/${sandboxId}/items`);
1483
+ (items || []).forEach((item) => {
1484
+ const key = `${sandboxId}:${item.id}`;
1485
+ map[key] = item.name || item.id;
1486
+ });
1487
+ } catch {
1488
+ // Ignore per-sandbox fetch failure to keep diary page available.
1489
+ }
1490
+ }));
1491
+ state.diaryWorkItemNameMap = map;
1492
+ }
1493
+
1494
+ function getDiaryWorkItemName(sandboxId, workItemId) {
1495
+ if (!workItemId) return '';
1496
+ const sandboxKey = String(sandboxId || '').trim();
1497
+ const itemKey = String(workItemId || '').trim();
1498
+ if (!itemKey) return '';
1499
+ if (sandboxKey) {
1500
+ const key = `${sandboxKey}:${itemKey}`;
1501
+ if (state.diaryWorkItemNameMap[key]) {
1502
+ return state.diaryWorkItemNameMap[key];
1503
+ }
1504
+ }
1505
+ if (state.currentSandbox?.id === sandboxKey) {
1506
+ const node = (state.currentSandbox.items || []).find((item) => item.id === itemKey);
1507
+ if (node?.name) return node.name;
1508
+ }
1509
+ return itemKey;
1510
+ }
1511
+
1512
+ function openDiaryWorkItemInSandbox(sandboxId, workItemId) {
1513
+ const targetSandboxId = String(sandboxId || '').trim();
1514
+ const targetWorkItemId = String(workItemId || '').trim();
1515
+ if (!targetSandboxId || !targetWorkItemId) return;
1516
+ const nextHash = `/sandbox/${encodeURIComponent(targetSandboxId)}?node_id=${encodeURIComponent(targetWorkItemId)}&open_drawer=1`;
1517
+ const currentHash = window.location.hash.slice(1);
1518
+ if (currentHash === nextHash && state.currentSandbox?.id === targetSandboxId) {
1519
+ showNodeEntityDrawer(targetWorkItemId);
1520
+ return;
1521
+ }
1522
+ window.location.hash = nextHash;
1523
+ }
1524
+
889
1525
  async function loadDiaries() {
890
1526
  state.diaries = await apiRequest(`${API_BASE}/diaries`);
1527
+ await hydrateDiaryWorkItemNames(state.diaries);
891
1528
  renderDiaries();
892
1529
  }
893
1530
 
@@ -899,20 +1536,31 @@ function renderDiaries() {
899
1536
  mountDiaryTimeline('diary-timeline', {
900
1537
  diaries: filtered,
901
1538
  getSandboxName,
902
- onConfirm: async (id) => {
1539
+ getWorkItemName: getDiaryWorkItemName,
1540
+ onOpenWorkItem: (sandboxId, workItemId) => {
1541
+ openDiaryWorkItemInSandbox(sandboxId, workItemId);
1542
+ },
1543
+ renderContent: (content) => renderMarkdownSnippet(content),
1544
+ onConfirm: state.readonly ? undefined : async (id) => {
903
1545
  await apiRequest(`${API_BASE}/diaries/${id}/process`, {
904
1546
  method: 'PUT',
905
1547
  body: JSON.stringify({ action: 'confirm' }),
906
1548
  });
907
1549
  await loadDiaries();
908
1550
  },
909
- onIgnore: async (id) => {
1551
+ onIgnore: state.readonly ? undefined : async (id) => {
910
1552
  await apiRequest(`${API_BASE}/diaries/${id}/process`, {
911
1553
  method: 'PUT',
912
1554
  body: JSON.stringify({ action: 'ignore' }),
913
1555
  });
914
1556
  await loadDiaries();
915
- }
1557
+ },
1558
+ onEdit: state.readonly ? undefined : async (id) => {
1559
+ const diary = state.diaries.find((row) => row.id === id);
1560
+ if (!diary) return;
1561
+ openDiaryEditDialog(diary);
1562
+ },
1563
+ readonly: state.readonly,
916
1564
  });
917
1565
  }
918
1566
 
@@ -922,10 +1570,12 @@ function getSandboxName(id) {
922
1570
  }
923
1571
 
924
1572
  function updateSandboxSelect() {
925
- const diarySelect = document.getElementById('diary-sandbox-select');
926
- if (diarySelect) {
927
- diarySelect.innerHTML = '<option value="">选择沙盘(可选)</option>' +
1573
+ const diaryFilterSelect = document.getElementById('diary-sandbox-filter');
1574
+ if (diaryFilterSelect) {
1575
+ const current = state.diarySandboxFilter || '';
1576
+ diaryFilterSelect.innerHTML = '<option value="">全部关联沙盘</option>' +
928
1577
  state.sandboxes.map(s => `<option value="${s.id}">${safeText(s.name)}</option>`).join('');
1578
+ diaryFilterSelect.value = current;
929
1579
  }
930
1580
 
931
1581
  const changesSelect = document.getElementById('changes-sandbox-filter');
@@ -1241,15 +1891,6 @@ function collectSettingHeadersFromUI() {
1241
1891
  return headers;
1242
1892
  }
1243
1893
 
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
1894
  function showPage(pageId) {
1254
1895
  document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
1255
1896
  const page = document.getElementById(`page-${pageId}`);
@@ -1277,9 +1918,12 @@ function editWorkItem(id) {
1277
1918
  document.getElementById('item-dialog').showModal();
1278
1919
  }
1279
1920
 
1280
- function initApp() {
1281
- const hash = window.location.hash;
1921
+ async function initApp() {
1922
+ await loadRuntimeMode();
1282
1923
  loadWorkItemAssigneePreference();
1924
+ loadWorkTreeViewModePreference();
1925
+ renderQuickDiaryTargetLabel();
1926
+ applyReadonlyMode();
1283
1927
 
1284
1928
  document.querySelectorAll('.nav-list a').forEach(link => {
1285
1929
  link.addEventListener('click', (e) => {
@@ -1289,6 +1933,7 @@ function initApp() {
1289
1933
  });
1290
1934
 
1291
1935
  document.getElementById('add-sandbox-btn')?.addEventListener('click', () => {
1936
+ if (state.readonly) return;
1292
1937
  document.getElementById('sandbox-dialog').showModal();
1293
1938
  });
1294
1939
 
@@ -1297,6 +1942,7 @@ function initApp() {
1297
1942
  });
1298
1943
 
1299
1944
  const createSandbox = async (e) => {
1945
+ if (state.readonly) return;
1300
1946
  e?.preventDefault?.();
1301
1947
  const name = document.getElementById('new-sandbox-name').value;
1302
1948
  const description = document.getElementById('new-sandbox-desc').value;
@@ -1320,6 +1966,7 @@ function initApp() {
1320
1966
  document.getElementById('confirm-sandbox-btn')?.addEventListener('click', createSandbox);
1321
1967
 
1322
1968
  document.getElementById('add-item-btn')?.addEventListener('click', () => {
1969
+ if (state.readonly) return;
1323
1970
  document.getElementById('item-dialog-title').textContent = '添加任务';
1324
1971
  document.getElementById('item-dialog').dataset.editId = '';
1325
1972
  document.getElementById('new-item-name').value = '';
@@ -1331,9 +1978,11 @@ function initApp() {
1331
1978
  document.getElementById('item-dialog').showModal();
1332
1979
  });
1333
1980
 
1334
- document.getElementById('toggle-sandbox-present-btn')?.addEventListener('click', () => {
1335
- state.sandboxPresentationMode = !state.sandboxPresentationMode;
1336
- applySandboxLayoutMode();
1981
+ document.getElementById('toggle-sandbox-chat-btn')?.addEventListener('click', () => {
1982
+ if (state.readonly) return;
1983
+ state.sandboxChatVisible = !state.sandboxChatVisible;
1984
+ applySandboxChatVisibility();
1985
+ applySandboxLayoutHeight();
1337
1986
  });
1338
1987
 
1339
1988
  document.getElementById('toggle-sandbox-fullscreen-btn')?.addEventListener('click', async () => {
@@ -1354,6 +2003,7 @@ function initApp() {
1354
2003
  });
1355
2004
 
1356
2005
  document.getElementById('toggle-node-entity-form-btn')?.addEventListener('click', () => {
2006
+ if (state.readonly) return;
1357
2007
  setNodeEntityFormExpanded(!state.nodeEntityFormExpanded);
1358
2008
  if (state.nodeEntityFormExpanded) {
1359
2009
  document.getElementById('entity-title-input')?.focus();
@@ -1382,6 +2032,7 @@ function initApp() {
1382
2032
  });
1383
2033
 
1384
2034
  document.getElementById('create-node-entity-btn')?.addEventListener('click', async () => {
2035
+ if (state.readonly) return;
1385
2036
  if (!state.currentSandbox || !state.selectedNodeId) return;
1386
2037
  const btn = document.getElementById('create-node-entity-btn');
1387
2038
  const title = document.getElementById('entity-title-input').value.trim();
@@ -1480,6 +2131,7 @@ function initApp() {
1480
2131
  });
1481
2132
 
1482
2133
  document.getElementById('confirm-item-btn')?.addEventListener('click', async () => {
2134
+ if (state.readonly) return;
1483
2135
  const dialog = document.getElementById('item-dialog');
1484
2136
  const editId = dialog.dataset.editId || null;
1485
2137
  const isNewItem = !editId;
@@ -1526,50 +2178,15 @@ function initApp() {
1526
2178
  });
1527
2179
 
1528
2180
  document.getElementById('rollback-btn')?.addEventListener('click', async () => {
2181
+ if (state.readonly) return;
1529
2182
  if (!state.currentSandbox?.items?.length) return;
1530
2183
  const lastItem = state.currentSandbox.items[state.currentSandbox.items.length - 1];
1531
2184
  await apiRequest(`${API_BASE}/items/${lastItem.id}/rollback`, { method: 'POST' });
1532
2185
  await loadSandbox(state.currentSandbox.id);
1533
2186
  });
1534
2187
 
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
2188
  document.getElementById('sandbox-send-btn')?.addEventListener('click', async () => {
2189
+ if (state.readonly) return;
1573
2190
  const input = document.getElementById('sandbox-chat-input');
1574
2191
  const content = input.value.trim();
1575
2192
  if (!content || !state.currentSandbox) return;
@@ -1687,26 +2304,67 @@ function initApp() {
1687
2304
  sandboxActionHandler = handleAIAction;
1688
2305
 
1689
2306
  document.getElementById('sandbox-chat-input')?.addEventListener('keypress', (e) => {
2307
+ if (state.readonly) return;
1690
2308
  if (e.key === 'Enter') {
1691
2309
  document.getElementById('sandbox-send-btn').click();
1692
2310
  }
1693
2311
  });
1694
2312
 
1695
2313
  document.getElementById('save-diary-btn')?.addEventListener('click', async () => {
1696
- const sandboxId = document.getElementById('diary-sandbox-select').value || null;
2314
+ if (state.readonly) return;
1697
2315
  const content = document.getElementById('diary-content').value.trim();
1698
2316
  if (!content) return;
1699
-
1700
- await apiRequest(`${API_BASE}/diaries`, {
1701
- method: 'POST',
1702
- body: JSON.stringify({ sandbox_id: sandboxId, content }),
1703
- });
1704
-
2317
+ await saveDiaryEntry({ content, sandboxId: null, workItemId: null });
1705
2318
  document.getElementById('diary-content').value = '';
1706
2319
  await loadDiaries();
1707
2320
  });
2321
+
2322
+ document.getElementById('drawer-diary-save-btn')?.addEventListener('click', async () => {
2323
+ if (state.readonly) return;
2324
+ if (!state.currentSandbox || !state.selectedNodeId) return;
2325
+ const textarea = document.getElementById('drawer-diary-content');
2326
+ if (!(textarea instanceof HTMLTextAreaElement)) return;
2327
+ const content = textarea.value.trim();
2328
+ if (!content) return;
2329
+ const selectedNodeId = state.selectedNodeId;
2330
+ const selectedFilter = state.nodeEntityFilter;
2331
+ await saveDiaryEntry({
2332
+ content,
2333
+ sandboxId: state.currentSandbox.id,
2334
+ workItemId: selectedNodeId,
2335
+ });
2336
+ textarea.value = '';
2337
+ await loadSandbox(state.currentSandbox.id);
2338
+ await loadDiaries();
2339
+ showNodeEntityDrawer(selectedNodeId, selectedFilter);
2340
+ });
2341
+
2342
+ document.getElementById('cancel-edit-diary-btn')?.addEventListener('click', () => {
2343
+ closeDiaryEditDialog();
2344
+ });
2345
+ document.getElementById('confirm-edit-diary-btn')?.addEventListener('click', async () => {
2346
+ if (state.readonly) return;
2347
+ await submitDiaryEditDialog();
2348
+ });
2349
+ document.getElementById('diary-edit-content')?.addEventListener('keydown', async (event) => {
2350
+ if (state.readonly) return;
2351
+ const isSubmit = event.key === 'Enter' && (event.metaKey || event.ctrlKey);
2352
+ if (!isSubmit) return;
2353
+ event.preventDefault();
2354
+ await submitDiaryEditDialog();
2355
+ });
2356
+ document.getElementById('diary-edit-content')?.addEventListener('input', (event) => {
2357
+ const preview = document.getElementById('diary-edit-preview');
2358
+ if (!(preview instanceof HTMLElement)) return;
2359
+ const target = event.target;
2360
+ const value = target instanceof HTMLTextAreaElement ? target.value : '';
2361
+ const rendered = renderMarkdownSnippet(value);
2362
+ preview.innerHTML = rendered || '<p class="is-empty">在左侧输入 Markdown,这里会实时预览。</p>';
2363
+ preview.classList.toggle('is-empty', !rendered);
2364
+ });
1708
2365
 
1709
2366
  document.getElementById('save-settings-btn')?.addEventListener('click', async () => {
2367
+ if (state.readonly) return;
1710
2368
  const api_url = document.getElementById('setting-api-url').value;
1711
2369
  const api_key = document.getElementById('setting-api-key').value;
1712
2370
  const model = document.getElementById('setting-model').value;
@@ -1736,6 +2394,7 @@ function initApp() {
1736
2394
  });
1737
2395
 
1738
2396
  document.getElementById('add-setting-header-btn')?.addEventListener('click', () => {
2397
+ if (state.readonly) return;
1739
2398
  const list = document.getElementById('setting-headers-list');
1740
2399
  if (!(list instanceof HTMLElement)) return;
1741
2400
  list.appendChild(createSettingHeaderRow('', ''));
@@ -1754,6 +2413,12 @@ function initApp() {
1754
2413
  document.getElementById('work-tree-view-mode')?.addEventListener('change', (e) => {
1755
2414
  const nextMode = e.target.value || 'full';
1756
2415
  applyWorkTreeViewMode(nextMode);
2416
+ persistWorkTreeViewModePreference();
2417
+ renderWorkTree();
2418
+ });
2419
+
2420
+ document.getElementById('work-item-element-preview-mode')?.addEventListener('change', (e) => {
2421
+ state.workItemElementPreviewMode = e.target.value || 'none';
1757
2422
  renderWorkTree();
1758
2423
  });
1759
2424
 
@@ -1773,6 +2438,11 @@ function initApp() {
1773
2438
  renderDiaries();
1774
2439
  });
1775
2440
 
2441
+ document.getElementById('diary-sandbox-filter')?.addEventListener('change', (e) => {
2442
+ state.diarySandboxFilter = e.target.value || '';
2443
+ renderDiaries();
2444
+ });
2445
+
1776
2446
  document.getElementById('generate-report-btn')?.addEventListener('click', () => {
1777
2447
  if (!state.currentSandbox) return;
1778
2448
  const mode = document.getElementById('report-template')?.value || 'management';
@@ -1800,16 +2470,26 @@ function initApp() {
1800
2470
  document.getElementById('generate-insight-btn')?.addEventListener('click', async () => {
1801
2471
  if (!state.currentSandbox) return;
1802
2472
  const output = document.getElementById('sandbox-insight-output');
2473
+ const contentEl = document.getElementById('sandbox-insight-content');
2474
+ if (!(output instanceof HTMLElement) || !(contentEl instanceof HTMLElement)) return;
1803
2475
  output.classList.remove('hidden');
1804
- output.textContent = '生成中...';
2476
+ output.removeAttribute('hidden');
2477
+ contentEl.textContent = '生成中...';
1805
2478
  try {
1806
2479
  const result = await apiRequest(`${API_BASE}/chats/sandbox/${state.currentSandbox.id}/insight`);
1807
- output.textContent = result.insight;
2480
+ contentEl.textContent = result.insight;
1808
2481
  } catch (error) {
1809
- output.textContent = `生成失败:${error.message}`;
2482
+ contentEl.textContent = `生成失败:${error.message}`;
1810
2483
  }
1811
2484
  });
1812
2485
 
2486
+ document.getElementById('close-sandbox-insight-btn')?.addEventListener('click', () => {
2487
+ const output = document.getElementById('sandbox-insight-output');
2488
+ if (!(output instanceof HTMLElement)) return;
2489
+ output.classList.add('hidden');
2490
+ output.setAttribute('hidden', 'hidden');
2491
+ });
2492
+
1813
2493
  document.getElementById('changes-sandbox-filter')?.addEventListener('change', async (e) => {
1814
2494
  state.changesSandboxFilter = e.target.value || '';
1815
2495
  await loadChanges();
@@ -1857,39 +2537,57 @@ function initApp() {
1857
2537
  clearTimeout(resizeRenderTimer);
1858
2538
  }
1859
2539
  resizeRenderTimer = setTimeout(() => {
2540
+ applySandboxLayoutHeight();
1860
2541
  renderWorkTree();
1861
2542
  }, 120);
1862
2543
  });
1863
2544
 
1864
2545
  window.addEventListener('hashchange', handleRoute);
1865
- handleRoute();
2546
+ await handleRoute();
1866
2547
 
1867
2548
  async function handleRoute() {
1868
2549
  const hash = window.location.hash.slice(1) || '/';
2550
+ const [pathHash, queryString = ''] = hash.split('?');
2551
+ const query = new URLSearchParams(queryString);
1869
2552
  const fullscreenRoot = getSandboxFullscreenElement();
1870
- if (!hash.startsWith('/sandbox/') && fullscreenRoot && document.fullscreenElement === fullscreenRoot) {
2553
+ if (!pathHash.startsWith('/sandbox/') && fullscreenRoot && document.fullscreenElement === fullscreenRoot) {
1871
2554
  await document.exitFullscreen();
1872
2555
  }
1873
2556
 
1874
- if (hash === '/') {
1875
- showPage('home');
1876
- await loadChats();
1877
- } else if (hash === '/sandboxes') {
2557
+ if (pathHash === '/') {
2558
+ showPage('sandboxes');
2559
+ await loadSandboxes();
2560
+ } else if (pathHash === '/sandboxes') {
1878
2561
  showPage('sandboxes');
1879
2562
  await loadSandboxes();
1880
- } else if (hash.startsWith('/sandbox/')) {
1881
- const id = hash.split('/')[2];
2563
+ } else if (pathHash.startsWith('/sandbox/')) {
2564
+ const id = decodeURIComponent(pathHash.split('/')[2] || '');
1882
2565
  showPage('sandbox-detail');
1883
2566
  await loadSandbox(id);
1884
- } else if (hash === '/diaries') {
2567
+ const targetNodeId = String(query.get('node_id') || '').trim();
2568
+ const shouldOpenDrawer = query.get('open_drawer') === '1' || Boolean(targetNodeId);
2569
+ const preferredFilter = query.get('entity_filter') || 'all';
2570
+ if (shouldOpenDrawer && targetNodeId) {
2571
+ showNodeEntityDrawer(targetNodeId, preferredFilter);
2572
+ }
2573
+ } else if (pathHash === '/diaries') {
1885
2574
  showPage('diaries');
1886
2575
  await loadDiaries();
1887
2576
  await loadSandboxes();
1888
- } else if (hash === '/changes') {
2577
+ } else if (pathHash === '/changes') {
1889
2578
  showPage('changes');
1890
2579
  await loadSandboxes();
1891
2580
  await loadChanges();
1892
- } else if (hash === '/settings') {
2581
+ } else if (pathHash === '/settings') {
2582
+ if (state.readonly) {
2583
+ if (window.location.hash !== '#/sandboxes') {
2584
+ window.location.hash = '/sandboxes';
2585
+ return;
2586
+ }
2587
+ showPage('sandboxes');
2588
+ await loadSandboxes();
2589
+ return;
2590
+ }
1893
2591
  showPage('settings');
1894
2592
  await loadSettings();
1895
2593
  }