dsh-tiddlywiki 0.16.11 → 0.16.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/lib/client.bundle.js +26 -84
- package/lib/client.js +26 -84
- package/lib/index.js +300 -477
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/editor-popup.ts +21 -12
- package/src/client/endpoints.ts +10 -0
- package/src/client/index.ts +0 -4
- package/src/client/knowledge-fab.ts +14 -8
- package/src/client/note-widget.ts +28 -9
- package/src/client/panel.ts +11 -6
- package/src/client/quick-note-dock.ts +1 -4
- package/src/client/session-summary.ts +29 -1
- package/src/client/settings-page.ts +7 -2
- package/src/client/sidebar-entry.ts +2 -1
- package/src/client/styles.ts +0 -58
- package/src/client/sync-button.ts +5 -3
- package/src/client/ui-config.ts +1 -1
- package/src/host/admin.ts +2 -24
- package/src/host/http.ts +57 -0
- package/src/host/routes.ts +25 -92
- package/src/host/seeds.ts +114 -167
- package/src/host/session-summary.ts +35 -15
- package/src/host/tools.ts +41 -33
- package/src/index.ts +20 -6
package/src/host/http.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared node:http helpers for the DSH webserver routes (routes.ts, admin.ts).
|
|
3
|
+
*
|
|
4
|
+
* One implementation of body reading + JSON response writing so the route
|
|
5
|
+
* layers cannot drift (they previously carried two/three copies).
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-tiddlywiki/host/http
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
10
|
+
|
|
11
|
+
/** Default cap for small JSON bodies (note/restart/config…). */
|
|
12
|
+
export const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024
|
|
13
|
+
|
|
14
|
+
/** Cap for the /api passthrough body (tiddler content can be large). */
|
|
15
|
+
export const MAX_PROXY_BODY_BYTES = 16 * 1024 * 1024
|
|
16
|
+
|
|
17
|
+
/** Cap for uploaded file bodies (/tw proxy, /upload). */
|
|
18
|
+
export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
function readBodyStream(req: IncomingMessage, limit: number, encoding: true): Promise<string>
|
|
21
|
+
function readBodyStream(req: IncomingMessage, limit: number, encoding: false): Promise<Buffer>
|
|
22
|
+
function readBodyStream(req: IncomingMessage, limit: number, encoding: boolean): Promise<string | Buffer> {
|
|
23
|
+
return new Promise((resolveP, rejectP) => {
|
|
24
|
+
let size = 0
|
|
25
|
+
const chunks: Buffer[] = []
|
|
26
|
+
req.on('data', (chunk: Buffer) => {
|
|
27
|
+
size += chunk.length
|
|
28
|
+
if (size > limit) {
|
|
29
|
+
rejectP(new Error('body too large'))
|
|
30
|
+
req.destroy()
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
chunks.push(chunk)
|
|
34
|
+
})
|
|
35
|
+
req.on('end', () => {
|
|
36
|
+
const buf = Buffer.concat(chunks)
|
|
37
|
+
resolveP(encoding ? buf.toString('utf8') : buf)
|
|
38
|
+
})
|
|
39
|
+
req.on('error', rejectP)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Read a JSON (utf8) request body up to `limit` bytes (default MAX_JSON_BODY_BYTES). */
|
|
44
|
+
export function readBody(req: IncomingMessage, limit = MAX_JSON_BODY_BYTES): Promise<string> {
|
|
45
|
+
return readBodyStream(req, limit, true)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read a raw (binary-safe) request body up to `limit` bytes (default MAX_UPLOAD_BYTES). */
|
|
49
|
+
export function readBodyBuffer(req: IncomingMessage, limit = MAX_UPLOAD_BYTES): Promise<Buffer> {
|
|
50
|
+
return readBodyStream(req, limit, false)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Write a JSON response (no-store, utf-8). */
|
|
54
|
+
export function json(res: ServerResponse, payload: unknown, status = 200): void {
|
|
55
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
56
|
+
res.end(JSON.stringify(payload))
|
|
57
|
+
}
|
package/src/host/routes.ts
CHANGED
|
@@ -27,21 +27,13 @@ import type { WikiServer } from './wiki.ts'
|
|
|
27
27
|
import type { GitFace } from './git.ts'
|
|
28
28
|
import { PATH_PREFIX, TW_PROXY_PREFIX, TW_PROXY_PATH } from './wiki.ts'
|
|
29
29
|
import { writeSessionSummary, type SessionQueryFace } from './session-summary.ts'
|
|
30
|
+
import { readBody, readBodyBuffer, json, MAX_PROXY_BODY_BYTES, MAX_UPLOAD_BYTES } from './http.ts'
|
|
30
31
|
|
|
31
32
|
export { writeSessionSummary, SESSION_SUMMARY_PREFIX } from './session-summary.ts'
|
|
32
33
|
export type { SessionQueryFace, SessionSummaryResult } from './session-summary.ts'
|
|
33
34
|
|
|
34
35
|
export const ROUTE_PREFIX = PATH_PREFIX
|
|
35
36
|
|
|
36
|
-
/** Max JSON body for note/restart. */
|
|
37
|
-
const MAX_BODY_BYTES = 2 * 1024 * 1024
|
|
38
|
-
|
|
39
|
-
/** Max passthrough body (tiddler content can be large). */
|
|
40
|
-
const MAX_PROXY_BODY_BYTES = 16 * 1024 * 1024
|
|
41
|
-
|
|
42
|
-
/** Max uploaded file body. */
|
|
43
|
-
const MAX_UPLOAD_BYTES = 64 * 1024 * 1024
|
|
44
|
-
|
|
45
37
|
/** Tiddler type for quick-notes: Markdown, so the uploaded images/links and
|
|
46
38
|
* any Markdown in the note actually render in TW (a type-less tiddler is
|
|
47
39
|
* treated as plain wiki text and shows raw `![..]`/`[..]` instead). */
|
|
@@ -196,43 +188,6 @@ export interface RouteDeps {
|
|
|
196
188
|
sendToAgentToken: () => string
|
|
197
189
|
}
|
|
198
190
|
|
|
199
|
-
function readBody(req: IncomingMessage, limit = MAX_BODY_BYTES): Promise<string> {
|
|
200
|
-
return new Promise((resolveP, rejectP) => {
|
|
201
|
-
let size = 0
|
|
202
|
-
const chunks: Buffer[] = []
|
|
203
|
-
req.on('data', (chunk: Buffer) => {
|
|
204
|
-
size += chunk.length
|
|
205
|
-
if (size > limit) {
|
|
206
|
-
rejectP(new Error('body too large'))
|
|
207
|
-
req.destroy()
|
|
208
|
-
return
|
|
209
|
-
}
|
|
210
|
-
chunks.push(chunk)
|
|
211
|
-
})
|
|
212
|
-
req.on('end', () => resolveP(Buffer.concat(chunks).toString('utf8')))
|
|
213
|
-
req.on('error', rejectP)
|
|
214
|
-
})
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** Read a raw (binary-safe) request body up to `limit` bytes. */
|
|
218
|
-
function readBodyBuffer(req: IncomingMessage, limit = MAX_UPLOAD_BYTES): Promise<Buffer> {
|
|
219
|
-
return new Promise((resolveP, rejectP) => {
|
|
220
|
-
let size = 0
|
|
221
|
-
const chunks: Buffer[] = []
|
|
222
|
-
req.on('data', (chunk: Buffer) => {
|
|
223
|
-
size += chunk.length
|
|
224
|
-
if (size > limit) {
|
|
225
|
-
rejectP(new Error('file too large'))
|
|
226
|
-
req.destroy()
|
|
227
|
-
return
|
|
228
|
-
}
|
|
229
|
-
chunks.push(chunk)
|
|
230
|
-
})
|
|
231
|
-
req.on('end', () => resolveP(Buffer.concat(chunks)))
|
|
232
|
-
req.on('error', rejectP)
|
|
233
|
-
})
|
|
234
|
-
}
|
|
235
|
-
|
|
236
191
|
/** Header names forwarded to the upstream TW service by the proxy routes. */
|
|
237
192
|
const FORWARD_HEADER_NAMES = [
|
|
238
193
|
'accept', 'accept-encoding', 'content-type', 'cookie', 'authorization',
|
|
@@ -269,12 +224,6 @@ function sanitizeUploadName(input: unknown): string {
|
|
|
269
224
|
return name
|
|
270
225
|
}
|
|
271
226
|
|
|
272
|
-
function json(res: ServerResponse, payload: unknown, status = 200): void {
|
|
273
|
-
const body = JSON.stringify(payload)
|
|
274
|
-
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
275
|
-
res.end(body)
|
|
276
|
-
}
|
|
277
|
-
|
|
278
227
|
function pad(n: number): string {
|
|
279
228
|
return n < 10 ? `0${n}` : String(n)
|
|
280
229
|
}
|
|
@@ -443,6 +392,26 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
443
392
|
}
|
|
444
393
|
}
|
|
445
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Shared gate for the TW-side send-to-agent routes: feature switch
|
|
397
|
+
* (`ui.sendToAgent.enabled`) then, when a shared token is configured, the
|
|
398
|
+
* `x-send-to-agent-token` header must match. Returns false after writing the
|
|
399
|
+
* error response — the caller just does `if (!guard(...)) return`.
|
|
400
|
+
*/
|
|
401
|
+
const guardSendToAgent = (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
402
|
+
if (!deps.sendToAgentEnabled()) {
|
|
403
|
+
json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
|
|
404
|
+
return false
|
|
405
|
+
}
|
|
406
|
+
const token = deps.sendToAgentToken().trim()
|
|
407
|
+
if (token.length === 0) return true
|
|
408
|
+
const got = req.headers['x-send-to-agent-token']
|
|
409
|
+
const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
|
|
410
|
+
if (value === token) return true
|
|
411
|
+
json(res, { ok: false, error: 'unauthorized' }, 401)
|
|
412
|
+
return false
|
|
413
|
+
}
|
|
414
|
+
|
|
446
415
|
/**
|
|
447
416
|
* GET /dsh-tiddlywiki/agent/modes — available "工作模式" (Agent presets) for
|
|
448
417
|
* the TW picker: id/name/description per preset plus the deployment default.
|
|
@@ -450,19 +419,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
450
419
|
*/
|
|
451
420
|
const handleAgentModes = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
452
421
|
try {
|
|
453
|
-
if (!
|
|
454
|
-
json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
|
|
455
|
-
return
|
|
456
|
-
}
|
|
457
|
-
const token = deps.sendToAgentToken().trim()
|
|
458
|
-
if (token.length > 0) {
|
|
459
|
-
const got = req.headers['x-send-to-agent-token']
|
|
460
|
-
const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
|
|
461
|
-
if (value !== token) {
|
|
462
|
-
json(res, { ok: false, error: 'unauthorized' }, 401)
|
|
463
|
-
return
|
|
464
|
-
}
|
|
465
|
-
}
|
|
422
|
+
if (!guardSendToAgent(req, res)) return
|
|
466
423
|
const ap = deps.getAgentPresets()
|
|
467
424
|
if (ap === undefined) {
|
|
468
425
|
json(res, { ok: false, error: 'agent presets service unavailable' }, 503)
|
|
@@ -516,19 +473,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
516
473
|
*/
|
|
517
474
|
const handleAgentSend = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
518
475
|
try {
|
|
519
|
-
if (!
|
|
520
|
-
json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
|
|
521
|
-
return
|
|
522
|
-
}
|
|
523
|
-
const token = deps.sendToAgentToken().trim()
|
|
524
|
-
if (token.length > 0) {
|
|
525
|
-
const got = req.headers['x-send-to-agent-token']
|
|
526
|
-
const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
|
|
527
|
-
if (value !== token) {
|
|
528
|
-
json(res, { ok: false, error: 'unauthorized' }, 401)
|
|
529
|
-
return
|
|
530
|
-
}
|
|
531
|
-
}
|
|
476
|
+
if (!guardSendToAgent(req, res)) return
|
|
532
477
|
const body = JSON.parse(await readBody(req)) as { sessionId?: unknown; text?: unknown }
|
|
533
478
|
const sessionId = typeof body.sessionId === 'string' && body.sessionId.trim().length > 0 ? body.sessionId.trim() : ''
|
|
534
479
|
const text = typeof body.text === 'string' && body.text.trim().length > 0 ? body.text.trim() : ''
|
|
@@ -574,19 +519,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
574
519
|
*/
|
|
575
520
|
const handleAgentCreate = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
576
521
|
try {
|
|
577
|
-
if (!
|
|
578
|
-
json(res, { ok: false, error: 'send-to-agent is disabled' }, 403)
|
|
579
|
-
return
|
|
580
|
-
}
|
|
581
|
-
const token = deps.sendToAgentToken().trim()
|
|
582
|
-
if (token.length > 0) {
|
|
583
|
-
const got = req.headers['x-send-to-agent-token']
|
|
584
|
-
const value = typeof got === 'string' ? got : Array.isArray(got) ? got[0] ?? '' : ''
|
|
585
|
-
if (value !== token) {
|
|
586
|
-
json(res, { ok: false, error: 'unauthorized' }, 401)
|
|
587
|
-
return
|
|
588
|
-
}
|
|
589
|
-
}
|
|
522
|
+
if (!guardSendToAgent(req, res)) return
|
|
590
523
|
const body = JSON.parse(await readBody(req)) as { cwd?: unknown; mode?: unknown; permission?: unknown }
|
|
591
524
|
const cwd = typeof body.cwd === 'string' ? body.cwd.trim() : ''
|
|
592
525
|
const mode = typeof body.mode === 'string' && body.mode.trim().length > 0 ? body.mode.trim() : undefined
|
|
@@ -781,7 +714,7 @@ export function registerRoutes(ctx: { webServer: WebServerFace }, deps: RouteDep
|
|
|
781
714
|
text: t.text ?? '',
|
|
782
715
|
tags: t.tags ?? [],
|
|
783
716
|
type: t.type ?? 'text/vnd.tiddlywiki',
|
|
784
|
-
modified: typeof t.modified === 'string' ?
|
|
717
|
+
modified: typeof t.modified === 'string' ? t.modified : null,
|
|
785
718
|
fields,
|
|
786
719
|
})
|
|
787
720
|
} catch (err) {
|
package/src/host/seeds.ts
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* - `remove` — optional seeds only: delete the seeded tiddlers + markers,
|
|
19
19
|
* returning the wiki to the "never seeded" state.
|
|
20
20
|
*
|
|
21
|
-
* Registry: doc-note / send-to-agent /
|
|
22
|
-
* menubar-theme / tw-web-host.
|
|
21
|
+
* Registry: doc-note / send-to-agent / render-route / home-index /
|
|
22
|
+
* all-articles / menubar-theme / tw-web-host.
|
|
23
23
|
*
|
|
24
24
|
* @module dsh-tiddlywiki/host/seeds
|
|
25
25
|
*/
|
|
@@ -93,182 +93,129 @@ const removedDetail = (id: string, removed: string[]): SeedRunResult => ({
|
|
|
93
93
|
: '本就不存在,无需移除',
|
|
94
94
|
})
|
|
95
95
|
|
|
96
|
+
/** Metadata shared by every seed and echoed in its status/result objects. */
|
|
97
|
+
interface SeedMeta {
|
|
98
|
+
id: string
|
|
99
|
+
title: string
|
|
100
|
+
description: string
|
|
101
|
+
core: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type SeedWriter = (client: TiddlyWebClient, opts?: { force?: boolean }) => Promise<boolean>
|
|
105
|
+
type SeedUnseeder = (client: TiddlyWebClient) => Promise<{ removed: string[] }>
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Define a seed from a small spec, killing the id/title/description
|
|
109
|
+
* triplication and the try/catch boilerplate every registry entry used to
|
|
110
|
+
* repeat by hand:
|
|
111
|
+
* - `presentTitle` → default check: is that tiddler present?
|
|
112
|
+
* - `check` → custom presence check (overrides presentTitle).
|
|
113
|
+
* - `write` → built-in content writer; true = (re)written this call.
|
|
114
|
+
* - `run` → custom runner (overrides the standard write wrapper;
|
|
115
|
+
* e.g. tw-web-host honors a user-chosen host value).
|
|
116
|
+
* - `unseed` → remove the seeded tiddlers + markers (removable seeds).
|
|
117
|
+
*/
|
|
118
|
+
function defineSeed(meta: SeedMeta, impl: {
|
|
119
|
+
presentTitle?: string
|
|
120
|
+
check?: (ctx: SeedContext) => Promise<SeedStatus>
|
|
121
|
+
write?: SeedWriter
|
|
122
|
+
run?: (ctx: SeedContext, force: boolean) => Promise<SeedRunResult>
|
|
123
|
+
unseed?: SeedUnseeder
|
|
124
|
+
}): SeedDef {
|
|
125
|
+
const { id, title, description, core } = meta
|
|
126
|
+
const removable = !core
|
|
127
|
+
const check: SeedDef['check'] = impl.check ?? (async (ctx) => {
|
|
128
|
+
const present = await presentOf(ctx, impl.presentTitle ?? '')
|
|
129
|
+
return { id, title, description, present, removable, detail: present ? '已存在' : '缺失' }
|
|
130
|
+
})
|
|
131
|
+
const run: SeedDef['run'] = impl.run ?? (async (ctx, force) => {
|
|
132
|
+
try {
|
|
133
|
+
const wrote = await impl.write!(ctx.client, { force })
|
|
134
|
+
return { id, ok: true, wrote, detail: wrote ? (force ? '已重新初始化' : '已写入') : (force ? '内容已是最新(未重写)' : '已存在,跳过') }
|
|
135
|
+
} catch (err) {
|
|
136
|
+
return { id, ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
const remove: SeedDef['remove'] = impl.unseed === undefined ? undefined : async (ctx) => {
|
|
140
|
+
try {
|
|
141
|
+
return removedDetail(id, (await impl.unseed!(ctx.client)).removed)
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return { id, ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { id, title, description, core, check, run, ...(remove === undefined ? {} : { remove }) }
|
|
147
|
+
}
|
|
148
|
+
|
|
96
149
|
/** The full registry, in display order. */
|
|
97
150
|
export const SEED_DEFS: SeedDef[] = [
|
|
98
|
-
|
|
99
|
-
id: 'doc-note',
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
},
|
|
123
|
-
{
|
|
124
|
-
id: 'send-to-agent',
|
|
125
|
-
title: '「发送给 Agent」按钮',
|
|
126
|
-
description: 'TW 笔记工具栏「发送给 Agent」按钮插件($:/plugins/dsh/send-to-agent)——把笔记一键注入 DSH 会话。',
|
|
127
|
-
core: true,
|
|
128
|
-
check: async (ctx) => {
|
|
129
|
-
const present = await presentOf(ctx, SEND_TO_AGENT_PLUGIN_TITLE)
|
|
130
|
-
return { id: 'send-to-agent', title: '「发送给 Agent」按钮', description: 'TW 笔记工具栏「发送给 Agent」按钮插件($:/plugins/dsh/send-to-agent)——把笔记一键注入 DSH 会话。', present, removable: false, detail: present ? '已存在' : '缺失' }
|
|
131
|
-
},
|
|
132
|
-
run: async (ctx, force) => {
|
|
133
|
-
try {
|
|
134
|
-
const wrote = await seedSendToAgent(ctx.client, { force })
|
|
135
|
-
return { id: 'send-to-agent', ok: true, wrote, detail: wrote ? (force ? '已重新初始化' : '已写入') : (force ? '内容已是最新(未重写)' : '已存在,跳过') }
|
|
136
|
-
} catch (err) {
|
|
137
|
-
return { id: 'send-to-agent', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
|
|
138
|
-
}
|
|
139
|
-
},
|
|
140
|
-
},
|
|
141
|
-
{
|
|
142
|
-
id: 'render-route',
|
|
143
|
-
title: '原生渲染路由(/render)',
|
|
144
|
-
description: 'TW 服务端路由插件($:/plugins/dsh/render,server-routes/render.js)——把 wiki 文本在运行中的 TW 里原生渲染成 HTML 片段,回复流工具卡与 wiki 链接跳转依赖它。seed 写入后需重启 TW 使路由生效。',
|
|
145
|
-
core: true,
|
|
146
|
-
check: async (ctx) => {
|
|
147
|
-
const present = await presentOf(ctx, RENDER_PLUGIN_TITLE)
|
|
148
|
-
return { id: 'render-route', title: '原生渲染路由(/render)', description: 'TW 服务端路由插件($:/plugins/dsh/render,server-routes/render.js)——把 wiki 文本在运行中的 TW 里原生渲染成 HTML 片段,回复流工具卡与 wiki 链接跳转依赖它。seed 写入后需重启 TW 使路由生效。', present, removable: false, detail: present ? '已存在' : '缺失' }
|
|
149
|
-
},
|
|
150
|
-
run: async (ctx, force) => {
|
|
151
|
-
try {
|
|
152
|
-
const wrote = await seedRenderRoute(ctx.client, { force })
|
|
153
|
-
return { id: 'render-route', ok: true, wrote, detail: wrote ? (force ? '已重新初始化' : '已写入') : (force ? '内容已是最新(未重写)' : '已存在,跳过') }
|
|
154
|
-
} catch (err) {
|
|
155
|
-
return { id: 'render-route', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
|
|
156
|
-
}
|
|
157
|
-
},
|
|
158
|
-
},
|
|
159
|
-
{
|
|
160
|
-
id: 'home-index',
|
|
161
|
-
title: '首页(主页 / 所有标签 / 标签笔记)',
|
|
162
|
-
description: '默认主页:四象限待办 + 「所有标签」「所有文章」入口;所有标签:标签统计 + Agent 区块(纯 Agent / Agent+人工);标签笔记:按标签浏览。系统提示承诺的首页由这里 seed,主页同时写入 $:/DefaultTiddlers。',
|
|
163
|
-
core: false,
|
|
164
|
-
check: async (ctx) => {
|
|
165
|
-
const missing: string[] = []
|
|
166
|
-
for (const item of HOME_INDEX_ITEMS) {
|
|
167
|
-
if (!(await presentOf(ctx, item.title))) missing.push(item.title)
|
|
168
|
-
}
|
|
169
|
-
return { id: 'home-index', title: '首页(主页 / 所有标签 / 标签笔记)', description: '默认主页:四象限待办 + 「所有标签」「所有文章」入口;所有标签:标签统计 + Agent 区块(纯 Agent / Agent+人工);标签笔记:按标签浏览。系统提示承诺的首页由这里 seed,主页同时写入 $:/DefaultTiddlers。', present: missing.length === 0, removable: true, detail: missing.length === 0 ? '已存在' : `缺失:${missing.join('、')}` }
|
|
170
|
-
},
|
|
171
|
-
run: async (ctx, force) => {
|
|
172
|
-
try {
|
|
173
|
-
const wrote = await seedHomeIndex(ctx.client, { force })
|
|
174
|
-
return { id: 'home-index', ok: true, wrote, detail: wrote ? (force ? '已重新初始化' : '已写入') : (force ? '内容已是最新(未重写)' : '已存在,跳过') }
|
|
175
|
-
} catch (err) {
|
|
176
|
-
return { id: 'home-index', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
|
|
177
|
-
}
|
|
178
|
-
},
|
|
179
|
-
remove: async (ctx) => {
|
|
180
|
-
try {
|
|
181
|
-
return removedDetail('home-index', (await unseedHomeIndex(ctx.client)).removed)
|
|
182
|
-
} catch (err) {
|
|
183
|
-
return { id: 'home-index', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
|
|
184
|
-
}
|
|
185
|
-
},
|
|
186
|
-
},
|
|
187
|
-
{
|
|
188
|
-
id: 'all-articles',
|
|
189
|
-
title: '所有文章(两列分页总览)',
|
|
190
|
-
description: '「所有文章」——全部条目分两列(🤖 Agent 撰写 / 👤 人工·人类)各自分页展示。每页条数取插件设置 ui.allArticles.pageSize(默认 10)。',
|
|
191
|
-
core: false,
|
|
192
|
-
check: async (ctx) => {
|
|
193
|
-
const present = await presentOf(ctx, ALL_ARTICLES_TITLE)
|
|
194
|
-
return { id: 'all-articles', title: '所有文章(两列分页总览)', description: '「所有文章」——全部条目分两列(🤖 Agent 撰写 / 👤 人工·人类)各自分页展示。每页条数取插件设置 ui.allArticles.pageSize(默认 10)。', present, removable: true, detail: present ? '已存在' : '缺失' }
|
|
195
|
-
},
|
|
196
|
-
run: async (ctx, force) => {
|
|
197
|
-
try {
|
|
198
|
-
const wrote = await seedAllArticles(ctx.client, { force })
|
|
199
|
-
return { id: 'all-articles', ok: true, wrote, detail: wrote ? (force ? '已重新初始化' : '已写入') : (force ? '内容已是最新(未重写)' : '已存在,跳过') }
|
|
200
|
-
} catch (err) {
|
|
201
|
-
return { id: 'all-articles', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
|
|
202
|
-
}
|
|
203
|
-
},
|
|
204
|
-
remove: async (ctx) => {
|
|
205
|
-
try {
|
|
206
|
-
return removedDetail('all-articles', (await unseedAllArticles(ctx.client)).removed)
|
|
207
|
-
} catch (err) {
|
|
208
|
-
return { id: 'all-articles', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
|
|
209
|
-
}
|
|
210
|
-
},
|
|
211
|
-
},
|
|
212
|
-
{
|
|
213
|
-
id: 'menubar-theme',
|
|
214
|
-
title: 'menubar 顶栏主题自适应',
|
|
215
|
-
description: '样式表覆盖($:/plugins/dsh-tiddlywiki/menubar-theme,tag $:/tags/Stylesheet)——把 tiddlywiki/menubar 顶栏从「默认色映射的蓝色」改为跟随当前 palette 的 background/foreground,随 DSH 主题切换($:/palette 翻转)自动换色。',
|
|
216
|
-
core: false,
|
|
217
|
-
check: async (ctx) => {
|
|
218
|
-
const present = await presentOf(ctx, MENUBAR_THEME_TIDDLER)
|
|
219
|
-
return { id: 'menubar-theme', title: 'menubar 顶栏主题自适应', description: '样式表覆盖($:/plugins/dsh-tiddlywiki/menubar-theme,tag $:/tags/Stylesheet)——把 tiddlywiki/menubar 顶栏从「默认色映射的蓝色」改为跟随当前 palette 的 background/foreground,随 DSH 主题切换($:/palette 翻转)自动换色。', present, removable: true, detail: present ? '已存在' : '缺失' }
|
|
220
|
-
},
|
|
221
|
-
run: async (ctx, force) => {
|
|
222
|
-
try {
|
|
223
|
-
const wrote = await seedMenubarTheme(ctx.client, { force })
|
|
224
|
-
return { id: 'menubar-theme', ok: true, wrote, detail: wrote ? (force ? '已重新初始化' : '已写入') : (force ? '内容已是最新(未重写)' : '已存在,跳过') }
|
|
225
|
-
} catch (err) {
|
|
226
|
-
return { id: 'menubar-theme', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
|
|
227
|
-
}
|
|
228
|
-
},
|
|
229
|
-
remove: async (ctx) => {
|
|
230
|
-
try {
|
|
231
|
-
return removedDetail('menubar-theme', (await unseedMenubarTheme(ctx.client)).removed)
|
|
232
|
-
} catch (err) {
|
|
233
|
-
return { id: 'menubar-theme', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
|
|
234
|
-
}
|
|
235
|
-
},
|
|
236
|
-
},
|
|
237
|
-
{
|
|
238
|
-
id: 'tw-web-host',
|
|
239
|
-
title: 'TW 前端 API 基址(同源代理)',
|
|
240
|
-
description: '把 $:/config/tiddlyweb/host 指向 DSH 同源代理,嵌入式 TW 才能经 DSH origin 访问(远程访问模式的前提)。',
|
|
241
|
-
core: true,
|
|
242
|
-
check: async (ctx) => {
|
|
243
|
-
let current: string | undefined
|
|
244
|
-
try {
|
|
245
|
-
current = (await ctx.client.get(TW_WEB_HOST_TIDDLER))?.text?.trim()
|
|
246
|
-
} catch {
|
|
247
|
-
current = undefined
|
|
248
|
-
}
|
|
249
|
-
const ok = current === TW_PROXY_PATH
|
|
250
|
-
return { id: 'tw-web-host', title: 'TW 前端 API 基址(同源代理)', description: '把 $:/config/tiddlyweb/host 指向 DSH 同源代理,嵌入式 TW 才能经 DSH origin 访问(远程访问模式的前提)。', present: ok, removable: false, detail: ok ? `已指向 ${TW_PROXY_PATH}` : `当前:${current ?? '(缺失)'},应为 ${TW_PROXY_PATH}` }
|
|
151
|
+
defineSeed(
|
|
152
|
+
{ id: 'doc-note', title: '插件说明笔记', description: '「dsh-tiddlywiki 插件说明」——新 wiki 首启自动写入的入门说明(ONE-SHOT,用户可改可删)。', core: false },
|
|
153
|
+
{ presentTitle: DOC_NOTE_TITLE, write: seedDocNote, unseed: unseedDocNote },
|
|
154
|
+
),
|
|
155
|
+
defineSeed(
|
|
156
|
+
{ id: 'send-to-agent', title: '「发送给 Agent」按钮', description: 'TW 笔记工具栏「发送给 Agent」按钮插件($:/plugins/dsh/send-to-agent)——把笔记一键注入 DSH 会话。', core: true },
|
|
157
|
+
{ presentTitle: SEND_TO_AGENT_PLUGIN_TITLE, write: seedSendToAgent },
|
|
158
|
+
),
|
|
159
|
+
defineSeed(
|
|
160
|
+
{ id: 'render-route', title: '原生渲染路由(/render)', description: 'TW 服务端路由插件($:/plugins/dsh/render,server-routes/render.js)——把 wiki 文本在运行中的 TW 里原生渲染成 HTML 片段,回复流工具卡与 wiki 链接跳转依赖它。seed 写入后需重启 TW 使路由生效。', core: true },
|
|
161
|
+
{ presentTitle: RENDER_PLUGIN_TITLE, write: seedRenderRoute },
|
|
162
|
+
),
|
|
163
|
+
defineSeed(
|
|
164
|
+
{ id: 'home-index', title: '首页(主页 / 所有标签 / 标签笔记)', description: '默认主页:四象限待办 + 「所有标签」「所有文章」入口;所有标签:标签统计 + Agent 区块(纯 Agent / Agent+人工);标签笔记:按标签浏览。系统提示承诺的首页由这里 seed,主页同时写入 $:/DefaultTiddlers。', core: false },
|
|
165
|
+
{
|
|
166
|
+
check: async (ctx) => {
|
|
167
|
+
const missing: string[] = []
|
|
168
|
+
for (const item of HOME_INDEX_ITEMS) {
|
|
169
|
+
if (!(await presentOf(ctx, item.title))) missing.push(item.title)
|
|
170
|
+
}
|
|
171
|
+
return { id: 'home-index', title: '首页(主页 / 所有标签 / 标签笔记)', description: '默认主页:四象限待办 + 「所有标签」「所有文章」入口;所有标签:标签统计 + Agent 区块(纯 Agent / Agent+人工);标签笔记:按标签浏览。系统提示承诺的首页由这里 seed,主页同时写入 $:/DefaultTiddlers。', present: missing.length === 0, removable: true, detail: missing.length === 0 ? '已存在' : `缺失:${missing.join('、')}` }
|
|
172
|
+
},
|
|
173
|
+
write: seedHomeIndex,
|
|
174
|
+
unseed: unseedHomeIndex,
|
|
251
175
|
},
|
|
252
|
-
|
|
253
|
-
|
|
176
|
+
),
|
|
177
|
+
defineSeed(
|
|
178
|
+
{ id: 'all-articles', title: '所有文章(两列分页总览)', description: '「所有文章」——全部条目分两列(🤖 Agent 撰写 / 👤 人工·人类)各自分页展示。每页条数取插件设置 ui.allArticles.pageSize(默认 10)。', core: false },
|
|
179
|
+
{ presentTitle: ALL_ARTICLES_TITLE, write: seedAllArticles, unseed: unseedAllArticles },
|
|
180
|
+
),
|
|
181
|
+
defineSeed(
|
|
182
|
+
{ id: 'menubar-theme', title: 'menubar 顶栏主题自适应', description: '样式表覆盖($:/plugins/dsh-tiddlywiki/menubar-theme,tag $:/tags/Stylesheet)——把 tiddlywiki/menubar 顶栏从「默认色映射的蓝色」改为跟随当前 palette 的 background/foreground,随 DSH 主题切换($:/palette 翻转)自动换色。', core: false },
|
|
183
|
+
{ presentTitle: MENUBAR_THEME_TIDDLER, write: seedMenubarTheme, unseed: unseedMenubarTheme },
|
|
184
|
+
),
|
|
185
|
+
defineSeed(
|
|
186
|
+
{ id: 'tw-web-host', title: 'TW 前端 API 基址(同源代理)', description: '把 $:/config/tiddlyweb/host 指向 DSH 同源代理,嵌入式 TW 才能经 DSH origin 访问(远程访问模式的前提)。', core: true },
|
|
187
|
+
{
|
|
188
|
+
check: async (ctx) => {
|
|
254
189
|
let current: string | undefined
|
|
255
190
|
try {
|
|
256
191
|
current = (await ctx.client.get(TW_WEB_HOST_TIDDLER))?.text?.trim()
|
|
257
192
|
} catch {
|
|
258
193
|
current = undefined
|
|
259
194
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
195
|
+
const ok = current === TW_PROXY_PATH
|
|
196
|
+
return { id: 'tw-web-host', title: 'TW 前端 API 基址(同源代理)', description: '把 $:/config/tiddlyweb/host 指向 DSH 同源代理,嵌入式 TW 才能经 DSH origin 访问(远程访问模式的前提)。', present: ok, removable: false, detail: ok ? `已指向 ${TW_PROXY_PATH}` : `当前:${current ?? '(缺失)'},应为 ${TW_PROXY_PATH}` }
|
|
197
|
+
},
|
|
198
|
+
run: async (ctx, force) => {
|
|
199
|
+
try {
|
|
200
|
+
let current: string | undefined
|
|
201
|
+
try {
|
|
202
|
+
current = (await ctx.client.get(TW_WEB_HOST_TIDDLER))?.text?.trim()
|
|
203
|
+
} catch {
|
|
204
|
+
current = undefined
|
|
205
|
+
}
|
|
206
|
+
// Non-force keeps the ensure semantics: write only when missing or still
|
|
207
|
+
// the legacy default (a user override pointing elsewhere is honored).
|
|
208
|
+
if (!force && current !== undefined && current !== TW_WEB_HOST_DEFAULT) {
|
|
209
|
+
return { id: 'tw-web-host', ok: true, wrote: false, detail: '已指向自定义基址,未覆盖' }
|
|
210
|
+
}
|
|
211
|
+
await ctx.client.put({ title: TW_WEB_HOST_TIDDLER, text: TW_PROXY_PATH, type: 'text/plain', tags: [] })
|
|
212
|
+
return { id: 'tw-web-host', ok: true, wrote: true, detail: force ? '已重新初始化(强制写回代理基址)' : '已写入代理基址' }
|
|
213
|
+
} catch (err) {
|
|
214
|
+
return { id: 'tw-web-host', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
|
|
264
215
|
}
|
|
265
|
-
|
|
266
|
-
return { id: 'tw-web-host', ok: true, wrote: true, detail: force ? '已重新初始化(强制写回代理基址)' : '已写入代理基址' }
|
|
267
|
-
} catch (err) {
|
|
268
|
-
return { id: 'tw-web-host', ok: false, wrote: false, error: err instanceof Error ? err.message : String(err) }
|
|
269
|
-
}
|
|
216
|
+
},
|
|
270
217
|
},
|
|
271
|
-
|
|
218
|
+
),
|
|
272
219
|
]
|
|
273
220
|
|
|
274
221
|
/** Check every seed, returning statuses in registry order. */
|
|
@@ -105,9 +105,24 @@ function collectDescendantIds(nodes: SessionLineageNode[] | undefined, out: stri
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* 汇总条目会直接写进 `[[标题]]` wikitext——标题既要能安全渲染、又不能制造死链。
|
|
110
|
+
* 会话日志里常混入模型的示例/残缺写法(`#…`、`#标题`、带反引号的截断链接、
|
|
111
|
+
* 控制字符等),这类收进来会被 TW 渲染成「佚失条目」;一律过滤。
|
|
112
|
+
*/
|
|
113
|
+
function isPlausibleTitle(title: string): boolean {
|
|
114
|
+
if (title.length === 0 || title.length > 300) return false
|
|
115
|
+
if (title.trim().length === 0) return false
|
|
116
|
+
if (/[\u0000-\u001f\u007f]/.test(title)) return false // 控制字符 / 换行
|
|
117
|
+
if (/[\]|`#]/.test(title)) return false // 会破坏 [[…]] 链接语法
|
|
118
|
+
if (title.startsWith('…') || title === '标题') return false // 占位 / 示例写法
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
108
122
|
/** 记录一篇笔记的触达;同标题合并(时间取最新、detail 取最新、subagent 取 AND)。 */
|
|
109
123
|
function record(map: Map<string, NoteEntry>, entry: NoteEntry): void {
|
|
110
124
|
if (entry.title.startsWith('$:/')) return
|
|
125
|
+
if (!isPlausibleTitle(entry.title)) return
|
|
111
126
|
const prev = map.get(entry.title)
|
|
112
127
|
if (prev !== undefined) {
|
|
113
128
|
prev.time = Math.max(prev.time, entry.time)
|
|
@@ -120,7 +135,9 @@ function record(map: Map<string, NoteEntry>, entry: NoteEntry): void {
|
|
|
120
135
|
|
|
121
136
|
/** 扫助手回复文本里的 `/dsh-tiddlywiki/tw/#标题` 引用链接 → 读取。 */
|
|
122
137
|
function scanRefs(text: string, time: number, subagent: boolean, collected: Collected): void {
|
|
123
|
-
|
|
138
|
+
// 只认到下一个 `)` / 空白 / `]` / `#` 为止——`#` 会截断后续碎片(模型示例常写成
|
|
139
|
+
// `#…` 或 `#标题`,这些由 isPlausibleTitle 在 record 时过滤)。
|
|
140
|
+
const re = /\/dsh-tiddlywiki\/tw\/#([^#)\s\]]+)/g
|
|
124
141
|
let m: RegExpExecArray | null
|
|
125
142
|
while ((m = re.exec(text)) !== null) {
|
|
126
143
|
const raw = m[1] ?? ''
|
|
@@ -221,27 +238,30 @@ interface TitleState {
|
|
|
221
238
|
snippet?: string
|
|
222
239
|
}
|
|
223
240
|
|
|
224
|
-
/**
|
|
241
|
+
/** 并发(8 路)查询每篇笔记的当前状态(404 → 不存在/已删除)。 */
|
|
225
242
|
async function enrichTitles(client: TiddlyWebClient, titles: string[]): Promise<Map<string, TitleState>> {
|
|
226
243
|
const out = new Map<string, TitleState>()
|
|
227
|
-
|
|
244
|
+
const enquire = async (title: string): Promise<[string, TitleState]> => {
|
|
228
245
|
try {
|
|
229
246
|
const t = await client.get(title)
|
|
230
|
-
if (t === undefined) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
snippet: flat.length > 48 ? `${flat.slice(0, 48)}…` : flat,
|
|
239
|
-
})
|
|
240
|
-
}
|
|
247
|
+
if (t === undefined) return [title, { exists: false, tags: [] }]
|
|
248
|
+
const flat = typeof t.text === 'string' ? t.text.replace(/\s+/g, ' ').trim() : ''
|
|
249
|
+
return [title, {
|
|
250
|
+
exists: true,
|
|
251
|
+
tags: t.tags ?? [],
|
|
252
|
+
modified: typeof t.modified === 'string' ? t.modified : undefined,
|
|
253
|
+
snippet: flat.length > 48 ? `${flat.slice(0, 48)}…` : flat,
|
|
254
|
+
}]
|
|
241
255
|
} catch {
|
|
242
|
-
|
|
256
|
+
return [title, { exists: false, tags: [] }]
|
|
243
257
|
}
|
|
244
258
|
}
|
|
259
|
+
// Bounded batches: parallel within a batch, insertion order preserved across
|
|
260
|
+
// batches (Map order = the caller's title order, same as the old sequential).
|
|
261
|
+
for (let i = 0; i < titles.length; i += 8) {
|
|
262
|
+
const batch = titles.slice(i, i + 8)
|
|
263
|
+
for (const [title, state] of await Promise.all(batch.map(enquire))) out.set(title, state)
|
|
264
|
+
}
|
|
245
265
|
return out
|
|
246
266
|
}
|
|
247
267
|
|