dsh-tiddlywiki 0.28.13 → 0.29.0

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.
@@ -28,7 +28,7 @@
28
28
  */
29
29
  import * as React from 'react'
30
30
  import { RESTART_ENDPOINT, resolveTwUrl, twProxyFor } from './endpoints.ts'
31
- import { fetchStatus } from './status-cache.ts'
31
+ import { fetchStatus, type StatusPayload } from './status-cache.ts'
32
32
  import { getFocusWiki, subscribeFocusWiki } from './wiki-focus.ts'
33
33
  import { attachThemeSync, setThemeSyncConfig } from './theme-sync.ts'
34
34
 
@@ -111,6 +111,8 @@ export interface TwFrameSkin {
111
111
  frame: string
112
112
  /** Error / starting panel. */
113
113
  error: string
114
+ /** Hint shown while a DIFFERENT wiki's document is on the wire (v0.28.14). */
115
+ loading: string
114
116
  }
115
117
 
116
118
  /**
@@ -177,7 +179,19 @@ export function createTwFrameSurface(skin: TwFrameSkin, hooks: TwFrameHooks = {}
177
179
  let view: HTMLDivElement | undefined
178
180
  let frame: HTMLIFrameElement | undefined
179
181
  let errorArea: HTMLDivElement | undefined
182
+ let loadingEl: HTMLDivElement | undefined
180
183
  let refreshTimer: number | undefined
184
+ /** "Switching wiki" hint timer (v0.28.14). */
185
+ let switchTimer: number | undefined
186
+ /**
187
+ * The last successful `/status` payload (v0.28.14).
188
+ *
189
+ * A wiki switch needs only two things from it — `mode` and the proxy bases — and we
190
+ * already have them from the refresh that opened the current wiki. Re-fetching costs
191
+ * a full round-trip (the host runs up to five `git` processes per `/status`, measured
192
+ * 300–400 ms here), which used to sit BETWEEN the click and the start of the switch.
193
+ */
194
+ let lastStatus: StatusPayload | undefined
181
195
  /** In-flight hash-readiness retry timers (cancelled by dispose, v0.19.1). */
182
196
  const hashWaitTimers = new Set<number>()
183
197
  let refreshAttempts = 0
