dsh-tiddlywiki 0.16.11 → 0.16.13

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tiddlywiki",
3
3
  "description": "TiddlyWiki 5 as the DSH persistent knowledge base: tiddlywiki_* agent tools (search with filters/recent/tags/batch/rename + git sync & conflict resolve), a full TiddlyWiki editor embedded in the GUI center column (same-origin proxy, works over Tailscale/LAN/domain), a quick-note card with draft auto-save & recent-notes picker behind a single knowledge FAB, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.16.11",
4
+ "version": "0.16.13",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -76,15 +76,19 @@ function ensurePopup(): void {
76
76
  })
77
77
 
78
78
  // Drag by the title bar (un-center by setting explicit left/top + margin 0).
79
- bar.addEventListener('mousedown', (event) => {
79
+ // Pointer Events + setPointerCapture: the pointer is captured by the bar, so
80
+ // pointermove/pointerup keep firing on it even over the iframe or outside the
81
+ // window — no window-level listeners, hence nothing to leak on a lost mouseup.
82
+ bar.addEventListener('pointerdown', (event) => {
80
83
  if (event.button !== 0 || root === undefined) return
81
84
  event.preventDefault()
85
+ bar.setPointerCapture(event.pointerId)
82
86
  const rect = root.getBoundingClientRect()
83
87
  const startX = event.clientX
84
88
  const startY = event.clientY
85
89
  const baseLeft = rect.left
86
90
  const baseTop = rect.top
87
- const onMove = (ev: MouseEvent): void => {
91
+ const onMove = (ev: PointerEvent): void => {
88
92
  if (root === undefined) return
89
93
  root.style.left = `${baseLeft + ev.clientX - startX}px`
90
94
  root.style.top = `${baseTop + ev.clientY - startY}px`
@@ -93,24 +97,27 @@ function ensurePopup(): void {
93
97
  root.style.bottom = 'auto'
94
98
  }
95
99
  const onUp = (): void => {
96
- window.removeEventListener('mousemove', onMove)
97
- window.removeEventListener('mouseup', onUp)
100
+ bar.removeEventListener('pointermove', onMove)
101
+ bar.removeEventListener('pointerup', onUp)
102
+ bar.removeEventListener('pointercancel', onUp)
98
103
  }
99
- window.addEventListener('mousemove', onMove)
100
- window.addEventListener('mouseup', onUp)
104
+ bar.addEventListener('pointermove', onMove)
105
+ bar.addEventListener('pointerup', onUp)
106
+ bar.addEventListener('pointercancel', onUp)
101
107
  })
102
108
 
103
109
  // Resize from the bottom-right corner.
104
- resize.addEventListener('mousedown', (event) => {
110
+ resize.addEventListener('pointerdown', (event) => {
105
111
  if (event.button !== 0 || root === undefined) return
106
112
  event.preventDefault()
107
113
  event.stopPropagation()
114
+ resize.setPointerCapture(event.pointerId)
108
115
  const rect = root.getBoundingClientRect()
109
116
  const startX = event.clientX
110
117
  const startY = event.clientY
111
118
  const baseW = rect.width
112
119
  const baseH = rect.height
113
- const onMove = (ev: MouseEvent): void => {
120
+ const onMove = (ev: PointerEvent): void => {
114
121
  if (root === undefined) return
115
122
  root.style.width = `${Math.max(360, baseW + ev.clientX - startX)}px`
116
123
  root.style.height = `${Math.max(260, baseH + ev.clientY - startY)}px`
@@ -118,10 +125,12 @@ function ensurePopup(): void {
118
125
  root.style.bottom = 'auto'
119
126
  }
120
127
  const onUp = (): void => {
121
- window.removeEventListener('mousemove', onMove)
122
- window.removeEventListener('mouseup', onUp)
128
+ resize.removeEventListener('pointermove', onMove)
129
+ resize.removeEventListener('pointerup', onUp)
130
+ resize.removeEventListener('pointercancel', onUp)
123
131
  }
124
- window.addEventListener('mousemove', onMove)
125
- window.addEventListener('mouseup', onUp)
132
+ resize.addEventListener('pointermove', onMove)
133
+ resize.addEventListener('pointerup', onUp)
134
+ resize.addEventListener('pointercancel', onUp)
126
135
  })
127
136
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Same-origin DSH endpoints the client calls. The authoritative route table
3
+ * lives in src/host/routes.ts (see AGENTS.md §1「DSH 路由」) — this is just the
4
+ * client-side mirror, so the path string exists once per endpoint instead of
5
+ * being re-typed in every widget.
6
+ *
7
+ * @module dsh-tiddlywiki/client/endpoints
8
+ */
9
+ export const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
10
+ export const GET_ENDPOINT = '/dsh-tiddlywiki/get'
@@ -71,10 +71,6 @@ export function apply(ctx: ClientContextFace): void {
71
71
  if (ctx.slots !== undefined) {
72
72
  void fetchUiConfig().then((cfg) => {
73
73
  if (clientDisposed) return
74
- // 输入框上方「快速笔记」快捷按钮(conversation.input.dock 槽位)。该槽位
75
- // 是官方为「输入框上方的全宽条目」预留的挂载点,todo/cost-meter/goal/
76
- // queue/git-graph 等插件内容都渲染在这里、按纵向 flex 排列,天然不重叠。
77
- // 由 ui.showQuickNoteDock 配置控制(默认开)。
78
74
  if (cfg.showQuickNoteDock) {
79
75
  const removeDock = ctx.slots?.inject('conversation.input.dock', () =>
80
76
  ctx.slots?.register(
@@ -19,8 +19,9 @@
19
19
  import type { PanelState } from './state.ts'
20
20
  import type { NoteWidgetHandle } from './note-widget.ts'
21
21
  import type { SyncController } from './sync-button.ts'
22
+ import { PANEL_RELOAD_EVENT } from './panel.ts'
22
23
 
23
- const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
24
+ import { STATUS_ENDPOINT } from './endpoints.ts'
24
25
 
25
26
  /** Book icon (same visual family as the sidebar entry). */
26
27
  const BOOK_ICON = '<svg viewBox="0 0 16 16" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 2.5h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/><path d="M6 6h4M6 8.5h2.5"/></svg>'
@@ -84,6 +85,15 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
84
85
  if (tip !== undefined) tip.hidden = true
85
86
  }
86
87
 
88
+ /** Capture-phase outside-click handler (closes the open menu); must be
89
+ * removable so unmount does not leak the document listener. */
90
+ const onDocumentClick = (event: Event): void => {
91
+ if (!menuOpen) return
92
+ const target = event.target as Node
93
+ if (root !== undefined && root.contains(target)) return
94
+ closeMenu()
95
+ }
96
+
87
97
  /** Rebuild the TW status row's hover tip (TW 服务 + git 状态 + 最近日志). */
88
98
  const renderTip = (): void => {
89
99
  if (tip === undefined) return
@@ -181,7 +191,7 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
181
191
  reload.textContent = '🔄 重载 TW 面板'
182
192
  reload.addEventListener('click', () => {
183
193
  closeMenu()
184
- document.dispatchEvent(new CustomEvent('dsh-tw-panel-reload'))
194
+ document.dispatchEvent(new CustomEvent(PANEL_RELOAD_EVENT))
185
195
  void refreshTwStatus()
186
196
  })
187
197
  menu.append(reload)
@@ -224,12 +234,7 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
224
234
  renderDot()
225
235
 
226
236
  // Outside click closes the menu.
227
- document.addEventListener('click', (event) => {
228
- if (!menuOpen) return
229
- const target = event.target as Node
230
- if (root !== undefined && root.contains(target)) return
231
- closeMenu()
232
- }, true)
237
+ document.addEventListener('click', onDocumentClick, true)
233
238
  }
234
239
 
235
240
  // Reflect panel open/close in the menu label.
@@ -247,6 +252,7 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
247
252
 
248
253
  return () => {
249
254
  disposed = true
255
+ document.removeEventListener('click', onDocumentClick, true)
250
256
  unsubPanel()
251
257
  unsubSync()
252
258
  root?.remove()
@@ -32,13 +32,19 @@ import { buildMarkdownEditor, type MarkdownEditor } from './markdown-editor.ts'
32
32
 
33
33
  const NOTE_ENDPOINT = '/dsh-tiddlywiki/note'
34
34
  const EDIT_ENDPOINT = '/dsh-tiddlywiki/edit'
35
- const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
35
+ import { STATUS_ENDPOINT } from './endpoints.ts'
36
36
  const TAGS_ENDPOINT = '/dsh-tiddlywiki/tags'
37
37
  const RECENT_ENDPOINT = '/dsh-tiddlywiki/recent'
38
38
  const GET_ENDPOINT = '/dsh-tiddlywiki/get'
39
39
  const UPLOAD_ENDPOINT = '/dsh-tiddlywiki/upload'
40
40
  const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
41
41
 
42
+ /**
43
+ * Broadcast by the card on open/close (detail: { open }). The input-dock quick-
44
+ * note button (quick-note-dock.ts) listens to highlight while the card is open.
45
+ */
46
+ export const NOTE_STATE_EVENT = 'dsh-tw-note-state'
47
+
42
48
  /** localStorage key for the autosaved quick-note draft. */
43
49
  const DRAFT_KEY = 'dsh-tw-note-draft-v1'
44
50
  /** Draft auto-save debounce. */
@@ -119,6 +125,8 @@ function buildTagEditor(opts: { onChange?: () => void } = {}): {
119
125
  getTags: () => string[]
120
126
  setDefault: (tag: string) => void
121
127
  setTags: (tags: string[]) => void
128
+ /** Remove the document-level outside-click listener (unmount must not leak). */
129
+ dispose: () => void
122
130
  } {
123
131
  const wrap = document.createElement('div')
124
132
  wrap.className = 'dsh-tw-note-tags'
@@ -225,9 +233,10 @@ function buildTagEditor(opts: { onChange?: () => void } = {}): {
225
233
  hideSuggest()
226
234
  }
227
235
  })
228
- document.addEventListener('click', (event) => {
236
+ const onDocClick = (event: Event): void => {
229
237
  if (!wrap.contains(event.target as Node)) hideSuggest()
230
- }, true)
238
+ }
239
+ document.addEventListener('click', onDocClick, true)
231
240
 
232
241
  return {
233
242
  el: wrap,
@@ -255,6 +264,7 @@ function buildTagEditor(opts: { onChange?: () => void } = {}): {
255
264
  renderChips()
256
265
  hideSuggest()
257
266
  },
267
+ dispose: () => document.removeEventListener('click', onDocClick, true),
258
268
  }
259
269
  }
260
270
 
@@ -341,7 +351,7 @@ export function createNoteWidget(): NoteWidgetHandle {
341
351
  */
342
352
  const emitState = (open: boolean): void => {
343
353
  try {
344
- window.dispatchEvent(new CustomEvent('dsh-tw-note-state', { detail: { open } }))
354
+ window.dispatchEvent(new CustomEvent(NOTE_STATE_EVENT, { detail: { open } }))
345
355
  } catch { /* event dispatch is best-effort */ }
346
356
  }
347
357
 
@@ -630,12 +640,18 @@ export function createNoteWidget(): NoteWidgetHandle {
630
640
  close()
631
641
  }
632
642
 
643
+ // In-flight guard: the save button is disabled while saving, but Ctrl+Enter
644
+ // from the editor keymap is not — a second POST within the same second
645
+ // would carry the same timestamp title and silently overwrite the first.
646
+ let saving = false
633
647
  doSave = async (): Promise<void> => {
648
+ if (saving) return
634
649
  const text = editor.getValue().trim()
635
650
  if (text.length === 0) {
636
651
  toast('内容为空,未保存')
637
652
  return
638
653
  }
654
+ saving = true
639
655
  saveBtn.disabled = true
640
656
  saveBtn.textContent = '保存中…'
641
657
  try {
@@ -655,6 +671,7 @@ export function createNoteWidget(): NoteWidgetHandle {
655
671
  } catch (err) {
656
672
  toast(`保存失败:${err instanceof Error ? err.message : String(err)}`)
657
673
  } finally {
674
+ saving = false
658
675
  saveBtn.disabled = false
659
676
  saveBtn.textContent = '保存'
660
677
  }
@@ -785,8 +802,12 @@ export function createNoteWidget(): NoteWidgetHandle {
785
802
  } else {
786
803
  resetTitle()
787
804
  defaultTag = (await fetchUiOptions()).defaultTag
805
+ // dispose() may have run while awaiting (sets ui = undefined); a live
806
+ // `ui` reference must be re-read under the same guard as above.
807
+ if (disposed || ui === undefined) return
788
808
  ui.tagEditor.setDefault(defaultTag)
789
809
  }
810
+ if (ui === undefined) return
790
811
  ui.editor.focus()
791
812
  },
792
813
  close() {
@@ -806,6 +827,7 @@ export function createNoteWidget(): NoteWidgetHandle {
806
827
  dispose() {
807
828
  disposed = true
808
829
  if (draftTimer !== undefined) { clearTimeout(draftTimer); draftTimer = undefined }
830
+ ui?.tagEditor.dispose()
809
831
  ui?.editor.view.destroy()
810
832
  ui?.root.remove()
811
833
  const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
@@ -815,8 +837,5 @@ export function createNoteWidget(): NoteWidgetHandle {
815
837
  }
816
838
  }
817
839
 
818
- /** Legacy compatibility export: the FAB owns the trigger in v0.5. */
819
- export function mountNoteWidget(): () => void {
820
- const handle = createNoteWidget()
821
- return () => handle.dispose()
822
- }
840
+ // (The FAB owns the trigger since v0.5 — there is deliberately no standalone
841
+ // mount export here; index.ts wires createNoteWidget into mountKnowledgeFab.)
@@ -24,7 +24,7 @@ import type { PanelState } from './state.ts'
24
24
  import { ENTRY_SELECTOR } from './sidebar-entry.ts'
25
25
  import { attachThemeSync, setThemeSyncConfig } from './theme-sync.ts'
26
26
 
27
- export const PANEL_VIEW_SELECTOR = '[data-dsh-tw-view]'
27
+ export const PANEL_RELOAD_EVENT = 'dsh-tw-panel-reload'
28
28
 
29
29
  /**
30
30
  * Center-column targets, most-specific shell generation first. The official
@@ -57,7 +57,7 @@ const APP_OVERLAY_Z_INDEX = 20
57
57
  /** Safety re-measure cadence for shell layout changes CSS can't see. */
58
58
  const SYNC_INTERVAL_MS = 2_000
59
59
 
60
- const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
60
+ import { STATUS_ENDPOINT } from './endpoints.ts'
61
61
  const RESTART_ENDPOINT = '/dsh-tiddlywiki/restart'
62
62
 
63
63
  /**
@@ -100,7 +100,7 @@ interface StatusPayload {
100
100
  wikiPath?: string
101
101
  error?: string
102
102
  note?: { tag?: string }
103
- ui?: { showPanelStatus?: boolean; followDshTheme?: boolean; darkPalette?: string }
103
+ ui?: { followDshTheme?: boolean; darkPalette?: string }
104
104
  }
105
105
 
106
106
  function conversationColumn(): HTMLElement | undefined {
@@ -143,6 +143,8 @@ export function mountPanel(state: PanelState): () => void {
143
143
  let frameLoaded = false
144
144
  /** A tiddler-hash open request waiting for the frame to become ready. */
145
145
  let pendingHash: string | null = null
146
+ /** Set by the disposer: no timers/observers may touch DOM or the iframe after. */
147
+ let disposed = false
146
148
 
147
149
  const build = (): HTMLDivElement => {
148
150
  const view = document.createElement('div')
@@ -281,6 +283,7 @@ export function mountPanel(state: PanelState): () => void {
281
283
  return
282
284
  }
283
285
  const tryOnce = (attempt: number): void => {
286
+ if (disposed) return // unmounted: do not keep waiting or touch the iframe
284
287
  if (pendingHash !== hash) return // superseded by a newer request
285
288
  const frameTw = win as { $tw?: unknown }
286
289
  if (typeof frameTw.$tw !== 'object' || frameTw.$tw === null) {
@@ -303,7 +306,7 @@ export function mountPanel(state: PanelState): () => void {
303
306
  }
304
307
 
305
308
  const fallbackLoad = (hash: string): void => {
306
- if (iframe === undefined) return
309
+ if (disposed || iframe === undefined) return // never drive a detached frame
307
310
  if (pendingHash === hash) pendingHash = null
308
311
  const base = iframe.src.split('#')[0]
309
312
  if (iframe.src !== `${base}${hash}`) iframe.src = `${base}${hash}`
@@ -324,6 +327,7 @@ export function mountPanel(state: PanelState): () => void {
324
327
  refreshTimer = undefined
325
328
  }
326
329
  const payload = await fetchStatus()
330
+ if (disposed) return // unmounted while fetching: stop, don't touch DOM
327
331
  if (payload === null) {
328
332
  showError('无法访问 /dsh-tiddlywiki/status')
329
333
  return
@@ -413,10 +417,11 @@ export function mountPanel(state: PanelState): () => void {
413
417
  const onReloadRequest = (): void => {
414
418
  if (iframe !== undefined && !iframe.hidden) iframe.src = iframe.src
415
419
  }
416
- document.addEventListener('dsh-tw-panel-reload', onReloadRequest)
420
+ document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
417
421
  document.addEventListener(OPEN_TIDDLER_EVENT, onOpenTiddler)
418
422
 
419
423
  return () => {
424
+ disposed = true
420
425
  if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
421
426
  window.clearInterval(syncInterval)
422
427
  window.removeEventListener('resize', onWindowResize)
@@ -424,7 +429,7 @@ export function mountPanel(state: PanelState): () => void {
424
429
  resizeObserver.disconnect()
425
430
  document.removeEventListener('click', onClickSidebarRow, true)
426
431
  document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
427
- document.removeEventListener('dsh-tw-panel-reload', onReloadRequest)
432
+ document.removeEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
428
433
  document.removeEventListener(OPEN_TIDDLER_EVENT, onOpenTiddler)
429
434
  waitObserver.disconnect()
430
435
  unsubscribe()
@@ -15,10 +15,7 @@
15
15
  * @module dsh-tiddlywiki/client/quick-note-dock
16
16
  */
17
17
  import * as React from 'react'
18
- import type { NoteWidgetHandle } from './note-widget.ts'
19
-
20
- /** Event name the note card dispatches on open/close (detail: { open }). */
21
- export const NOTE_STATE_EVENT = 'dsh-tw-note-state'
18
+ import { NOTE_STATE_EVENT, type NoteWidgetHandle } from './note-widget.ts'
22
19
 
23
20
  /**
24
21
  * Build the dock entry component bound to one note-widget handle. Called once
@@ -18,12 +18,13 @@
18
18
  */
19
19
  import * as React from 'react'
20
20
  import { attachThemeSync } from './theme-sync.ts'
21
+ import { GET_ENDPOINT } from './endpoints.ts'
21
22
 
22
23
  export const SESSION_SUMMARY_VIEW_ID = 'dsh-tiddlywiki-summary'
23
24
  const SUMMARY_ENDPOINT = '/dsh-tiddlywiki/session/summary'
24
25
  /** Same-origin TW proxy base(与 host TW_PROXY_PATH 一致,client 不能 import 它)。 */
25
26
  const TW_PROXY_BASE = '/dsh-tiddlywiki/tw/'
26
- export const SESSION_SUMMARY_LABEL_DEFAULT = '知识库'
27
+ const SESSION_SUMMARY_LABEL_DEFAULT = '知识库'
27
28
 
28
29
  /** Tab 显示名缓存:客户端从 /status 读到 `ui.tabLabel` 后更新;默认「知识库」。 */
29
30
  let tabLabel = SESSION_SUMMARY_LABEL_DEFAULT
@@ -49,6 +50,7 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
49
50
  const { sessionId, viewRequest, completeViewRequest } = props
50
51
  const [phase, setPhase] = React.useState<'loading' | 'ready' | 'error'>('loading')
51
52
  const [twUrl, setTwUrl] = React.useState<string | null>(null)
53
+ const [summaryTitle, setSummaryTitle] = React.useState<string | null>(null)
52
54
  const [error, setError] = React.useState('')
53
55
  const [frameNonce, setFrameNonce] = React.useState(0)
54
56
  const genRef = React.useRef(0)
@@ -77,6 +79,7 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
77
79
  return
78
80
  }
79
81
  if (typeof data.title === 'string' && data.title.length > 0) {
82
+ setSummaryTitle(data.title)
80
83
  setTwUrl(new URL(TW_PROXY_BASE, window.location.origin).href + '#' + encodeURIComponent(data.title))
81
84
  // 新 nonce 强制 iframe 重挂载 → 用最新汇总重新加载(含刷新场景)。
82
85
  setFrameNonce((n) => n + 1)
@@ -94,6 +97,31 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
94
97
  void generate()
95
98
  }, [generate])
96
99
 
100
+ // 自愈:汇总条目是 volatile 的 `$:/temp`,TW 重启即消失;若 iframe 已就绪但条目
101
+ // 被清掉(/get 404),自动重新生成,避免停在 TW 的「佚失条目」页。连续 3 次仍
102
+ // 缺失(例如服务端写不进去)就停止自动重试,交还手动「🔄 刷新」。
103
+ React.useEffect(() => {
104
+ if (phase !== 'ready' || summaryTitle === null) return
105
+ let misses = 0
106
+ const timer = window.setInterval(() => {
107
+ void (async () => {
108
+ try {
109
+ const res = await fetch(`${GET_ENDPOINT}?title=${encodeURIComponent(summaryTitle)}`, { signal: AbortSignal.timeout(8_000) })
110
+ const data = (await res.json().catch(() => null)) as { notFound?: boolean } | null
111
+ if (res.status === 404 || data?.notFound === true) {
112
+ misses++
113
+ if (misses < 3) void generate()
114
+ } else {
115
+ misses = 0
116
+ }
117
+ } catch {
118
+ /* 检查失败保持现状,下个周期再试 */
119
+ }
120
+ })()
121
+ }, 30_000)
122
+ return () => window.clearInterval(timer)
123
+ }, [phase, summaryTitle, generate])
124
+
97
125
  // 一次性 focus 请求直接确认(本视图无可聚焦子目标,避免 shell 挂起)。
98
126
  React.useEffect(() => {
99
127
  if (viewRequest !== null && viewRequest !== undefined) completeViewRequest?.()
@@ -12,7 +12,8 @@
12
12
  *
13
13
  * Sections:
14
14
  * 1. 状态/重启 TW 运行状态 + git 概览 + 重启按钮
15
- * 2. 常规配置 note.tag / git.* / uiLanguage(改了什么保存什么)
15
+ * 2. 常规配置 note.tag / git.* / ui.*(含会话「知识库」Tab 名称、sendToAgent
16
+ * 开关/token/endpoint;改了什么保存什么)
16
17
  * 3. 插件管理 自带官方插件勾选(可搜索)→ 应用并重启 TW
17
18
  * 4. 主题管理 自带主题单选 → 应用并重启 TW
18
19
  * 5. 初始化 一次性预置(doc-note/send-to-agent/home-index/all-articles/menubar-theme/tw-web-host)
@@ -232,9 +233,13 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
232
233
  checkField('ui.showPanelStatus', '显示「知识库」按钮里的 TW 面板/重载入口与状态行', ui.showPanelStatus !== false)
233
234
  checkField('ui.showSyncButton', '显示「知识库」按钮里的「同步」入口与 git 状态点', ui.showSyncButton !== false)
234
235
  checkField('ui.followDshTheme', '嵌入式 TW 跟随 DSH 深浅主题(暗色时自动切深色 palette,不写回 wiki)', ui.followDshTheme !== false)
235
- textField('ui.darkPalette', '暗色时 TW palette(tiddler 标题)', typeof ui.darkPalette === 'string' && ui.darkPalette.length > 0 ? ui.darkPalette : '$:/palettes/CupertinoDark')
236
+ textField('ui.darkPalette', '暗色时 TW palette(tiddler 标题)', typeof ui.darkPalette === 'string' && ui.darkPalette.trim().length > 0 ? ui.darkPalette.trim() : '$:/palettes/CupertinoDark')
236
237
  textField('ui.tabLabel', '会话顶部「知识库」Tab 名称', typeof ui.tabLabel === 'string' && ui.tabLabel.trim().length > 0 ? ui.tabLabel.trim() : '知识库')
237
238
  checkField('ui.showSessionTab', '显示会话顶部「知识库」Tab(本会话相关 wiki 笔记汇总)', ui.showSessionTab !== false)
239
+ const sendToAgent = (ui.sendToAgent ?? {}) as Record<string, unknown>
240
+ checkField('ui.sendToAgent.enabled', '启用「发送给 Agent」(TW 笔记 → DSH 会话注入)', sendToAgent.enabled !== false)
241
+ textField('ui.sendToAgent.endpoint', 'TW 端请求基址(空=自动取当前 DSH origin)', typeof sendToAgent.endpoint === 'string' ? sendToAgent.endpoint : '')
242
+ textField('ui.sendToAgent.token', '共享 token(非空时路由校验 x-send-to-agent-token 头)', typeof sendToAgent.token === 'string' ? sendToAgent.token : '')
238
243
  const allArticles = (ui.allArticles ?? {}) as Record<string, unknown>
239
244
  numField('ui.allArticles.pageSize', '「所有文章」每页条数', typeof allArticles.pageSize === 'number' ? allArticles.pageSize : 10)
240
245
  // 界面语言在下方「语言管理」区块设置(config 的 uiLanguage 仅供启动时自动应用)。
@@ -10,6 +10,7 @@
10
10
  * @module dsh-tiddlywiki/client/sidebar-entry
11
11
  */
12
12
  import type { PanelState } from './state.ts'
13
+ import { STATUS_ENDPOINT } from './endpoints.ts'
13
14
 
14
15
  /** Stable data attribute identifying this entry row. */
15
16
  export const ENTRY_SELECTOR = '[data-dsh-tw-entry]'
@@ -96,7 +97,7 @@ export function mountSidebarEntry(state: PanelState, initialLabel = 'TiddlyWiki'
96
97
  // 异步到达后原地更新,无需重建 DOM(旧 host 无该字段时保持默认名)。
97
98
  void (async () => {
98
99
  try {
99
- const res = await fetch('/dsh-tiddlywiki/status', { signal: AbortSignal.timeout(5_000) })
100
+ const res = await fetch(STATUS_ENDPOINT, { signal: AbortSignal.timeout(5_000) })
100
101
  if (!res.ok) return
101
102
  const p = (await res.json()) as { ui?: { sidebarLabel?: string } }
102
103
  const label = p.ui?.sidebarLabel
@@ -51,32 +51,6 @@ html[data-dsh-tw-active] [data-pane="conversation"] > :not([data-dsh-tw-view]),
51
51
  html[data-dsh-tw-active] [class*="centerCol"] > :not([data-dsh-tw-view]),
52
52
  html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view]) { display: none !important; }
53
53
 
54
- /* ── bottom-right status / reload floating buttons ───────────────────
55
- The top panel bar (title + status + reload row) is gone so TW's menubar
56
- reaches the panel top; status/reload now live here as floating buttons,
57
- same visual language as the quick-note toggle. The ui.showPanelStatus
58
- setting controls whether they mount at all. */
59
- .dsh-tw-panel-status {
60
- /* 状态/重载悬浮按钮:右对齐到与快速笔记按钮同一条竖线(right:24),
61
- 垂直排在「同步」按钮(bottom:140)上方;
62
- 两个按钮竖直依次排列(状态在上、重载在下),与其他插件按钮列对齐、不堆叠。 */
63
- position: fixed; right: 24px; bottom: 192px; z-index: 950;
64
- display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
65
- }
66
- .dsh-tw-panel-status-btn {
67
- border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
68
- background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
69
- font: inherit; font-size: 12px; padding: 7px 12px; border-radius: 999px;
70
- box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16)); cursor: pointer;
71
- display: inline-flex; align-items: center; gap: 6px;
72
- transition: background-color 120ms ease;
73
- }
74
- .dsh-tw-panel-status-btn:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
75
- .dsh-tw-status-dot { width: 8px; height: 8px; border-radius: 50%; background: #999; flex: none; }
76
- .dsh-tw-status-dot[data-state="running"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
77
- .dsh-tw-status-dot[data-state="starting"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
78
- .dsh-tw-status-dot[data-state="failed"], .dsh-tw-status-dot[data-state="stopped"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
79
-
80
54
  /* The iframe background follows the DSH theme so a blank/preload frame never
81
55
  flashes pure white in dark mode (TW paints its own palette once loaded). */
82
56
  .dsh-tw-panel-frame { flex: 1; min-height: 0; border: 0; width: 100%; display: block; background: var(--dsw-alias-bg-layer-1, #fff); }
@@ -264,38 +238,6 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
264
238
  .dsh-tw-editor-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, #222); }
265
239
  .dsh-tw-editor-frame { flex: 1; min-height: 0; width: 100%; border: 0; background: var(--dsw-alias-bg-layer-1, #fff); }
266
240
  .dsh-tw-editor-resize { position: absolute; right: 0; bottom: 0; width: 16px; height: 16px; cursor: nwse-resize; }
267
- .dsh-tw-note-toggle {
268
- border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
269
- background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
270
- font: inherit; font-size: 13px; padding: 9px 16px; border-radius: 999px;
271
- box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16)); cursor: pointer;
272
- display: inline-flex; align-items: center; gap: 6px;
273
- transition: background-color 120ms ease;
274
- }
275
- .dsh-tw-note-toggle:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
276
-
277
- /* ── floating sync button (bottom-right) ───────────────────────
278
- Pill FAB between the quick-note toggle (bottom:88) and the TW panel
279
- status/reload floaters (bottom:192); dot reflects git state. */
280
- .dsh-tw-sync {
281
- position: fixed; right: 24px; bottom: 140px; z-index: 950;
282
- display: inline-flex; align-items: center; gap: 7px;
283
- padding: 9px 14px; border-radius: 999px;
284
- border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
285
- background: var(--dsw-alias-bg-layer-2, #fff); color: var(--dsw-alias-label-primary, #222);
286
- font: inherit; font-size: 13px; box-shadow: var(--dsw-shadow-lv3, 0 4px 16px rgba(0,0,0,.16));
287
- cursor: pointer; transition: background-color 120ms ease;
288
- }
289
- .dsh-tw-sync:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.1)); }
290
- .dsh-tw-sync:disabled { opacity: .65; cursor: default; }
291
- .dsh-tw-sync-icon { display: inline-flex; flex: none; }
292
- .dsh-tw-sync-spin .dsh-tw-sync-icon svg { animation: dsh-tw-spin 1s linear infinite; transform-origin: center; }
293
- @keyframes dsh-tw-spin { to { transform: rotate(360deg); } }
294
- .dsh-tw-sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #999; flex: none; }
295
- .dsh-tw-sync-dot[data-state="clean"] { background: var(--dsw-alias-state-success-primary, #3eaa5f); }
296
- .dsh-tw-sync-dot[data-state="dirty"] { background: var(--dsw-alias-state-warning-primary, #d9822b); }
297
- .dsh-tw-sync-dot[data-state="behind"] { background: var(--dsw-alias-state-error-primary, #d13b3b); }
298
- .dsh-tw-sync-dot[data-state="syncing"] { background: var(--dsw-alias-brand-primary, #3e63dd); }
299
241
 
300
242
  /* ── toast ──────────────────────────────────────────────────── */
301
243
  .dsh-tw-toast {
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { toast } from './toast.ts'
20
20
 
21
- const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
21
+ import { STATUS_ENDPOINT } from './endpoints.ts'
22
22
  const SYNC_ENDPOINT = '/dsh-tiddlywiki/sync'
23
23
  const POLL_MS = 30_000
24
24
 
@@ -38,7 +38,6 @@ interface StatusPayload {
38
38
  ok?: boolean
39
39
  status?: string
40
40
  git?: GitView | null
41
- ui?: { showSyncButton?: boolean }
42
41
  }
43
42
 
44
43
  export interface SyncStateView {
@@ -121,6 +120,7 @@ export function createSyncController(): SyncController {
121
120
  let lastSync: Date | undefined
122
121
  let timer: number | undefined
123
122
  let syncing = false
123
+ let disposed = false
124
124
  const listeners = new Set<() => void>()
125
125
 
126
126
  const emit = (): void => {
@@ -136,11 +136,12 @@ export function createSyncController(): SyncController {
136
136
  }
137
137
 
138
138
  const poll = async (): Promise<void> => {
139
+ if (disposed) return // unmounted: no further fetches or state churn
139
140
  applyStatus(await fetchStatus())
140
141
  }
141
142
 
142
143
  const doSync = async (): Promise<SyncStateView> => {
143
- if (syncing) return state
144
+ if (disposed || syncing) return state
144
145
  syncing = true
145
146
  state = { ...state, state: 'syncing', label: '同步中…' }
146
147
  emit()
@@ -184,6 +185,7 @@ export function createSyncController(): SyncController {
184
185
  return () => { listeners.delete(cb) }
185
186
  },
186
187
  dispose() {
188
+ disposed = true
187
189
  if (timer !== undefined) { clearInterval(timer); timer = undefined }
188
190
  listeners.clear()
189
191
  },
@@ -6,7 +6,7 @@
6
6
  * @module dsh-tiddlywiki/client/ui-config
7
7
  */
8
8
 
9
- const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
9
+ import { STATUS_ENDPOINT } from './endpoints.ts'
10
10
 
11
11
  export interface UiConfig {
12
12
  /** 聊天输入框上方的「快速笔记」快捷按钮是否显示(默认 true)。 */
package/src/host/admin.ts CHANGED
@@ -24,6 +24,8 @@ import type { TiddlyWebClient } from './tw-api.ts'
24
24
  import type { WikiServer } from './wiki.ts'
25
25
  import { ROUTE_PREFIX, type WebServerFace } from './routes.ts'
26
26
  import { CONFIG_TIDDLER, type ConfigStore, type PluginConfigShape } from './config.ts'
27
+ import { readBody, json } from './http.ts'
28
+ import { GitFace } from './git.ts'
27
29
 
28
30
  /** One bundled plugin/theme from the catalog. */
29
31
  export interface CatalogEntry {
@@ -219,29 +221,6 @@ export async function ensureLanguage(wikiPath: string, twRoot: string, lang: str
219
221
  return true
220
222
  }
221
223
 
222
- function json(res: ServerResponse, payload: unknown, status = 200): void {
223
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
224
- res.end(JSON.stringify(payload))
225
- }
226
-
227
- async function readBody(req: IncomingMessage, limit = 1024 * 1024): Promise<string> {
228
- return new Promise((resolveP, rejectP) => {
229
- let size = 0
230
- const chunks: Buffer[] = []
231
- req.on('data', (chunk: Buffer) => {
232
- size += chunk.length
233
- if (size > limit) {
234
- rejectP(new Error('body too large'))
235
- req.destroy()
236
- return
237
- }
238
- chunks.push(chunk)
239
- })
240
- req.on('end', () => resolveP(Buffer.concat(chunks).toString('utf8')))
241
- req.on('error', rejectP)
242
- })
243
- }
244
-
245
224
  export interface AdminDeps {
246
225
  server: WikiServer
247
226
  getClient: () => TiddlyWebClient | undefined
@@ -264,7 +243,6 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
264
243
  const [info, catalog] = await Promise.all([readWikiInfo(wikiPath), bundledCatalog(deps.twRoot())])
265
244
  let git: unknown = null
266
245
  try {
267
- const { GitFace } = await import('./git.ts')
268
246
  git = await new GitFace().status(wikiPath)
269
247
  } catch {
270
248
  git = null