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.
- package/docs/CHANGELOG.md +4 -0
- package/docs/development.md +41 -18
- package/docs/features.md +3 -3
- package/docs/seed-initialization.md +4 -3
- package/docs/usage.md +3 -2
- package/docs/wechat-publish-setup.md +1 -1
- package/lib/client.js +25 -15
- package/lib/index.js +249 -52
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/knowledge-fab.ts +46 -8
- package/src/client/note-widget.ts +83 -23
- package/src/client/panel.ts +1 -0
- package/src/client/rightbar-tab.ts +5 -0
- package/src/client/session-summary.ts +19 -1
- package/src/client/settings-page-config.ts +9 -0
- package/src/client/settings-page-runtime.ts +1 -6
- package/src/client/settings-page.ts +39 -7
- package/src/client/sidebar-entry.ts +33 -9
- package/src/client/styles.ts +10 -0
- package/src/client/tool-views.ts +5 -2
- package/src/client/tw-frame.ts +109 -7
- package/src/client/wiki-scope.ts +33 -8
- package/src/host/admin-routes.ts +45 -4
- package/src/host/clip-bridge.ts +19 -1
- package/src/host/config.ts +48 -6
- package/src/host/prompt.ts +47 -19
- package/src/host/routes-session.ts +10 -2
- package/src/host/routes.ts +49 -6
- package/src/host/seed-wechat-docs.ts +1 -1
- package/src/host/session-scope.ts +16 -1
- package/src/host/tools-attach.ts +1 -1
- package/src/host/wiki-farm.ts +55 -10
- package/src/host/wiki-instance.ts +18 -5
- package/src/index.ts +22 -2
package/src/client/tw-frame.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
376
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
/**
|
package/src/client/wiki-scope.ts
CHANGED
|
@@ -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
|
-
*
|
|
51
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
}
|
package/src/host/admin-routes.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
package/src/host/clip-bridge.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/host/config.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
package/src/host/prompt.ts
CHANGED
|
@@ -180,29 +180,48 @@ export function escapePromptBraces(text: string): string {
|
|
|
180
180
|
return text.replace(/\{(?=\{)/g, '{\u200B')
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
/**
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
/**
|
|
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
|
-
-
|
|
201
|
-
-
|
|
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
|
|
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
|
-
/**
|
|
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_*\`
|
|
268
|
+
本机有一个 TiddlyWiki 5 持久知识库(wiki 文件夹即 git 仓库)。插件提供 ${count} 个 \`tiddlywiki_*\` 工具:**参数与返回契约以各工具 schema 的 description 为准**,本段只补充 schema 表达不了的约定。`
|
|
241
269
|
}
|
|
242
270
|
|
|
243
271
|
/** Heading + the generated signature catalogue (full intro). */
|