@@ -209,6 +223,7 @@ export function createTwFrameSurface(skin: TwFrameSkin, hooks: TwFrameHooks = {}
209
223
 
210
224
  const showError = (message: string): void => {
211
225
  if (frame === undefined || errorArea === undefined) return
226
+ hideSwitching()
212
227
  frame.hidden = true
213
228
  errorArea.hidden = false
214
229
  errorArea.replaceChildren()
@@ -229,6 +244,7 @@ export function createTwFrameSurface(skin: TwFrameSkin, hooks: TwFrameHooks = {}
229
244
 
230
245
  const showStarting = (): void => {
231
246
  if (frame === undefined || errorArea === undefined) return
247
+ hideSwitching()
232
248
  frame.hidden = true
233
249
  errorArea.hidden = false
234
250
  errorArea.replaceChildren()
@@ -237,7 +253,44 @@ export function createTwFrameSurface(skin: TwFrameSkin, hooks: TwFrameHooks = {}
237
253
  errorArea.append(p)
238
254
  }
239
255
 
240
- const showFrame = (url: string, wiki: string | undefined): void => {
256
+ /** Cancel the "switching wiki" hint's safety timer. */
257
+ const clearSwitchTimer = (): void => {
258
+ if (switchTimer !== undefined) {
259
+ window.clearTimeout(switchTimer)
260
+ switchTimer = undefined
261
+ }
262
+ }
263
+
264
+ /** Drop the "loading another wiki" hint (the new document arrived, or we gave up). */
265
+ const hideSwitching = (): void => {
266
+ clearSwitchTimer()
267
+ if (loadingEl !== undefined) loadingEl.hidden = true
268
+ }
269
+
270
+ /**
271
+ * Say 「正在载入知识库「X」…」 while a DIFFERENT wiki's document is on the wire.
272
+ *
273
+ * WHY (v0.28.14,作者报障「点击切换感觉会卡那么一下」): 一个 iframe 只能装一份 TW
274
+ * 文档,换库 = 重新下载并解析**整份** wiki。本机实测这份文档是 **9.9 MB / 29.9 MB /
275
+ * 28 MB**,而且 TW 自己回 `Cache-Control: no-store`、没有 ETag —— 既缓存不了也预取不了。
276
+ * 这段时间界面完全不动,用户只能感觉"卡住了"。加载时长不是这套代码能把控的,但**界面
277
+ * 必须说清正在发生什么**(否则一次正常的 1–2 秒等待看起来就是"卡死/没反应")。
278
+ *
279
+ * 兜底 15s:`load` 万一不来(库没起来 / 服务挂了),提示不能永久挂在界面上 —— 那条路径
280
+ * 由 showError/showStarting 接管。
281
+ */
282
+ const showSwitching = (label: string): void => {
283
+ if (loadingEl === undefined) return
284
+ loadingEl.textContent = `正在载入知识库「${label}」…`
285
+ loadingEl.hidden = false
286
+ clearSwitchTimer()
287
+ switchTimer = window.setTimeout(() => { switchTimer = undefined; hideSwitching() }, 15_000)
288
+ }
289
+
290
+ /** A wiki's display name, from the last roster we saw (falls back to its id). */
291
+ const wikiLabel = (id: string): string => lastStatus?.wikis?.find((w) => w.id === id)?.label ?? id
292
+
293
+ const showFrame = (url: string, wiki: string | undefined, label?: string): void => {
241
294
  if (frame === undefined || errorArea === undefined) return
242
295
  errorArea.hidden = true
243
296
  // Only reveal the frame while the surface is on screen: doRefresh() can
@@ -252,6 +305,8 @@ export function createTwFrameSurface(skin: TwFrameSkin, hooks: TwFrameHooks = {}
252
305
  frame.dataset.loaded = url
253
306
  frameLoaded = false
254
307
  frame.src = url
308
+ // 换了文档 = 重新下载整份 wiki(10–30MB、no-store):这段时间必须给个可见说明。
309
+ if (label !== undefined) showSwitching(label)
255
310
  }
256
311
  // 换库这一步到此为止(url 没变也算了结:单库模式下两个 id 解析出同一条裸路径)。
257
312
  // 迟到的 hash 请求现在才允许落地 —— src 真的换了就等 load 事件再调一次。
@@ -352,6 +407,8 @@ export function createTwFrameSurface(skin: TwFrameSkin, hooks: TwFrameHooks = {}
352
407
  showError('无法访问 /dsh-tiddlywiki/status')
353
408
  return
354
409
  }
410
+ // 记住这一份:换库要用它的 mode / 代理基址 / 名册(见 switchFrameNow 与 wikiLabel)。
411
+ lastStatus = payload
355
412
  if (payload.ui !== undefined) {
356
413
  // Shared surface label + theme config: both are module-level, so any
357
414
  // surface that polls keeps them fresh for every other surface.
@@ -372,10 +429,12 @@ export function createTwFrameSurface(skin: TwFrameSkin, hooks: TwFrameHooks = {}
372
429
  // TW refuses to load its sync adaptor anywhere else — see resolveTwUrl.
373
430
  // WHICH knowledge base (v0.28.0): the focused one in multi mode, else the
374
431
  // bare path (= the default wiki, exactly as before).
375
- const bases = twProxyFor(payload.mode, hooks.wikiId?.(), payload.twProxy, payload.twProxyAbsolute)
376
- showFrame(resolveTwUrl(bases.relative, bases.absolute), hooks.wikiId?.())
432
+ const wiki = hooks.wikiId?.()
433
+ const bases = twProxyFor(payload.mode, wiki, payload.twProxy, payload.twProxyAbsolute)
434
+ showFrame(resolveTwUrl(bases.relative, bases.absolute), wiki, wiki === undefined ? undefined : wikiLabel(wiki))
377
435
  } else if (typeof payload.url === 'string') {
378
- showFrame(payload.url, hooks.wikiId?.())
436
+ const wiki = hooks.wikiId?.()
437
+ showFrame(payload.url, wiki, wiki === undefined ? undefined : wikiLabel(wiki))
379
438
  } else {
380
439
  showError('服务未返回编辑器地址')
381
440
  }
@@ -410,14 +469,45 @@ export function createTwFrameSurface(skin: TwFrameSkin, hooks: TwFrameHooks = {}
410
469
  }
411
470
  document.addEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
412
471
 
472
+ /**
473
+ * Point the frame at the focused wiki RIGHT NOW, using the last `/status` payload.
474
+ *
475
+ * v0.28.14(作者报障「点击切换感觉会卡那么一下」):旧实现只调 `doRefresh()`,而它要**先**
476
+ * 等一次 `/status` 往返才动 iframe。host 处理一次 `/status` 会跑最多五个 `git` 进程
477
+ * (本机实测 300–400 ms),于是"点下去"与"真的开始换库"之间空了 0.3–0.4 秒,紧接着才是
478
+ * 10–30MB 文档的下载与解析 —— 两段加起来就是那一下卡顿。换库需要的全部信息(`mode`、
479
+ * 代理基址、名册)上一次刷新已经拿到了,没有理由再等一次。
480
+ *
481
+ * 没有可用 payload(首次加载失败、服务在重启)时什么都不做:调用方的 `doRefresh()`
482
+ * 仍会照旧把整件事做完,行为与 v0.28.13 完全一致。
483
+ */
484
+ const switchFrameNow = (): void => {
485
+ const payload = lastStatus
486
+ if (payload === undefined || payload.status !== 'running') return
487
+ const wiki = hooks.wikiId?.()
488
+ const label = wiki === undefined ? undefined : wikiLabel(wiki)
489
+ if (typeof payload.twProxy === 'string') {
490
+ const bases = twProxyFor(payload.mode, wiki, payload.twProxy, payload.twProxyAbsolute)
491
+ showFrame(resolveTwUrl(bases.relative, bases.absolute), wiki, label)
492
+ } else if (typeof payload.url === 'string') {
493
+ showFrame(payload.url, wiki, label)
494
+ }
495
+ }
496
+
413
497
  /**
414
498
  * Switching the focused knowledge base needs a different proxy path, so the
415
499
  * frame reloads (v0.28.0). TW reloads inside the iframe — one iframe cannot
416
500
  * host two editors, and silently keeping the old wiki's data while the UI says
417
501
  * otherwise is exactly the confusion this feature must not create.
502
+ *
503
+ * v0.28.14:**先立即换,再后台复探**。`switchFrameNow()` 用已有 payload 当场把 iframe
504
+ * 指过去(并把"正在载入…"挂上),`doRefresh()` 只负责随后的状态/配置对账,不再挡在
505
+ * 用户点击与界面反应之间。
418
506
  */
419
507
  const unsubscribeFocus = subscribeFocusWiki(() => {
420
- if (!disposed && visible) void doRefresh()
508
+ if (disposed || !visible) return
509
+ switchFrameNow()
510
+ void doRefresh()
421
511
  })
422
512
 
423
513
  const build = (): HTMLDivElement => {
@@ -438,14 +528,22 @@ export function createTwFrameSurface(skin: TwFrameSkin, hooks: TwFrameHooks = {}
438
528
  const errorEl = document.createElement('div')
439
529
  errorEl.className = skin.error
440
530
  errorEl.hidden = true
441
- viewEl.append(wrapEl, errorEl)
531
+ // 换库提示(v0.28.14):**view 的最后一个孩子**。刻意不放进 wrap / 不改 frame 的
532
+ // 显隐规则 —— 那条"未载入 TW 地址就绝不显示 frame"的规则是 v0.22.3 的血债,不碰它。
533
+ const loadingEl2 = document.createElement('div')
534
+ loadingEl2.className = skin.loading
535
+ loadingEl2.hidden = true
536
+ viewEl.append(wrapEl, errorEl, loadingEl2)
442
537
  view = viewEl
443
538
  frame = frameEl
444
539
  errorArea = errorEl
540
+ loadingEl = loadingEl2
445
541
  // Track load so a pending tiddler-hash navigation can target a ready
446
542
  // document (setting contentWindow.location.hash before load is a no-op).
447
543
  frameEl.addEventListener('load', () => {
448
544
  frameLoaded = true
545
+ // 新文档到了:收起"正在载入…"(幂等,普通刷新时它本来就是隐藏的)。
546
+ hideSwitching()
449
547
  applyPendingHash()
450
548
  })
451
549
  // Embedded TW follows the DSH light/dark theme (non-persisting palette
@@ -476,6 +574,8 @@ export function createTwFrameSurface(skin: TwFrameSkin, hooks: TwFrameHooks = {}
476
574
  } else {
477
575
  // Hidden surfaces stop polling and get a fresh budget on the next show.
478
576
  clearRetry()
577
+ // 界面都不显示了,"正在载入…" 没有意义(也不该在再次打开时凭空挂着)。
578
+ hideSwitching()
479
579
  refreshAttempts = 0
480
580
  }
481
581
  },
@@ -506,6 +606,7 @@ export function createTwFrameSurface(skin: TwFrameSkin, hooks: TwFrameHooks = {}
506
606
  document.removeEventListener(PANEL_RELOAD_EVENT, onReloadRequest)
507
607
  unsubscribeFocus()
508
608
  clearRetry()
609
+ clearSwitchTimer()
509
610
  for (const timer of hashWaitTimers) window.clearTimeout(timer)
510
611
  hashWaitTimers.clear()
511
612
  themeSyncDispose?.()
@@ -567,6 +668,7 @@ const RIGHTBAR_SKIN: TwFrameSkin = {
567
668
  frameWrap: 'dsh-tw-rightbar-frame-wrap',
568
669
  frame: 'dsh-tw-rightbar-frame',
569
670
  error: 'dsh-tw-rightbar-error',
671
+ loading: 'dsh-tw-loading',
570
672
  }
571
673
 
572
674
  /**
@@ -43,12 +43,22 @@ interface ScopeEntry { wikiId: string | undefined; at: number }
43
43
  const cache = new Map<string, ScopeEntry>()
44
44
  /** In-flight reads, so a card grid mounting at once makes ONE request. */
45
45
  const pending = new Map<string, Promise<string | undefined>>()
46
+ /** Scope-invalidation subscribers (v0.29.0) — see invalidateSessionWikiId. */
47
+ const scopeListeners = new Set<(sessionId?: string) => void>()
46
48
 
47
49
  /**
48
50
  * The wiki id this session is scoped to, or `undefined` for the default wiki.
49
51
  *
50
- * Never throws: a failed request resolves to `undefined` (the default), which is
51
- * the same thing the host does when nothing is scoped.
52
+ * v0.29.0 — "the default wiki" is now named explicitly in MULTI mode. When a
53
+ * session has no explicit scope, the host still serves its `tiddlywiki_*` calls
54
+ * from the registry default; the reply-stream card therefore shows THAT wiki's
55
+ * content, and its links must open THAT wiki. Previously this returned
56
+ * `undefined` for "no explicit scope", so with the session on the default wiki
57
+ * and the GUI focused on another one, a link inside the card (and inside the
58
+ * 「知识库」tab) opened the FOCUSED wiki — the v0.28.11 report, only half fixed
59
+ * (the card's own 「在 TW 打开」 button was already correct because it passes the
60
+ * resolved default). Single mode keeps returning `undefined`, so its DOM and
61
+ * URLs are byte-identical to what they were before multi-wiki.
52
62
  */
53
63
  export async function resolveSessionWikiId(sessionId: string | undefined): Promise<string | undefined> {
54
64
  if (typeof sessionId !== 'string' || sessionId.length === 0) return undefined
@@ -64,8 +74,11 @@ export async function resolveSessionWikiId(sessionId: string | undefined): Promi
64
74
  { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
65
75
  )
66
76
  if (!res.ok) return undefined
67
- const payload = (await res.json().catch(() => null)) as { scope?: unknown } | null
68
- return typeof payload?.scope === 'string' && payload.scope.length > 0 ? payload.scope : undefined
77
+ const payload = (await res.json().catch(() => null)) as { scope?: unknown; resolved?: { id?: unknown }; mode?: unknown } | null
78
+ if (typeof payload?.scope === 'string' && payload.scope.length > 0) return payload.scope
79
+ const resolvedId = payload?.resolved?.id
80
+ if (payload?.mode === 'multi' && typeof resolvedId === 'string' && resolvedId.length > 0) return resolvedId
81
+ return undefined
69
82
  } catch {
70
83
  return undefined
71
84
  } finally {
@@ -85,14 +98,26 @@ export async function resolveSessionWikiId(sessionId: string | undefined): Promi
85
98
  * so the cards rendered next reflect the new knowledge base instead of waiting
86
99
  * out the TTL — a stale entry here is the same "card shows another wiki's data"
87
100
  * bug this module exists to fix.
101
+ *
102
+ * v0.29.0 also NOTIFIES subscribers: invalidating the id is enough for a card
103
+ * that re-renders anyway, but the 「知识库」 tab caches a whole generated summary
104
+ * for 3 minutes (`session-summary.ts`), so without a signal it kept showing the
105
+ * previous wiki's notes (and its `data-dsh-tw-wiki`) after a switch.
88
106
  */
89
107
  export function invalidateSessionWikiId(sessionId?: string): void {
90
108
  if (typeof sessionId === 'string' && sessionId.length > 0) cache.delete(sessionId)
91
109
  else cache.clear()
110
+ const id = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : undefined
111
+ for (const listener of [...scopeListeners]) listener(id)
92
112
  }
93
113
 
94
- /** Test/teardown hook: forget everything, including in-flight reads. */
95
- export function resetSessionWikiIdCache(): void {
96
- cache.clear()
97
- pending.clear()
114
+ /**
115
+ * Subscribe to scope invalidations (one arg = the session, `undefined` = all).
116
+ *
117
+ * Returns the unsubscribe function; callers are React effects, so they must
118
+ * clean up on unmount like every other listener in this half.
119
+ */
120
+ export function subscribeSessionWikiId(listener: (sessionId?: string) => void): () => void {
121
+ scopeListeners.add(listener)
122
+ return () => { scopeListeners.delete(listener) }
98
123
  }
@@ -35,6 +35,17 @@ export interface AdminDeps {
35
35
  * practice, so the settings-page routes must say WHICH wiki they configure.
36
36
  */
37
37
  config: (req: IncomingMessage) => ConfigStore
38
+ /**
39
+ * Why this request names a knowledge base that cannot serve it (v0.29.0):
40
+ * `?wiki=<id>` naming a REGISTERED but STOPPED wiki returns an actionable
41
+ * sentence, everything else `undefined`.
42
+ *
43
+ * The settings page is the worst offender for the silent-wrong-wiki class
44
+ * (its status row and config form are built from `/admin/state`, which would
45
+ * otherwise answer with the DEFAULT wiki's path/git/config while the scope bar
46
+ * says 「正在配置:books」), so the wiki-scoped admin handlers check this FIRST.
47
+ */
48
+ targetProblem?: (req: IncomingMessage) => string | undefined
38
49
  /**
39
50
  * Called after a successful settings-page save (v0.21.0). The plugin rebuilds
40
51
  * its system-prompt section here, so a prompt.* edit applies to the running
@@ -138,9 +149,33 @@ export type AdminWikisApplyResult =
138
149
  | { ok: false; error: string }
139
150
 
140
151
  export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: AdminDeps): () => void {
152
+ /**
153
+ * 503 when `?wiki=<id>` names a REGISTERED but STOPPED knowledge base (v0.29.0).
154
+ *
155
+ * The settings page is where this used to hurt most: the scope bar said
156
+ * 「正在配置:books」 while `/admin/state` answered with the DEFAULT wiki's
157
+ * path/git/`tiddlywiki.info` and `/admin/config` wrote the books patch into the
158
+ * default wiki's config tiddler. Every wiki-scoped handler asks this FIRST,
159
+ * because `deps.config` / `deps.getWikiPath` have harmless-looking fallbacks
160
+ * that only ASK for the wrong-answer bug when the target is known-but-stopped.
161
+ */
162
+ const refuseStoppedTarget = (req: IncomingMessage, res: ServerResponse): boolean => {
163
+ const problem = deps.targetProblem?.(req)
164
+ if (problem === undefined) return false
165
+ json(res, { ok: false, error: problem }, 503)
166
+ return true
167
+ }
168
+
169
+ /** The 503 body for "no running wiki behind this request", wiki named if known. */
170
+ const notRunning = (req: IncomingMessage): string => deps.targetProblem?.(req) ?? 'wiki service is not running'
171
+
141
172
  const handleState = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
142
173
  try {
143
174
  if (rejectNonRead(req, res)) return
175
+ // `/admin/state` is the settings page's whole view of "the wiki I am
176
+ // configuring": answering with the default wiki's path/git/config while the
177
+ // scope bar names another one is the silent-wrong-wiki bug (v0.29.0).
178
+ if (refuseStoppedTarget(req, res)) return
144
179
  const wikiPath = deps.getWikiPath(req)
145
180
  const [info, catalog] = await Promise.all([readWikiInfo(wikiPath), bundledCatalog(deps.twRoot())])
146
181
  let git: unknown = null
@@ -183,6 +218,9 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
183
218
  const handleInfo = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
184
219
  try {
185
220
  if (rejectCrossSiteWrite(req, res, ['POST'])) return
221
+ // 插件/主题/语言写的是该库的 tiddlywiki.info:目标库没在跑就必须拒绝,
222
+ // 否则会把 A 库的清单写进**默认库**的文件(v0.29.0)。
223
+ if (refuseStoppedTarget(req, res)) return
186
224
  const body = JSON.parse(await readBody(req)) as { plugins?: unknown; themes?: unknown; themeActive?: unknown; languages?: unknown }
187
225
  const wikiPath = deps.getWikiPath(req)
188
226
  // A read failure must NEVER be turned into "the wiki has no plugins":
@@ -324,7 +362,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
324
362
  const body = JSON.parse(await readBody(req)) as unknown
325
363
  const client = deps.getClient(req)
326
364
  if (client === undefined) {
327
- json(res, { ok: false, error: 'wiki service is not running' }, 503)
365
+ json(res, { ok: false, error: notRunning(req) }, 503)
328
366
  return
329
367
  }
330
368
  // Validate/normalise BEFORE merging (v0.25.0): the raw body used to be
@@ -387,7 +425,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
387
425
  if (rejectNonRead(req, res)) return
388
426
  const client = deps.getClient(req)
389
427
  if (client === undefined) {
390
- json(res, { ok: false, error: 'wiki service is not running' }, 503)
428
+ json(res, { ok: false, error: notRunning(req) }, 503)
391
429
  return
392
430
  }
393
431
  const items = await deps.seeds.checkAll(client)
@@ -410,7 +448,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
410
448
  const force = body.force === true
411
449
  const client = deps.getClient(req)
412
450
  if (client === undefined) {
413
- json(res, { ok: false, error: 'wiki service is not running' }, 503)
451
+ json(res, { ok: false, error: notRunning(req) }, 503)
414
452
  return
415
453
  }
416
454
  const seedStartedAt = Date.now()
@@ -465,7 +503,7 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
465
503
  const id = typeof body.id === 'string' && body.id.trim().length > 0 ? body.id.trim() : undefined
466
504
  const client = deps.getClient(req)
467
505
  if (client === undefined) {
468
- json(res, { ok: false, error: 'wiki service is not running' }, 503)
506
+ json(res, { ok: false, error: notRunning(req) }, 503)
469
507
  return
470
508
  }
471
509
  const results = await deps.seeds.remove(client, id)
@@ -492,6 +530,9 @@ export function registerAdminRoutes(ctx: { webServer: WebServerFace }, deps: Adm
492
530
  */
493
531
  const handlePrompt = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
494
532
  try {
533
+ // 提示词是**按库**存的(每库一份 prompt.*):目标库没在跑时给出基座默认,
534
+ // 等于把「预览」变成另一个库的文本(v0.29.0)。
535
+ if (refuseStoppedTarget(req, res)) return
495
536
  const draft = (req.method ?? 'GET').toUpperCase() === 'POST'
496
537
  if (draft) {
497
538
  if (rejectCrossSiteWrite(req, res, ['POST'])) return
@@ -610,6 +610,19 @@ const CORS_HEADERS: Record<string, string> = {
610
610
  export class ClipBridge {
611
611
  private server: Server | undefined
612
612
  private boundPort = 0
613
+ /**
614
+ * 剪藏请求的串行队列(v0.29.0)。
615
+ *
616
+ * WHY: the note title is chosen with a check-then-write
617
+ * (`resolveClipTitle(deps.exists, …)`), so two clips of the same page in flight
618
+ * — a double bookmarklet click, or two tabs — both see "X is free" and the
619
+ * second PUT silently replaces the first note (leaving the first note's image
620
+ * tiddlers orphaned). TW's REST has no compare-and-set, so the cross-process
621
+ * race cannot be closed; the same-process one can, and that IS the common case:
622
+ * serialize `/clip` handling here. The second request then resolves the title
623
+ * AFTER the first landed and picks 「X 2」.
624
+ */
625
+ private clipChain: Promise<unknown> = Promise.resolve()
613
626
 
614
627
  constructor(private readonly deps: ClipBridgeDeps) {}
615
628
 
@@ -622,7 +635,12 @@ export class ClipBridge {
622
635
  start(port: number): Promise<void> {
623
636
  if (this.server !== undefined) return Promise.resolve()
624
637
  const server = createServer((req, res) => {
625
- void this.handle(req, res).catch((err: unknown) => {
638
+ // 串行执行(v0.29.0):标题解析是"先查后写",并发时第二次会覆盖第一次
639
+ // (见 clipChain 的注释)。队列本身必须永远保持 resolved,否则一次失败
640
+ // 会把后续所有剪藏请求一起卡住。
641
+ const run = this.clipChain.then(() => this.handle(req, res))
642
+ this.clipChain = run.then(() => undefined, () => undefined)
643
+ run.catch((err: unknown) => {
626
644
  const message = err instanceof Error ? err.message : String(err)
627
645
  this.deps.log?.(`request failed: ${message}`)
628
646
  if (!res.headersSent) {
@@ -207,6 +207,25 @@ export function describeUnreadableConfig(): string {
207
207
  + '为避免抹掉其它设置,本次保存已被拒绝。请先修好该 tiddler(或删除它、回落到 cordis config 块)再保存。'
208
208
  }
209
209
 
210
+ /**
211
+ * Same refusal, different cause (v0.29.0): the tiddler could not be READ at all
212
+ * and we have never managed to read it — so we do not know what it contains.
213
+ *
214
+ * This is the v0.23.4 wipe through the other door. `set()` re-reads the tiddler
215
+ * specifically so a transient startup failure cannot cost the user's other
216
+ * overrides; that protection only exists when the re-read SUCCEEDS. When both
217
+ * reads failed and the cache is still the empty placeholder, merging a one-key
218
+ * patch onto it and writing persists a config tiddler containing ONLY that key —
219
+ * silently dropping `prompt.extra`, `git.remote`, every `ui.*` switch.
220
+ *
221
+ * Rule #3 ("a read failure is not an absence") applied to a read-modify-write:
222
+ * the honest answer is to refuse and let the user retry.
223
+ */
224
+ export function describeUnconfirmedConfig(): string {
225
+ return `读不出配置 tiddler ${CONFIG_TIDDLER}(TiddlyWiki 正在重启或超时),因此无法确认里面还有哪些设置。`
226
+ + '为避免只写入本次改动、抹掉其余设置,本次保存已中止,请稍后重试。'
227
+ }
228
+
210
229
  /** A settings-page patch that cannot be accepted (v0.25.0) — mapped to HTTP 400. */
211
230
  export class ConfigPatchError extends Error {
212
231
  constructor(message: string) {
@@ -315,6 +334,13 @@ export function normalizeConfigPatch(input: unknown): PluginConfigShape {
315
334
  */
316
335
  export class ConfigStore {
317
336
  private overrides: PluginConfigShape = {}
337
+ /**
338
+ * True once this store has successfully DETERMINED the stored overrides
339
+ * (v0.29.0): either the tiddler parsed, or it was absent (404 = no overrides).
340
+ * A failed read leaves it false, which is what makes `set()` refuse to merge
341
+ * onto an empty cache it cannot vouch for — see describeUnconfirmedConfig().
342
+ */
343
+ private loaded = false
318
344
  /** Set when the stored tiddler exists but cannot be parsed (v0.23.4). */
319
345
  private lastParseError: string | undefined
320
346
 
@@ -361,9 +387,13 @@ export class ConfigStore {
361
387
  console.warn('[dsh-tiddlywiki] config tiddler unreadable, keeping cached overrides:', err instanceof Error ? err.message : err)
362
388
  return
363
389
  }
364
- if (tiddler === undefined) {
390
+ // `== null` covers a client that answers `null` for "no such tiddler" as well
391
+ // as the documented 404 → undefined (v0.29.0: `null.text` used to throw and be
392
+ // reported as "unparseable config", which is a different problem entirely).
393
+ if (tiddler == null) {
365
394
  this.overrides = {}
366
395
  this.lastParseError = undefined
396
+ this.loaded = true
367
397
  return
368
398
  }
369
399
  const raw = typeof tiddler.text === 'string' ? tiddler.text : ''
@@ -372,6 +402,7 @@ export class ConfigStore {
372
402
  if (!isPlainObject(parsed)) throw new Error('config tiddler is not a JSON object')
373
403
  this.overrides = parsed as PluginConfigShape
374
404
  this.lastParseError = undefined
405
+ this.loaded = true
375
406
  } catch {
376
407
  // Malformed/unparseable: keep the cache (never revert to the base) and
377
408
  // remember WHY so the UI can say it out loud.
@@ -400,24 +431,35 @@ export class ConfigStore {
400
431
  let storedText: string | undefined
401
432
  try {
402
433
  const tiddler = await client.get(CONFIG_TIDDLER)
403
- if (tiddler !== undefined && typeof tiddler.text === 'string') {
434
+ // `!= null` (not `!== undefined`): a client that answers `null` for "no such
435
+ // tiddler" means exactly that — absent, so writing only this patch loses
436
+ // nothing. Only a THROWN read is "unconfirmed" (v0.29.0).
437
+ if (tiddler != null && typeof tiddler.text === 'string') {
404
438
  storedText = tiddler.text
405
439
  const parsed = JSON.parse(tiddler.text) as unknown
406
440
  if (!isPlainObject(parsed)) throw new Error('config tiddler is not a JSON object')
407
441
  stored = parsed as PluginConfigShape
408
442
  }
409
- if (tiddler !== undefined && typeof tiddler.created === 'string' && tiddler.created.trim().length > 0) {
443
+ if (tiddler != null && typeof tiddler.created === 'string' && tiddler.created.trim().length > 0) {
410
444
  existingCreated = tiddler.created
411
445
  }
412
446
  } catch (err) {
413
447
  // Read succeeded (we have the raw text) but the content is unusable:
414
- // refuse — writing would drop every key the caller did not send. A READ
415
- // failure (no raw text) keeps the old "merge onto the cache" behaviour.
448
+ // refuse — writing would drop every key the caller did not send.
416
449
  if (storedText !== undefined) {
417
450
  this.lastParseError = describeUnreadableConfig()
418
451
  throw new ConfigUnreadableError(describeUnreadableConfig(), storedText)
419
452
  }
420
- // Unreadable config tiddler → merge onto the in-memory cache.
453
+ // The read FAILED and this store never confirmed what is stored (v0.29.0):
454
+ // the cache is still the empty placeholder, so merging onto it would persist
455
+ // a config tiddler holding ONLY this patch — every other override gone.
456
+ // Refuse; a retry after the wiki settles is cheap, the loss is not.
457
+ if (!this.loaded) {
458
+ this.lastParseError = describeUnconfirmedConfig()
459
+ throw new ConfigUnreadableError(describeUnconfirmedConfig(), '')
460
+ }
461
+ // Read failure with a cache we DO trust (it came from a successful read):
462
+ // keep the old "merge onto the cache" behaviour.
421
463
  void err
422
464
  }
423
465
  const merged = deepMerge(stored, patch) as PluginConfigShape
@@ -180,29 +180,48 @@ export function escapePromptBraces(text: string): string {
180
180
  return text.replace(/\{(?=\{)/g, '{\u200B')
181
181
  }
182
182
 
183
- /** Governance block: how to write without clobbering human edits. */
184
- const WRITE_RULES = `### 写入与并发
185
- - 覆盖或删除已有笔记前先 \`tiddlywiki_get\` 读一次,把读到的 \`modified\`(或 \`revision\`)作为 \`expectedModified\`(或 \`expectedRevision\`)传回去;若期间有人(在 TW 编辑器里)改过,写入会被拒绝并告诉你当前值——此时重新读一遍再决定,**不要用 \`force\` 硬覆盖**。\`tiddlywiki_attach\` 对同名已有条目默认拒绝写入,理由相同(要覆盖得显式 \`force: true\`)。
186
- - 纯增量内容(日志、批注、清单)优先用 \`tiddlywiki_append\`,不必读全文。
187
- - 覆盖既有条目时**不传 \`tags\` 就保留原有标签、自定义字段与内容类型**(只改正文);显式传 \`tags\` 才整体替换标签,要改内容类型用 \`fields.type\`。
188
- - 内容类型:新笔记默认写成 Markdown(工具自动补 \`text/markdown\`,\`$:/\` 系统条目除外);覆盖或追加既有条目时保留它原有的 \`type\`。要写 TW 原生 wikitext 才显式传 \`fields: {"type":"text/vnd.tiddlywiki"}\`。⚠️ \`fields.type\` 是 TW 的**内容类型**保留字段,业务分类请放 \`tags\`。`
183
+ /**
184
+ * Governance block: how to write without clobbering human edits.
185
+ *
186
+ * v0.29.0 — TRIMMED TO THE PART THE SCHEMAS CANNOT SAY.
187
+ *
188
+ * This block used to restate `tiddlywiki_put` / `append` / `batch_put` / `attach`
189
+ * nearly sentence for sentence: tags/fields/type preservation, `tags: []` meaning
190
+ * "clear", the markdown default for new notes, `fields.type` being TW's content
191
+ * type. All of that is ALREADY in those tools' descriptions — which the model
192
+ * receives on every request — and this section's own intro tells it that the
193
+ * schemas are the contract. Two copies of one rule is not redundancy insurance,
194
+ * it is a second thing to keep in sync (the exact drift that v0.21.0 removed).
195
+ *
196
+ * What is left is the one thing no schema states: after a refused write, read
197
+ * again instead of forcing. `scripts/verify-prompt.mjs` now asserts the delegated
198
+ * rules on the SCHEMA side, so deleting them here can never lose them silently.
199
+ */
200
+ const WRITE_RULES = `### 写入约定
201
+ - 覆盖或删除已有笔记前先 \`tiddlywiki_get\` 读一次,并把读到的 \`expectedModified\`(或 \`expectedRevision\`)传回去;被拒绝说明刚有人(在 TW 编辑器里)改过——**重读一遍再决定,不要用 \`force\` 硬覆盖**。`
189
202
 
190
203
  /** Governance block: the three-step git discipline + conflict recovery. */
191
- const SYNC_RULES = `### 同步纪律(三条)
192
- 1. 开工先 pull:\`tiddlywiki_git_sync action=pull\`(rebase + autostash;真冲突会自动 abort 并报冲突文件)。
193
- 2. 收工 commit + push:\`tiddlywiki_git_sync action=sync\`(pull → commit → push)。
194
- 3. 插件会自动防抖 commit(默认 60s),需要立刻同步时用上面的工具。
195
- pull 冲突后:先 \`tiddlywiki_git_resolve files=[冲突文件] strategy=keep-local\`(保留本地)或 \`strategy=keep-remote\`(改用远端版本),再重新 pull/sync 整合其余改动。`
204
+ const SYNC_RULES = `### 同步纪律
205
+ - 开工先 \`tiddlywiki_git_sync action=pull\`,收工 \`action=sync\`(插件另会防抖自动 commit,默认 60s)。
206
+ - pull 冲突:先 \`tiddlywiki_git_resolve files=[冲突文件] strategy=keep-local\`(保留本地)或 \`keep-remote\`,再重新 pull/sync 整合其余改动。`
196
207
 
197
- /** Governance block: knowledge-base conventions (tags, memory, links). */
208
+ /**
209
+ * Governance block: knowledge-base conventions (tags, memory, links).
210
+ *
211
+ * v0.29.0 — the 「工作区标记自动打」 bullet and the 「检索先窄后宽 / 多词 AND」
212
+ * bullet were REMOVED here because `tiddlywiki_put` and `tiddlywiki_search` already
213
+ * document them verbatim (see verify-prompt.mjs, which now asserts them on the
214
+ * schema side). What stays is what only this section can say: what a note IS for
215
+ * (long-term memory), the todo convention, time-boxing/supersession fields, the
216
+ * `human-edited` counterpart to the automatic `agent-written`, and the clickable
217
+ * link format.
218
+ */
198
219
  const NOTE_RULES = `### 笔记约定
199
220
  - wiki 是长期记忆:会议纪要、决策记录、调研笔记、随手的想法都存成独立 tiddler(tag 用 inbox/meeting/decision 等便于检索)。
200
- - **值得做但不在当前范围内的想法**:用 \`tiddlywiki_put\` 写成独立 tiddler、打 \`todo\`,正文简述来源(会话 / 工作区 / 项目背景),由用户决定是否继续。
201
- - **工作区标记自动打**:新建笔记自动带 \`ws/<项目名>\` 标签与 \`workspace\` 字段(取自会话工作目录),**不要手动再加**;只有内容显然属于**另一个**项目时才显式写 \`workspace\` 字段。
202
- - **检索先窄后宽**:\`tiddlywiki_search\` 先在工作区内查、没命中才自动扩到全库,回执写明实际范围——**「工作区内 0 条」不等于库里没有**。查询按空白切词、**全部词命中**才算(AND)。
203
- - **阶段性内容标时效**:会过期的笔记(版本记录 / 部署步骤 / 排期 / 临时方案 / 一次性口令)写时带 \`valid-until: YYYY-MM-DD\`(硬过期)或 \`review-after: YYYY-MM-DD\`(该复查);被取代时写 \`superseded-by: [[新笔记]]\` 并打 \`superseded\` 标签、**保留旧笔记**。**淘汰只由人决定**,不要自行删除。
221
+ - **值得做但不在当前范围内的想法**:写成独立 tiddler、打 \`todo\`,正文简述来源(会话 / 工作区 / 项目背景),由用户决定是否继续。
222
+ - **阶段性内容标时效**:会过期的笔记带 \`valid-until: YYYY-MM-DD\`(硬过期)或 \`review-after: YYYY-MM-DD\`(该复查);被取代时写 \`superseded-by: [[新笔记]]\` 并打 \`superseded\` 标签、**保留旧笔记**。**淘汰只由人决定**,不要自行删除。
204
223
  - \`agent-written\` 由工具自动补打,别手动加或删;人类编辑过 Agent 笔记后补 \`human-edited\`。
205
- - **引用笔记用可点击链接**:\`[标题](/dsh-tiddlywiki/tw/#标题)\`(空格等特殊字符做 URL 编码;中文可直写)。点击会打开中央 TW 面板并跳转,优先用它代替纯文本标题。`
224
+ - **引用笔记用可点击链接**:\`[标题](/dsh-tiddlywiki/tw/#标题)\`(空格等特殊字符做 URL 编码;中文可直写)。点它会打开中央 TW 面板并跳转,优先用它代替纯文本标题。`
206
225
 
207
226
  /**
208
227
  * Publish-metadata rule, appended ONLY when `wechat.enabled` is on (v0.23.0).
@@ -233,11 +252,20 @@ export function toolSignatureLines(tools: readonly PromptToolSummary[], bullet =
233
252
  })
234
253
  }
235
254
 
236
- /** Heading + a one-line capability pointer (slim intro). */
255
+ /**
256
+ * Heading + a one-line capability pointer (slim intro).
257
+ *
258
+ * v0.29.0 — the hand-written CATEGORY LIST ("检索/读写/批量/增量追加/重命名…") is
259
+ * gone. Only the COUNT was derived from the live registry, so adding or renaming a
260
+ * tool silently made that sentence wrong, and `verify-prompt.mjs` could not see it
261
+ * (it asserts there is no signature catalogue, not that this list is current). The
262
+ * tools themselves are described in full by their schemas, which the model always
263
+ * has; a second, unmaintainable summary of them is exactly what v0.21.0 removed.
264
+ */
237
265
  function slimIntro(count: number): string {
238
266
  return `## TiddlyWiki 持久知识库
239
267
 
240
- 本机有一个 TiddlyWiki 5 持久知识库(wiki 文件夹即 git 仓库)。插件提供 ${count} 个 \`tiddlywiki_*\` 工具:检索/读写/批量/增量追加/重命名/删除与回收站/反向链接/附件/体检/git 同步与冲突解决。**参数与返回契约以各工具 schema 的 description 为准**,本段只补充 schema 表达不了的约定。`
268
+ 本机有一个 TiddlyWiki 5 持久知识库(wiki 文件夹即 git 仓库)。插件提供 ${count} 个 \`tiddlywiki_*\` 工具:**参数与返回契约以各工具 schema 的 description 为准**,本段只补充 schema 表达不了的约定。`
241
269
  }
242
270
 
243
271
  /** Heading + the generated signature catalogue (full intro). */