dsh-tiddlywiki 0.27.2 → 0.28.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/LICENSE +21 -21
- package/README.md +3 -0
- package/cordis.patch.yml +13 -13
- package/docs/CHANGELOG.md +2 -1
- package/docs/code-review-2026-09-17.md +151 -0
- package/docs/features.md +5 -2
- package/docs/usage.md +34 -3
- package/docs/wiki-split.md +97 -0
- package/lib/client.js +54 -27
- package/lib/index.js +2803 -517
- package/lib/index.js.map +1 -1
- package/package.json +5 -4
- package/skills/wiki-split/SKILL.md +106 -0
- package/src/client/endpoints.ts +37 -0
- package/src/client/index.ts +14 -0
- package/src/client/knowledge-fab.ts +62 -3
- package/src/client/markdown-editor.ts +128 -128
- package/src/client/panel.ts +2 -1
- package/src/client/settings-page.ts +217 -3
- package/src/client/state.ts +39 -39
- package/src/client/status-cache.ts +17 -0
- package/src/client/styles.ts +27 -0
- package/src/client/toast.ts +22 -22
- package/src/client/tw-frame.ts +28 -4
- package/src/client/wiki-focus.ts +70 -0
- package/src/client/wiki-scope-dock.ts +116 -0
- package/src/host/admin.ts +175 -35
- package/src/host/git.ts +50 -5
- package/src/host/path-key.ts +35 -0
- package/src/host/prompt.ts +24 -0
- package/src/host/repo-committers.ts +149 -0
- package/src/host/routes.ts +254 -81
- package/src/host/seeds.ts +51 -10
- package/src/host/session-scope.ts +154 -0
- package/src/host/skill-install.ts +121 -0
- package/src/host/tools.ts +141 -49
- package/src/host/wiki-farm.ts +417 -0
- package/src/host/wiki-instance.ts +596 -0
- package/src/host/wiki-registry.ts +601 -0
- package/src/host/wiki.ts +19 -0
- package/src/index.ts +686 -459
package/src/host/routes.ts
CHANGED
|
@@ -193,14 +193,73 @@ export interface UiDefaultsPublic {
|
|
|
193
193
|
showRightbarTab: boolean
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/** One knowledge base as the GUI sees it (list/selector payload). */
|
|
197
|
+
export interface WikiSummaryPublic {
|
|
198
|
+
id: string
|
|
199
|
+
label: string
|
|
200
|
+
/** Health of this wiki's TW child ('running' | 'starting' | 'stopped' | 'failed'). */
|
|
201
|
+
status: string
|
|
202
|
+
/** May the AGENT reach it at all (hidden ones are absent from every prompt). */
|
|
203
|
+
agentVisible: boolean
|
|
204
|
+
/** Comes up at boot (multi mode); others start on demand. */
|
|
205
|
+
autostart: boolean
|
|
206
|
+
/** A TW child is currently serving it. */
|
|
207
|
+
running: boolean
|
|
208
|
+
path: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The whole farm as the GUI sees it. */
|
|
212
|
+
export interface WikiFarmPublic {
|
|
213
|
+
/** 'single' = one wiki (legacy behaviour); 'multi' = the farm. */
|
|
214
|
+
mode: string
|
|
215
|
+
/** Id a session without an explicit scope falls back to. */
|
|
216
|
+
defaultId: string
|
|
217
|
+
items: WikiSummaryPublic[]
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Everything the routes need from the plugin.
|
|
222
|
+
*
|
|
223
|
+
* v0.28.0: every PER-WIKI accessor takes the request, because the plugin no
|
|
224
|
+
* longer serves exactly one knowledge base. In single-wiki mode they all
|
|
225
|
+
* resolve to the same instance, so the call sites read exactly as before; in
|
|
226
|
+
* multi-wiki mode they resolve the wiki THIS request targets (`?wiki=<id>`, or
|
|
227
|
+
* the session's scope — see host/wiki-farm.ts).
|
|
228
|
+
*
|
|
229
|
+
* ⚠️ `server` returns `undefined` when the targeted wiki is not running. The
|
|
230
|
+
* routes then answer 503 — the same answer the previous
|
|
231
|
+
* `getClient() === undefined` guard already produced.
|
|
232
|
+
*
|
|
233
|
+
* The DSH *session* services below stay request-independent on purpose: they
|
|
234
|
+
* belong to the host, not to a wiki.
|
|
235
|
+
*/
|
|
196
236
|
export interface RouteDeps {
|
|
197
|
-
|
|
198
|
-
|
|
237
|
+
/** The whole farm's list/mode/default (the GUI's per-wiki selector). */
|
|
238
|
+
wikiSummaries: (req: IncomingMessage) => WikiFarmPublic
|
|
239
|
+
/** The TW child serving the wiki this request targets (undefined = not up). */
|
|
240
|
+
server: (req: IncomingMessage) => WikiServer | undefined
|
|
241
|
+
/** Lazily resolved REST client for the same wiki. */
|
|
242
|
+
getClient: (req: IncomingMessage) => TiddlyWebClient | undefined
|
|
243
|
+
/** The TW child of a NAMED wiki (the `/tw/<id>/…` proxy form). */
|
|
244
|
+
serverById: (id: string) => WikiServer | undefined
|
|
245
|
+
/** Every registered wiki id (lets the proxy tell an id from a TW path). */
|
|
246
|
+
wikiIds: () => readonly string[]
|
|
199
247
|
git: GitFace
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
248
|
+
/** Debounced auto-commit touch for the targeted wiki. */
|
|
249
|
+
autoCommit: (req: IncomingMessage) => void
|
|
250
|
+
/** Default note tag of the targeted wiki. */
|
|
251
|
+
noteDefaults: (req: IncomingMessage) => { tag: string }
|
|
252
|
+
/** Effective UI flags of the targeted wiki. */
|
|
253
|
+
uiDefaults: (req: IncomingMessage) => UiDefaultsPublic
|
|
254
|
+
/** Absolute folder of the targeted wiki. */
|
|
255
|
+
getWikiPath: (req: IncomingMessage) => string
|
|
256
|
+
/**
|
|
257
|
+
* After a pull moved HEAD: restart the running wikis whose content actually
|
|
258
|
+
* changed, and report their ids (v0.28.0). `changedFiles` are
|
|
259
|
+
* repository-relative paths, so a shared repository can restart exactly the
|
|
260
|
+
* affected knowledge bases. Absent in headless contexts (no restart).
|
|
261
|
+
*/
|
|
262
|
+
restartAffected?: (req: IncomingMessage, dir: string, changedFiles: readonly string[]) => Promise<{ restarted: string[]; failed: Array<{ id: string; message: string }> }>
|
|
204
263
|
/** Optional DSH sessionController service (agent-send routes only); resolved
|
|
205
264
|
* lazily per request because it may register after webServer appears. */
|
|
206
265
|
getSessionController: () => SessionControllerFace | undefined
|
|
@@ -224,20 +283,33 @@ export interface RouteDeps {
|
|
|
224
283
|
* session's complete event log + descendant tree to decide which wiki notes
|
|
225
284
|
* belong to this conversation. Resolved lazily like the other services. */
|
|
226
285
|
getSessionQuery: () => SessionQueryFace | undefined
|
|
227
|
-
/** Whether the one-click send-to-agent feature is enabled
|
|
228
|
-
sendToAgentEnabled: () => boolean
|
|
286
|
+
/** Whether the one-click send-to-agent feature is enabled for that wiki. */
|
|
287
|
+
sendToAgentEnabled: (req: IncomingMessage) => boolean
|
|
229
288
|
/** Optional shared token that must match `x-send-to-agent-token` when set. */
|
|
230
|
-
sendToAgentToken: () => string
|
|
289
|
+
sendToAgentToken: (req: IncomingMessage) => string
|
|
231
290
|
/**
|
|
232
|
-
* Effective `wechat.*` config (v0.23.3): the opt-in
|
|
233
|
-
* switch, CLI override, optional token and default
|
|
234
|
-
* so a settings-page save applies without a dsh web
|
|
291
|
+
* Effective `wechat.*` config (v0.23.3) OF THE TARGETED WIKI: the opt-in
|
|
292
|
+
* 公众号发布 feature's switch, CLI override, optional token and default
|
|
293
|
+
* adapter. Read per request so a settings-page save applies without a dsh web
|
|
294
|
+
* restart.
|
|
235
295
|
*/
|
|
236
|
-
wechatConfig: () => WechatPublishConfig
|
|
237
|
-
/** The publish runner
|
|
296
|
+
wechatConfig: (req: IncomingMessage) => WechatPublishConfig
|
|
297
|
+
/** The publish runner (plugin-wide: one opencli child at a time). */
|
|
238
298
|
wechatRunner: () => WechatPublishFace | undefined
|
|
239
299
|
/** Readiness report (opencli + adapter files) for the button's precheck. */
|
|
240
|
-
wechatReady: () => Promise<WechatReadyView>
|
|
300
|
+
wechatReady: (req: IncomingMessage) => Promise<WechatReadyView>
|
|
301
|
+
/**
|
|
302
|
+
* Per-session knowledge-base scope (v0.28.0): what this conversation works on.
|
|
303
|
+
* The composer's selector reads and writes it; the agent tools and the
|
|
304
|
+
* injected prompt are driven by the same value, so what the user picks is what
|
|
305
|
+
* the model gets.
|
|
306
|
+
*/
|
|
307
|
+
sessionScope?: {
|
|
308
|
+
/** Current choice + what it resolves to (and why, when it cannot serve). */
|
|
309
|
+
get: (sessionId: string) => { scope?: string; resolved?: { id: string; label: string }; reason?: string }
|
|
310
|
+
/** Point the session at a wiki, or clear the choice with `undefined`. */
|
|
311
|
+
set: (sessionId: string, wikiId: string | undefined) => Promise<void>
|
|
312
|
+
}
|
|
241
313
|
}
|
|
242
314
|
|
|
243
315
|
/**
|
|
@@ -531,16 +603,19 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
531
603
|
* invalidates it so a sync/upload is reflected immediately.
|
|
532
604
|
*/
|
|
533
605
|
const GIT_STATUS_TTL_MS = 2_000
|
|
534
|
-
/** Cached git-status PROMISE (not value): a burst of concurrent
|
|
535
|
-
* then shares ONE probe instead of each spawning up to five git
|
|
536
|
-
* (v0.19.0 — value-caching still let every concurrent miss run its
|
|
537
|
-
|
|
606
|
+
/** Cached git-status PROMISE **per wiki** (not value): a burst of concurrent
|
|
607
|
+
* /status polls then shares ONE probe instead of each spawning up to five git
|
|
608
|
+
* processes (v0.19.0 — value-caching still let every concurrent miss run its
|
|
609
|
+
* own). The key is the wiki folder: with several knowledge bases a
|
|
610
|
+
* single-slot cache would hand wiki A the answer probed for wiki B (v0.28.0). */
|
|
611
|
+
let gitStatusCache: { key: string; at: number; value: Promise<GitStatusViewPublic | null> } | undefined
|
|
538
612
|
const invalidateGitStatus = (): void => { gitStatusCache = undefined }
|
|
539
|
-
const cachedGitStatus = (): Promise<GitStatusViewPublic | null> => {
|
|
540
|
-
|
|
613
|
+
const cachedGitStatus = (req: IncomingMessage): Promise<GitStatusViewPublic | null> => {
|
|
614
|
+
const key = deps.getWikiPath(req)
|
|
615
|
+
if (gitStatusCache !== undefined && gitStatusCache.key === key && Date.now() - gitStatusCache.at < GIT_STATUS_TTL_MS) return gitStatusCache.value
|
|
541
616
|
const pending = (async (): Promise<GitStatusViewPublic | null> => {
|
|
542
617
|
try {
|
|
543
|
-
const view = await deps.git.status(
|
|
618
|
+
const view = await deps.git.status(key)
|
|
544
619
|
// The remote URL can carry a PAT (`https://user:token@…`) and /status is
|
|
545
620
|
// unauthenticated — never hand the credential to the browser.
|
|
546
621
|
return { ...view, remote: redactRemoteUrl(typeof view.remote === 'string' ? view.remote : '') }
|
|
@@ -548,7 +623,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
548
623
|
return null
|
|
549
624
|
}
|
|
550
625
|
})()
|
|
551
|
-
gitStatusCache = { at: Date.now(), value: pending }
|
|
626
|
+
gitStatusCache = { key, at: Date.now(), value: pending }
|
|
552
627
|
return pending
|
|
553
628
|
}
|
|
554
629
|
|
|
@@ -639,8 +714,8 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
639
714
|
|
|
640
715
|
const handleStatus = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
641
716
|
if (rejectNonRead(req, res)) return
|
|
642
|
-
const view = deps.server
|
|
643
|
-
const gitSummary = await cachedGitStatus()
|
|
717
|
+
const view = deps.server(req)?.status() ?? { status: 'stopped' as const, wikiPath: deps.getWikiPath(req), logs: [] }
|
|
718
|
+
const gitSummary = await cachedGitStatus(req)
|
|
644
719
|
json(res, {
|
|
645
720
|
ok: true,
|
|
646
721
|
...view,
|
|
@@ -653,8 +728,16 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
653
728
|
// twProxyAbsoluteBase(). Absent when the Host header is unusable.
|
|
654
729
|
twProxyAbsolute: twProxyAbsoluteBase(req),
|
|
655
730
|
git: gitSummary,
|
|
656
|
-
note: { tag: deps.noteDefaults().tag },
|
|
657
|
-
ui: deps.uiDefaults(),
|
|
731
|
+
note: { tag: deps.noteDefaults(req).tag },
|
|
732
|
+
ui: deps.uiDefaults(req),
|
|
733
|
+
// The knowledge-base roster (v0.28.0): the GUI's per-wiki selector and the
|
|
734
|
+
// settings page read it from here. Deliberately WITHOUT per-wiki git
|
|
735
|
+
// status — that would spawn up to five git processes per wiki on every
|
|
736
|
+
// 30s poll; `/status?wiki=<id>` carries git for the one you are looking at.
|
|
737
|
+
...(() => {
|
|
738
|
+
const farm = deps.wikiSummaries(req)
|
|
739
|
+
return { mode: farm.mode, defaultId: farm.defaultId, wikis: farm.items }
|
|
740
|
+
})(),
|
|
658
741
|
})
|
|
659
742
|
}
|
|
660
743
|
|
|
@@ -670,6 +753,80 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
670
753
|
const SUMMARY_REUSE_MS = 3_000
|
|
671
754
|
const summaryInFlight = new Map<string, { at: number; value: Promise<SessionSummaryResult> }>()
|
|
672
755
|
|
|
756
|
+
/**
|
|
757
|
+
* GET /dsh-tiddlywiki/session/wiki?session=<id> — which knowledge base this
|
|
758
|
+
* conversation works on, plus what it resolves to right now.
|
|
759
|
+
*
|
|
760
|
+
* The per-wiki selector in the composer reads this; the agent tools and the
|
|
761
|
+
* injected prompt are driven by the SAME value, so what the user picks is what
|
|
762
|
+
* the model gets.
|
|
763
|
+
*/
|
|
764
|
+
const handleSessionWiki = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
765
|
+
try {
|
|
766
|
+
if (rejectNonRead(req, res)) return
|
|
767
|
+
if (deps.sessionScope === undefined) {
|
|
768
|
+
json(res, { ok: false, error: '会话作用域不可用(宿主未接线)' }, 503)
|
|
769
|
+
return
|
|
770
|
+
}
|
|
771
|
+
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
772
|
+
const session = (url.searchParams.get('session') ?? '').trim()
|
|
773
|
+
if (session.length === 0) {
|
|
774
|
+
json(res, { ok: false, error: 'session is required' }, 400)
|
|
775
|
+
return
|
|
776
|
+
}
|
|
777
|
+
json(res, { ok: true, session, ...deps.sessionScope.get(session) })
|
|
778
|
+
} catch (err) {
|
|
779
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* POST /dsh-tiddlywiki/session/wiki { session, wiki } — point this conversation
|
|
785
|
+
* at a knowledge base (`wiki: null`/`''` clears the choice and falls back to
|
|
786
|
+
* the default).
|
|
787
|
+
*
|
|
788
|
+
* A HIDDEN wiki is refused (400): `agentVisible: false` means "the agent never
|
|
789
|
+
* reaches this one", and a selector that lets a session pick it would quietly
|
|
790
|
+
* contradict the setting the user just made.
|
|
791
|
+
*/
|
|
792
|
+
const handleSessionWikiSet = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
793
|
+
try {
|
|
794
|
+
if (deps.sessionScope === undefined) {
|
|
795
|
+
json(res, { ok: false, error: '会话作用域不可用(宿主未接线)' }, 503)
|
|
796
|
+
return
|
|
797
|
+
}
|
|
798
|
+
let body: { session?: unknown; wiki?: unknown } = {}
|
|
799
|
+
try {
|
|
800
|
+
body = JSON.parse(await readBody(req)) as { session?: unknown; wiki?: unknown }
|
|
801
|
+
} catch {
|
|
802
|
+
json(res, { ok: false, error: '请求体必须是 JSON' }, 400)
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
const session = typeof body.session === 'string' ? body.session.trim() : ''
|
|
806
|
+
if (session.length === 0) {
|
|
807
|
+
json(res, { ok: false, error: 'session is required' }, 400)
|
|
808
|
+
return
|
|
809
|
+
}
|
|
810
|
+
const raw = typeof body.wiki === 'string' ? body.wiki.trim() : ''
|
|
811
|
+
const wikiId = raw.length === 0 ? undefined : raw
|
|
812
|
+
await deps.sessionScope.set(session, wikiId)
|
|
813
|
+
json(res, { ok: true, session, ...deps.sessionScope.get(session) })
|
|
814
|
+
} catch (err) {
|
|
815
|
+
// A refusal from the host (unknown / hidden wiki, bad session id) is the
|
|
816
|
+
// caller's problem: 400 + the reason, so the picker can say what happened.
|
|
817
|
+
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, 400)
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** `/session/wiki` takes both methods on ONE registration (see /admin/wikis). */
|
|
822
|
+
const handleSessionWikiRoute = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
823
|
+
if ((req.method ?? 'GET').toUpperCase() === 'POST') {
|
|
824
|
+
if (rejectCrossSiteWrite(req, res, ['POST'])) return
|
|
825
|
+
return handleSessionWikiSet(req, res)
|
|
826
|
+
}
|
|
827
|
+
return handleSessionWiki(req, res)
|
|
828
|
+
}
|
|
829
|
+
|
|
673
830
|
/**
|
|
674
831
|
* POST /dsh-tiddlywiki/session/summary — 生成当前会话的 wiki 汇总页(「知识库」
|
|
675
832
|
* Tab 的后端)。body `{ session: <会话ID> }`;后端用 sessionQuery 读本会话(含
|
|
@@ -698,7 +855,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
698
855
|
json(res, { ok: false, error: 'session id has an unsupported format' }, 400)
|
|
699
856
|
return
|
|
700
857
|
}
|
|
701
|
-
const client = deps.getClient()
|
|
858
|
+
const client = deps.getClient(req)
|
|
702
859
|
if (client === undefined) {
|
|
703
860
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
704
861
|
return
|
|
@@ -783,11 +940,11 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
783
940
|
* error response — the caller just does `if (!guard(...)) return`.
|
|
784
941
|
*/
|
|
785
942
|
const guardSendToAgent = (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
786
|
-
if (!deps.sendToAgentEnabled()) {
|
|
943
|
+
if (!deps.sendToAgentEnabled(req)) {
|
|
787
944
|
json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
|
|
788
945
|
return false
|
|
789
946
|
}
|
|
790
|
-
const token = deps.sendToAgentToken().trim()
|
|
947
|
+
const token = deps.sendToAgentToken(req).trim()
|
|
791
948
|
if (token.length === 0) return true
|
|
792
949
|
const got = req.headers['x-send-to-agent-token']
|
|
793
950
|
const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
|
|
@@ -1023,7 +1180,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1023
1180
|
json(res, { ok: false, error: 'text is required' }, 400)
|
|
1024
1181
|
return
|
|
1025
1182
|
}
|
|
1026
|
-
const client = deps.getClient()
|
|
1183
|
+
const client = deps.getClient(req)
|
|
1027
1184
|
if (client === undefined) {
|
|
1028
1185
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
1029
1186
|
return
|
|
@@ -1046,11 +1203,11 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1046
1203
|
const { tiddler } = buildWriteTiddler(title, text, {
|
|
1047
1204
|
existing,
|
|
1048
1205
|
tags,
|
|
1049
|
-
defaultTags: [deps.noteDefaults().tag],
|
|
1206
|
+
defaultTags: [deps.noteDefaults(req).tag],
|
|
1050
1207
|
agentTag: false,
|
|
1051
1208
|
})
|
|
1052
1209
|
await client.put(tiddler)
|
|
1053
|
-
deps.autoCommit()
|
|
1210
|
+
deps.autoCommit(req)
|
|
1054
1211
|
invalidateGitStatus()
|
|
1055
1212
|
const finalTags = tiddler.tags ?? []
|
|
1056
1213
|
json(res, { ok: true, title, tag: finalTags.join(' '), tags: finalTags, text, type: typeof tiddler.type === 'string' ? tiddler.type : NOTE_TYPE })
|
|
@@ -1067,7 +1224,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1067
1224
|
try {
|
|
1068
1225
|
if (rejectCrossSiteWrite(req, res, ['POST'])) return
|
|
1069
1226
|
const body = JSON.parse(await readBody(req)) as { title?: unknown; tag?: unknown; tags?: unknown; text?: unknown; expectedModified?: unknown; expectedRevision?: unknown; force?: unknown }
|
|
1070
|
-
const client = deps.getClient()
|
|
1227
|
+
const client = deps.getClient(req)
|
|
1071
1228
|
if (client === undefined) {
|
|
1072
1229
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
1073
1230
|
return
|
|
@@ -1079,10 +1236,10 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1079
1236
|
}
|
|
1080
1237
|
const text = typeof body.text === 'string' ? body.text : ''
|
|
1081
1238
|
const result = await openInTwEditor(client, title, text, resolveTags(body), {
|
|
1082
|
-
defaultTags: [deps.noteDefaults().tag],
|
|
1239
|
+
defaultTags: [deps.noteDefaults(req).tag],
|
|
1083
1240
|
...conflictTokens(body),
|
|
1084
1241
|
})
|
|
1085
|
-
deps.autoCommit()
|
|
1242
|
+
deps.autoCommit(req)
|
|
1086
1243
|
invalidateGitStatus()
|
|
1087
1244
|
json(res, { ok: true, ...result, twUrl: TW_PROXY_PATH, twUrlAbsolute: twProxyAbsoluteBase(req) })
|
|
1088
1245
|
} catch (err) {
|
|
@@ -1105,7 +1262,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1105
1262
|
* order, plus `total`/`truncated` so a capped caller can say "N of M". */
|
|
1106
1263
|
const handleTags = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
1107
1264
|
if (rejectNonRead(req, res)) return
|
|
1108
|
-
const client = deps.getClient()
|
|
1265
|
+
const client = deps.getClient(req)
|
|
1109
1266
|
if (client === undefined) {
|
|
1110
1267
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
1111
1268
|
return
|
|
@@ -1134,7 +1291,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1134
1291
|
/** Recent non-system tiddlers for the quick-note "最近" picker (newest first). */
|
|
1135
1292
|
const handleRecent = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
1136
1293
|
if (rejectNonRead(req, res)) return
|
|
1137
|
-
const client = deps.getClient()
|
|
1294
|
+
const client = deps.getClient(req)
|
|
1138
1295
|
if (client === undefined) {
|
|
1139
1296
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
1140
1297
|
return
|
|
@@ -1167,7 +1324,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1167
1324
|
/** Full tiddler for the quick-note "最近" picker (load into the editor). */
|
|
1168
1325
|
const handleGet = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
1169
1326
|
if (rejectNonRead(req, res)) return
|
|
1170
|
-
const client = deps.getClient()
|
|
1327
|
+
const client = deps.getClient(req)
|
|
1171
1328
|
if (client === undefined) {
|
|
1172
1329
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
1173
1330
|
return
|
|
@@ -1228,7 +1385,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1228
1385
|
*/
|
|
1229
1386
|
const handleSearch = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
1230
1387
|
if (rejectNonRead(req, res)) return
|
|
1231
|
-
const client = deps.getClient()
|
|
1388
|
+
const client = deps.getClient(req)
|
|
1232
1389
|
if (client === undefined) {
|
|
1233
1390
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
1234
1391
|
return
|
|
@@ -1302,15 +1459,15 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1302
1459
|
// This route used to SIGKILL the child directly, so clicking「重启 TW」
|
|
1303
1460
|
// within ~1s of any write silently lost that write.
|
|
1304
1461
|
drained = await drainThenStop({
|
|
1305
|
-
client: deps.getClient(),
|
|
1306
|
-
tiddlersDir: join(deps.getWikiPath(), 'tiddlers'),
|
|
1307
|
-
stop: () => deps.server
|
|
1462
|
+
client: deps.getClient(req),
|
|
1463
|
+
tiddlersDir: join(deps.getWikiPath(req), 'tiddlers'),
|
|
1464
|
+
stop: async () => { await deps.server(req)?.restart() },
|
|
1308
1465
|
log: (message) => console.warn('[dsh-tiddlywiki]', message),
|
|
1309
1466
|
})
|
|
1310
1467
|
} finally {
|
|
1311
1468
|
endMutation()
|
|
1312
1469
|
}
|
|
1313
|
-
json(res, { ok: true, status: deps.server
|
|
1470
|
+
json(res, { ok: true, status: deps.server(req)?.status().status, drained })
|
|
1314
1471
|
} catch (err) {
|
|
1315
1472
|
json(res, { ok: false, error: err instanceof Error ? err.message : String(err) }, errorStatus(err))
|
|
1316
1473
|
}
|
|
@@ -1329,7 +1486,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1329
1486
|
return
|
|
1330
1487
|
}
|
|
1331
1488
|
invalidateGitStatus()
|
|
1332
|
-
const dir = deps.getWikiPath()
|
|
1489
|
+
const dir = deps.getWikiPath(req)
|
|
1333
1490
|
const status = async (): Promise<GitStatusViewPublic | null> => {
|
|
1334
1491
|
try { return await deps.git.status(dir) } catch { return null }
|
|
1335
1492
|
}
|
|
@@ -1346,24 +1503,19 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1346
1503
|
return
|
|
1347
1504
|
}
|
|
1348
1505
|
let restarted = false
|
|
1506
|
+
let restartedWikis: string[] | undefined
|
|
1349
1507
|
let restartError: string | undefined
|
|
1350
|
-
if (pulled.changed === true) {
|
|
1508
|
+
if (pulled.changed === true && deps.restartAffected !== undefined) {
|
|
1351
1509
|
try {
|
|
1352
|
-
//
|
|
1353
|
-
//
|
|
1354
|
-
// Restarting
|
|
1355
|
-
//
|
|
1356
|
-
//
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
tiddlersDir: join(dir, 'tiddlers'),
|
|
1362
|
-
stop: () => deps.server.restart(),
|
|
1363
|
-
log: (message) => console.warn('[dsh-tiddlywiki]', message),
|
|
1364
|
-
})
|
|
1365
|
-
if (!drained) console.warn('[dsh-tiddlywiki] sync: syncer queue may not be drained before restart')
|
|
1366
|
-
restarted = true
|
|
1510
|
+
// v0.28.0: restart ONLY the wikis whose content changed, and let the
|
|
1511
|
+
// runtime drain the syncer queue first (ironclad rule #1 lives inside
|
|
1512
|
+
// `WikiInstance.restart()`). Restarting "the wiki this request came
|
|
1513
|
+
// from" would miss the one that actually changed when several share a
|
|
1514
|
+
// repository — and would interrupt a wiki nothing happened to.
|
|
1515
|
+
const outcome = await deps.restartAffected(req, dir, pulled.changedFiles ?? [])
|
|
1516
|
+
restarted = outcome.restarted.length > 0
|
|
1517
|
+
if (outcome.restarted.length > 0) restartedWikis = outcome.restarted
|
|
1518
|
+
if (outcome.failed.length > 0) restartError = outcome.failed.map((item) => `${item.id}: ${item.message}`).join('; ')
|
|
1367
1519
|
} catch (err) {
|
|
1368
1520
|
restartError = err instanceof Error ? err.message : String(err)
|
|
1369
1521
|
}
|
|
@@ -1397,6 +1549,9 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1397
1549
|
pull: 'ok',
|
|
1398
1550
|
...(pulled.changed === true ? { changed: true } : {}),
|
|
1399
1551
|
restarted,
|
|
1552
|
+
// Which knowledge bases were restarted (v0.28.0). `restarted` stays a
|
|
1553
|
+
// BOOLEAN for the existing client, which only shows a sentence.
|
|
1554
|
+
...(restartedWikis !== undefined ? { restartedWikis } : {}),
|
|
1400
1555
|
...(restartError !== undefined ? { restartError } : {}),
|
|
1401
1556
|
commit: committed.message,
|
|
1402
1557
|
push: pushed.message,
|
|
@@ -1441,7 +1596,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1441
1596
|
json(res, { ok: false, error: `不允许上传可执行/可脚本化的文件类型:${extension}` }, 400)
|
|
1442
1597
|
return
|
|
1443
1598
|
}
|
|
1444
|
-
const filesDir = join(deps.getWikiPath(), 'files')
|
|
1599
|
+
const filesDir = join(deps.getWikiPath(req), 'files')
|
|
1445
1600
|
await mkdir(filesDir, { recursive: true })
|
|
1446
1601
|
// Collision avoidance with an ATOMIC create (v0.19.3): the old code did
|
|
1447
1602
|
// `access(candidate)` then `writeFile(candidate)`, so (a) any access error
|
|
@@ -1468,7 +1623,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1468
1623
|
json(res, { ok: false, error: '同名文件过多,请换一个文件名' }, 409)
|
|
1469
1624
|
return
|
|
1470
1625
|
}
|
|
1471
|
-
deps.autoCommit()
|
|
1626
|
+
deps.autoCommit(req)
|
|
1472
1627
|
invalidateGitStatus()
|
|
1473
1628
|
json(res, {
|
|
1474
1629
|
ok: true,
|
|
@@ -1507,7 +1662,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1507
1662
|
const handleRender = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
1508
1663
|
try {
|
|
1509
1664
|
if (rejectCrossSiteWrite(req, res, ['POST'])) return
|
|
1510
|
-
const client = deps.getClient()
|
|
1665
|
+
const client = deps.getClient(req)
|
|
1511
1666
|
if (client === undefined) {
|
|
1512
1667
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
1513
1668
|
return
|
|
@@ -1586,8 +1741,10 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1586
1741
|
// forwarded to the TW child. The set is the TiddlyWeb API surface the TW
|
|
1587
1742
|
// frontend uses.
|
|
1588
1743
|
if (rejectCrossSiteWrite(req, res, ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS'])) return
|
|
1589
|
-
|
|
1590
|
-
|
|
1744
|
+
// The upstream base comes straight from the child (v0.28.0: per request, so
|
|
1745
|
+
// a named wiki can be proxied); the client is only a liveness proxy.
|
|
1746
|
+
const server = deps.server(req)
|
|
1747
|
+
if (server?.url === undefined) {
|
|
1591
1748
|
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
1592
1749
|
return
|
|
1593
1750
|
}
|
|
@@ -1607,7 +1764,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1607
1764
|
if (method === 'PUT' || method === 'DELETE' || method === 'POST') headers['x-requested-with'] = 'TiddlyWiki'
|
|
1608
1765
|
const init: RequestInit = { method, headers, signal: AbortSignal.timeout(15_000) }
|
|
1609
1766
|
if (method === 'PUT' || method === 'POST') init.body = await readBody(req, MAX_PROXY_BODY_BYTES)
|
|
1610
|
-
const upstream = await fetch(`${
|
|
1767
|
+
const upstream = await fetch(`${server.url}${rest}${url.search}`, init)
|
|
1611
1768
|
const data = await upstream.text()
|
|
1612
1769
|
res.writeHead(upstream.status, {
|
|
1613
1770
|
'content-type': upstream.headers.get('content-type') ?? 'application/json; charset=utf-8',
|
|
@@ -1633,17 +1790,31 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1633
1790
|
// pathname only, so without it TRACE or a typo'd verb was forwarded straight
|
|
1634
1791
|
// to the TW child. Same set the sibling /api proxy declares.
|
|
1635
1792
|
if (rejectCrossSiteWrite(req, res, ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS'])) return
|
|
1636
|
-
const client = deps.getClient()
|
|
1637
|
-
if (client === undefined) {
|
|
1638
|
-
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
1639
|
-
return
|
|
1640
|
-
}
|
|
1641
1793
|
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
|
1642
|
-
|
|
1643
|
-
|
|
1794
|
+
// PER-WIKI FORM (v0.28.0): `/tw/<id>/<tw-path>`. The first segment is only
|
|
1795
|
+
// consumed when it names a REGISTERED wiki, so TW's own `/tw/status` and
|
|
1796
|
+
// `/tw/files/…` keep meaning "the default wiki's TW path" — that is exactly
|
|
1797
|
+
// what the reserved-id rule guarantees (a wiki id may never equal a TW root
|
|
1798
|
+
// segment; see RESERVED_WIKI_IDS in host/wiki-registry.ts).
|
|
1799
|
+
const afterPrefix = url.pathname.slice(TW_PROXY_PREFIX.length).replace(/^\//, '')
|
|
1800
|
+
const [firstSegment = '', ...tail] = afterPrefix.split('/')
|
|
1801
|
+
const named = deps.wikiIds().includes(firstSegment) ? firstSegment : undefined
|
|
1802
|
+
const rest = named === undefined
|
|
1803
|
+
? (url.pathname.replace(new RegExp(`^${TW_PROXY_PREFIX}(?=/|$)`), '') || '/')
|
|
1804
|
+
: `/${tail.join('/')}`
|
|
1805
|
+
// The guard runs on BOTH forms: the raw path is what a crafted request
|
|
1806
|
+
// controls, and the stripped `rest` is what actually reaches TW.
|
|
1807
|
+
if (isBlockedProxyPath(url.pathname) || isBlockedProxyPath(rest)) {
|
|
1644
1808
|
json(res, { ok: false, error: 'system tiddler not exposed' }, 403)
|
|
1645
1809
|
return
|
|
1646
1810
|
}
|
|
1811
|
+
// Resolve the child LAST, so a named-but-stopped wiki answers 503 instead of
|
|
1812
|
+
// being proxied to whoever happens to be the default.
|
|
1813
|
+
const server = named === undefined ? deps.server(req) : deps.serverById(named)
|
|
1814
|
+
if (server?.url === undefined) {
|
|
1815
|
+
json(res, { ok: false, error: 'wiki service is not running' }, 503)
|
|
1816
|
+
return
|
|
1817
|
+
}
|
|
1647
1818
|
try {
|
|
1648
1819
|
const method = (req.method ?? 'GET').toUpperCase()
|
|
1649
1820
|
const headers = forwardHeaders(req.headers)
|
|
@@ -1663,7 +1834,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1663
1834
|
// frontend does not send DELETE bodies, but the proxy is a generic
|
|
1664
1835
|
// passthrough — drain whatever is there.
|
|
1665
1836
|
else if (req.readableEnded === false && (req.headers['content-length'] !== undefined || req.headers['transfer-encoding'] !== undefined)) req.resume()
|
|
1666
|
-
const upstream = await fetch(`${
|
|
1837
|
+
const upstream = await fetch(`${server.url}${rest}${url.search}`, init)
|
|
1667
1838
|
const responseHeaders: Record<string, string> = {
|
|
1668
1839
|
'content-type': upstream.headers.get('content-type') ?? 'application/octet-stream',
|
|
1669
1840
|
'cache-control': upstream.headers.get('cache-control') ?? 'no-store',
|
|
@@ -1717,7 +1888,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1717
1888
|
* deployment answers 403 instead of starting browser automation.
|
|
1718
1889
|
*/
|
|
1719
1890
|
const guardWechat = (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
1720
|
-
const config = deps.wechatConfig()
|
|
1891
|
+
const config = deps.wechatConfig(req)
|
|
1721
1892
|
if (!config.enabled) {
|
|
1722
1893
|
json(res, { ok: false, error: 'wechat publishing is disabled' }, 403)
|
|
1723
1894
|
return false
|
|
@@ -1741,7 +1912,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1741
1912
|
try {
|
|
1742
1913
|
if (rejectNonRead(req, res)) return
|
|
1743
1914
|
if (!guardWechat(req, res)) return
|
|
1744
|
-
const ready = await deps.wechatReady()
|
|
1915
|
+
const ready = await deps.wechatReady(req)
|
|
1745
1916
|
json(res, {
|
|
1746
1917
|
ok: true,
|
|
1747
1918
|
enabled: ready.enabled,
|
|
@@ -1761,7 +1932,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1761
1932
|
* overrides it for exotic setups.
|
|
1762
1933
|
*/
|
|
1763
1934
|
const wechatDsn = (req: IncomingMessage): string => {
|
|
1764
|
-
const configured = deps.wechatConfig().dsn
|
|
1935
|
+
const configured = deps.wechatConfig(req).dsn
|
|
1765
1936
|
if (configured.length > 0) return configured
|
|
1766
1937
|
const port = req.socket.localPort
|
|
1767
1938
|
return port === undefined ? '' : `http://127.0.0.1:${port}${ROUTE_PREFIX}`
|
|
@@ -1812,7 +1983,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1812
1983
|
}
|
|
1813
1984
|
// Fail fast on a title that does not exist: the adapter would otherwise
|
|
1814
1985
|
// spend ~10s booting the browser only to 404 in /render.
|
|
1815
|
-
const client = deps.getClient()
|
|
1986
|
+
const client = deps.getClient(req)
|
|
1816
1987
|
if (client !== undefined) {
|
|
1817
1988
|
const tiddler = await client.get(title)
|
|
1818
1989
|
if (tiddler === undefined) {
|
|
@@ -1820,8 +1991,8 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1820
1991
|
return
|
|
1821
1992
|
}
|
|
1822
1993
|
}
|
|
1823
|
-
const adapter = (requested ?? deps.wechatConfig().adapter) as WechatAdapter
|
|
1824
|
-
const ready = await deps.wechatReady()
|
|
1994
|
+
const adapter = (requested ?? deps.wechatConfig(req).adapter) as WechatAdapter
|
|
1995
|
+
const ready = await deps.wechatReady(req)
|
|
1825
1996
|
const adapterReady = adapter === 'publish-note-imgs' ? ready.adapters.publishNoteImgs : ready.adapters.publishNote
|
|
1826
1997
|
if (!ready.opencli.ok || !adapterReady) {
|
|
1827
1998
|
// `stale` (v0.23.4) = installed but outdated adapter files: the common
|
|
@@ -1903,6 +2074,8 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
1903
2074
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/upload`, handler: guardHandler(handleUpload) }),
|
|
1904
2075
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/restart`, handler: guardHandler(handleRestart) }),
|
|
1905
2076
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/session/summary`, handler: guardHandler(handleSessionSummary) }),
|
|
2077
|
+
// One path, both methods (the host webserver dispatches by pathname only).
|
|
2078
|
+
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/session/wiki`, handler: guardHandler(handleSessionWikiRoute) }),
|
|
1906
2079
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/sessions`, handler: guardHandler(handleAgentSessions) }),
|
|
1907
2080
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/modes`, handler: guardHandler(handleAgentModes) }),
|
|
1908
2081
|
ctx.webServer.register({ kind: 'exact', path: `${ROUTE_PREFIX}/agent/send`, handler: guardHandler(handleAgentSend) }),
|