dsh-tiddlywiki 0.16.19 → 0.16.21

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.
@@ -1,604 +1,604 @@
1
- /**
2
- * Reply-stream native TiddlyWiki tool views (design 「回复流原生 TW wiki」 ②③).
3
- *
4
- * Registers the `tool.call.toolview` KEYED slot (key = each `tiddlywiki_*`
5
- * wire tool name, domain open — verified via Slots Inspect that none of our
6
- * keys are taken) so a tool call's card in the conversation flow renders the
7
- * wiki content in its NATIVE form:
8
- *
9
- * title (from block.call.argsRaw) → GET /dsh-tiddlywiki/get (full tiddler)
10
- * → POST /dsh-tiddlywiki/tw/render (native
11
- * HTML fragment, links rewritten to the
12
- * same-origin proxy hash) → card body
13
- *
14
- * The whole component is plain React (`React.createElement`, no JSX — the
15
- * client bundle is not transpiled); `react` is never bundled and resolves from
16
- * the web app at runtime. The native fragment is injected via
17
- * `dangerouslySetInnerHTML` — acceptable because the HTML comes from the LOCAL
18
- * wiki (same trust as the embedded center-column editor).
19
- *
20
- * The render fragment + the agent's `[标题](/dsh-tiddlywiki/tw/#标题)` markdown
21
- * links are handled by a document-level click interceptor → openTiddler()
22
- * (open the center panel + set the iframe hash → TW native page).
23
- *
24
- * @module dsh-tiddlywiki/client/tool-views
25
- */
26
- import * as React from 'react'
27
- import { openTiddler } from './panel.ts'
28
-
29
- /** Same-origin TW proxy base (mirrors host TW_PROXY_PATH, client can't import it). */
30
- const TW_PROXY_BASE = '/dsh-tiddlywiki/tw/'
31
-
32
- const GET_ENDPOINT = '/dsh-tiddlywiki/get'
33
- const SEARCH_ENDPOINT = '/dsh-tiddlywiki/search'
34
- const RECENT_ENDPOINT = '/dsh-tiddlywiki/recent'
35
- const TAGS_ENDPOINT = '/dsh-tiddlywiki/tags'
36
- const RENDER_ENDPOINT = '/dsh-tiddlywiki/tw/render'
37
-
38
- /** Chinese label for each tool (card badge). */
39
- const TOOL_LABELS: Record<string, string> = {
40
- tiddlywiki_get: '读取笔记',
41
- tiddlywiki_search: '检索笔记',
42
- tiddlywiki_recent: '最近修改',
43
- tiddlywiki_list_tags: '标签列表',
44
- tiddlywiki_put: '写入笔记',
45
- tiddlywiki_batch_put: '批量写入',
46
- tiddlywiki_rename: '重命名笔记',
47
- tiddlywiki_delete: '删除笔记',
48
- tiddlywiki_git_sync: 'git 同步',
49
- tiddlywiki_git_resolve: 'git 冲突解决',
50
- }
51
-
52
- /* ── structural types (subset of dsh-client-ui-conversation records) ── */
53
-
54
- interface CallHead {
55
- name: string
56
- argsRaw: string
57
- }
58
-
59
- interface RunningCallLike {
60
- name?: string
61
- argsRaw?: string
62
- }
63
-
64
- interface SettledLike {
65
- kind?: string
66
- call?: CallHead | null
67
- content?: readonly ContentBlockLike[]
68
- isError?: boolean
69
- }
70
-
71
- interface ContentBlockLike {
72
- kind?: string
73
- text?: unknown
74
- }
75
-
76
- /** Owner props the shell passes to a keyed tool view (verified via Inspect). */
77
- interface ToolCallOwnerProps {
78
- callId: string
79
- toolName: string
80
- block: RunningCallLike | SettledLike
81
- cwd?: string
82
- home?: string
83
- openFile: (path: string) => void
84
- inspect?: () => void
85
- }
86
-
87
- function isSettled(block: RunningCallLike | SettledLike): block is SettledLike {
88
- return typeof block === 'object' && block !== null && (block as SettledLike).kind === 'tool-result'
89
- }
90
-
91
- /** The call identity: settled nodes carry it under `call`, running nodes top-level. */
92
- function callArgs(block: RunningCallLike | SettledLike): CallHead | null {
93
- if (typeof block !== 'object' || block === null) return null
94
- const settled = block as SettledLike
95
- if (typeof settled.call === 'object' && settled.call !== null) {
96
- const c = settled.call as CallHead
97
- if (typeof c.name === 'string' && typeof c.argsRaw === 'string') return c
98
- }
99
- const running = block as RunningCallLike
100
- if (typeof running.name === 'string' && typeof running.argsRaw === 'string') {
101
- return { name: running.name, argsRaw: running.argsRaw }
102
- }
103
- return null
104
- }
105
-
106
- function parseArgs(argsRaw: string): Record<string, unknown> {
107
- try {
108
- const parsed = JSON.parse(argsRaw) as unknown
109
- return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
110
- } catch {
111
- return {}
112
- }
113
- }
114
-
115
- /** Model-visible rendered text of a settled node (fallback when service is down). */
116
- function contentText(content: readonly ContentBlockLike[] | undefined): string {
117
- if (!Array.isArray(content)) return ''
118
- return content
119
- .filter((b): b is ContentBlockLike & { text: string } => typeof b?.text === 'string')
120
- .map((b) => b.text as string)
121
- .join('\n')
122
- }
123
-
124
- function str(value: unknown): string {
125
- return typeof value === 'string' ? value : ''
126
- }
127
-
128
- /* ── fetch helpers (same-origin JSON / fragment) ── */
129
-
130
- async function fetchJson(url: string): Promise<Record<string, unknown> | null> {
131
- try {
132
- const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
133
- if (!res.ok) return null
134
- const data = (await res.json()) as unknown
135
- return typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : null
136
- } catch {
137
- return null
138
- }
139
- }
140
-
141
- async function fetchRender(title: string): Promise<string | null> {
142
- try {
143
- const res = await fetch(RENDER_ENDPOINT, {
144
- method: 'POST',
145
- // TW's server gates every POST behind the writer CSRF header
146
- // (core-server/server.js: POST → "writers", requires X-Requested-With).
147
- // Same-origin requests may set it freely; the header is what makes the
148
- // request land instead of a 403.
149
- headers: { 'content-type': 'application/json', 'x-requested-with': 'TiddlyWiki' },
150
- body: JSON.stringify({ title }),
151
- signal: AbortSignal.timeout(10_000),
152
- })
153
- if (!res.ok) return null
154
- return await res.text()
155
- } catch {
156
- return null
157
- }
158
- }
159
-
160
- /** Lightweight async-state hook for one loader keyed by `deps`. */
161
- function useAsync<T>(factory: () => Promise<T | null>, deps: readonly unknown[]): { loading: boolean; data: T | null } {
162
- const [state, setState] = React.useState<{ loading: boolean; data: T | null }>({ loading: true, data: null })
163
- React.useEffect(() => {
164
- let alive = true
165
- setState({ loading: true, data: null })
166
- Promise.resolve(factory()).then(
167
- (data) => {
168
- if (alive) setState({ loading: false, data })
169
- },
170
- () => {
171
- if (alive) setState({ loading: false, data: null })
172
- },
173
- )
174
- return () => {
175
- alive = false
176
- }
177
- // factory identity is not a dep on purpose — callers pass stable closures
178
- // eslint-disable-next-line react-hooks/exhaustive-deps
179
- }, deps)
180
- return state
181
- }
182
-
183
- /** Open a tiddler in the center TW panel (shared by rows + 「在 TW 打开」). */
184
- function openTw(title: string): (event: React.MouseEvent) => void {
185
- return (event) => {
186
- event.preventDefault()
187
- event.stopPropagation()
188
- openTiddler(title)
189
- }
190
- }
191
-
192
- /* ── shared card shell ── */
193
-
194
- function ToolCardShell(props: {
195
- toolName: string
196
- title?: string
197
- subtitle?: string
198
- tags?: readonly string[]
199
- onOpen?: (event: React.MouseEvent) => void
200
- foot?: React.ReactNode
201
- children?: React.ReactNode
202
- }): React.ReactElement {
203
- const label = TOOL_LABELS[props.toolName] ?? props.toolName
204
- const head = React.createElement(
205
- 'div',
206
- { className: 'dsh-tw-toolcard-head' },
207
- React.createElement('span', { className: 'dsh-tw-toolcard-badge' }, label),
208
- props.title !== undefined && props.title.length > 0
209
- ? React.createElement('span', { className: 'dsh-tw-toolcard-title', title: props.title }, props.title)
210
- : null,
211
- props.onOpen !== undefined
212
- ? React.createElement('button', { type: 'button', className: 'dsh-tw-toolcard-open', onClick: props.onOpen }, '在 TW 打开')
213
- : null,
214
- )
215
- const meta = props.subtitle !== undefined || (props.tags !== undefined && props.tags.length > 0)
216
- ? React.createElement(
217
- 'div',
218
- { className: 'dsh-tw-toolcard-meta' },
219
- props.subtitle !== undefined ? React.createElement('span', { className: 'dsh-tw-toolcard-sub' }, props.subtitle) : null,
220
- props.tags !== undefined && props.tags.length > 0
221
- ? React.createElement(
222
- 'span',
223
- { className: 'dsh-tw-toolcard-tags' },
224
- ...props.tags.map((tag) => React.createElement('span', { className: 'dsh-tw-toolcard-tag', key: tag }, tag)),
225
- )
226
- : null,
227
- )
228
- : null
229
- return React.createElement(
230
- 'div',
231
- { className: 'dsh-tw-toolcard' },
232
- head,
233
- meta,
234
- React.createElement('div', { className: 'dsh-tw-toolcard-body' }, props.children),
235
- props.foot !== undefined && props.foot !== null ? React.createElement('div', { className: 'dsh-tw-toolcard-foot' }, props.foot) : null,
236
- )
237
- }
238
-
239
- /* ── tiddler card (get / put / rename): native-rendered body ── */
240
-
241
- function TiddlerBodyCard(props: { toolName: string; title: string; subtitle: string }): React.ReactElement {
242
- const { title } = props
243
- const both = useAsync(
244
- async () => {
245
- const [get, html] = await Promise.all([fetchJson(`${GET_ENDPOINT}?title=${encodeURIComponent(title)}`), fetchRender(title)])
246
- return { get, html }
247
- },
248
- [title],
249
- )
250
- const { loading, data } = both
251
- const get = data?.get ?? null
252
- const html = data?.html ?? null
253
-
254
- let body: React.ReactNode
255
- if (loading) {
256
- body = React.createElement('div', { className: 'dsh-tw-toolcard-loading' }, '渲染中…')
257
- } else if (get !== null && get.notFound === true) {
258
- body = React.createElement('div', { className: 'dsh-tw-toolcard-empty' }, `tiddler「${title}」不存在`)
259
- } else if (typeof html === 'string' && html.length > 0) {
260
- body = React.createElement('div', {
261
- className: 'dsh-tw-toolcard-native',
262
- dangerouslySetInnerHTML: { __html: html },
263
- })
264
- } else if (get !== null && typeof get.text === 'string' && get.text.length > 0) {
265
- // Native render unavailable (wiki up but route missing) → raw text.
266
- body = React.createElement('pre', { className: 'dsh-tw-toolcard-fallback' }, get.text)
267
- } else {
268
- body = React.createElement('div', { className: 'dsh-tw-toolcard-empty' }, 'wiki 服务不可用')
269
- }
270
-
271
- const tags = Array.isArray(get?.tags) ? (get.tags as unknown[]).filter((t): t is string => typeof t === 'string') : []
272
- const modified = typeof get?.modified === 'string' ? (get.modified as string) : undefined
273
-
274
- return React.createElement(
275
- ToolCardShell,
276
- {
277
- toolName: props.toolName,
278
- title,
279
- subtitle: props.subtitle,
280
- tags,
281
- onOpen: openTw(title),
282
- foot: modified !== undefined ? `修改于 ${modified}` : undefined,
283
- },
284
- body,
285
- )
286
- }
287
-
288
- /* ── list cards (search / recent / batch) ── */
289
-
290
- function HitRow(props: { title: string; tags?: readonly string[]; modified?: string | null; snippet?: string }): React.ReactElement {
291
- return React.createElement(
292
- 'a',
293
- {
294
- className: 'dsh-tw-toolcard-row',
295
- href: `${TW_PROXY_BASE}#${encodeURIComponent(props.title)}`,
296
- onClick: openTw(props.title),
297
- title: props.title,
298
- },
299
- React.createElement('span', { className: 'dsh-tw-toolcard-row-title' }, props.title),
300
- props.tags !== undefined && props.tags.length > 0
301
- ? React.createElement('span', { className: 'dsh-tw-toolcard-row-tags' }, props.tags.slice(0, 4).join(' · '))
302
- : null,
303
- typeof props.modified === 'string' && props.modified.length > 0
304
- ? React.createElement('span', { className: 'dsh-tw-toolcard-row-meta' }, props.modified)
305
- : null,
306
- )
307
- }
308
-
309
- function ListCard(props: {
310
- toolName: string
311
- title: string
312
- subtitle: string
313
- rows: readonly { title: string; tags?: readonly string[]; modified?: string | null; snippet?: string }[]
314
- fallbackText: string
315
- }): React.ReactElement {
316
- const rows = props.rows.slice(0, 60)
317
- let body: React.ReactNode
318
- if (rows.length === 0) {
319
- body = React.createElement('div', { className: 'dsh-tw-toolcard-empty' }, props.fallbackText)
320
- } else {
321
- body = React.createElement(
322
- 'div',
323
- { className: 'dsh-tw-toolcard-list' },
324
- ...rows.map((row, index) => React.createElement(HitRow, { key: `${row.title}-${index}`, ...row })),
325
- )
326
- }
327
- return React.createElement(ToolCardShell, { toolName: props.toolName, title: props.title, subtitle: props.subtitle }, body)
328
- }
329
-
330
- function SearchCard(props: { toolName: string; args: Record<string, unknown>; text: string }): React.ReactElement {
331
- const query = str(props.args.query)
332
- const params = new URLSearchParams()
333
- if (query.length > 0) params.set('query', query)
334
- if (Array.isArray(props.args.tags)) {
335
- for (const t of props.args.tags) if (typeof t === 'string' && t.length > 0) params.append('tags', t)
336
- }
337
- if (typeof props.args.tag === 'string' && props.args.tag.length > 0) params.set('tag', props.args.tag)
338
- if (typeof props.args.since === 'string' && props.args.since.length > 0) params.set('since', props.args.since)
339
- if (typeof props.args.type === 'string' && props.args.type.length > 0) params.set('type', props.args.type)
340
- if (typeof props.args.limit === 'number') params.set('limit', String(props.args.limit))
341
- const paramsKey = params.toString()
342
- const data = useAsync(() => fetchJson(`${SEARCH_ENDPOINT}?${paramsKey}`), [paramsKey])
343
- const payload = data.data
344
- const items = Array.isArray(payload?.items) ? (payload.items as Record<string, unknown>[]) : []
345
- const rows = items.map((item) => ({
346
- title: str(item.title),
347
- tags: Array.isArray(item.tags) ? (item.tags as unknown[]).filter((t): t is string => typeof t === 'string') : [],
348
- modified: typeof item.modified === 'string' ? (item.modified as string) : null,
349
- }))
350
- const total = typeof payload?.total === 'number' ? (payload.total as number) : undefined
351
- const subtitle = `关键词「${query || '(全部)'}」${total !== undefined ? ` · 共 ${total} 条` : ''}`
352
- return React.createElement(ListCard, {
353
- toolName: props.toolName,
354
- title: query.length > 0 ? query : '检索',
355
- subtitle,
356
- rows,
357
- fallbackText: '没有匹配的笔记',
358
- })
359
- }
360
-
361
- function RecentCard(props: { toolName: string; args: Record<string, unknown>; text: string }): React.ReactElement {
362
- const params = new URLSearchParams()
363
- if (typeof props.args.limit === 'number') params.set('limit', String(props.args.limit))
364
- if (typeof props.args.since === 'string' && props.args.since.length > 0) params.set('since', props.args.since)
365
- const paramsKey = params.toString()
366
- const data = useAsync(() => fetchJson(`${RECENT_ENDPOINT}?${paramsKey}`), [paramsKey])
367
- const payload = data.data
368
- const items = Array.isArray(payload?.items) ? (payload.items as Record<string, unknown>[]) : []
369
- const rows = items.map((item) => ({
370
- title: str(item.title),
371
- tags: Array.isArray(item.tags) ? (item.tags as unknown[]).filter((t): t is string => typeof t === 'string') : [],
372
- modified: typeof item.modified === 'string' ? (item.modified as string) : null,
373
- }))
374
- return React.createElement(ListCard, {
375
- toolName: props.toolName,
376
- title: '最近修改',
377
- subtitle: rows.length > 0 ? `最近 ${rows.length} 条` : '',
378
- rows,
379
- fallbackText: '暂无笔记',
380
- })
381
- }
382
-
383
- function BatchCard(props: { toolName: string; args: Record<string, unknown>; text: string }): React.ReactElement {
384
- const rawItems = Array.isArray(props.args.items) ? (props.args.items as unknown[]) : []
385
- const rows = rawItems
386
- .map((item): string | null => (typeof item === 'object' && item !== null && typeof (item as Record<string, unknown>).title === 'string' ? str((item as Record<string, unknown>).title) : null))
387
- .filter((t): t is string => t !== null && t.length > 0)
388
- .map((title) => ({ title }))
389
- return React.createElement(ListCard, {
390
- toolName: props.toolName,
391
- title: '批量写入',
392
- subtitle: rows.length > 0 ? `写入 ${rows.length} 篇` : '(参数中没有可解析的 items)',
393
- rows,
394
- fallbackText: '没有可展示的笔记',
395
- })
396
- }
397
-
398
- /* ── tags card ── */
399
-
400
- function TagsCard(props: { toolName: string; text: string }): React.ReactElement {
401
- const data = useAsync(() => fetchJson(TAGS_ENDPOINT), [])
402
- const payload = data.data
403
- const items = Array.isArray(payload?.items) ? (payload.items as Record<string, unknown>[]) : []
404
- const chips = items.map((item) => ({
405
- tag: str(item.tag),
406
- count: typeof item.count === 'number' ? (item.count as number) : 0,
407
- }))
408
- let body: React.ReactNode
409
- if (chips.length === 0) {
410
- body = React.createElement('div', { className: 'dsh-tw-toolcard-empty' }, '暂无标签')
411
- } else {
412
- body = React.createElement(
413
- 'div',
414
- { className: 'dsh-tw-toolcard-tags-wrap' },
415
- ...chips.map((chip) =>
416
- React.createElement(
417
- 'span',
418
- { className: 'dsh-tw-toolcard-tag', key: chip.tag },
419
- `${chip.tag} · ${chip.count}`,
420
- ),
421
- ),
422
- )
423
- }
424
- return React.createElement(ToolCardShell, { toolName: props.toolName, title: '标签', subtitle: `共 ${chips.length} 个` }, body)
425
- }
426
-
427
- /* ── git / delete cards (no native render — show the model-visible text) ── */
428
-
429
- function GitCard(props: { toolName: string; text: string }): React.ReactElement {
430
- const body = props.text.trim().length > 0
431
- ? React.createElement('pre', { className: 'dsh-tw-toolcard-fallback' }, props.text)
432
- : React.createElement('div', { className: 'dsh-tw-toolcard-empty' }, '(无结果)')
433
- return React.createElement(ToolCardShell, { toolName: props.toolName }, body)
434
- }
435
-
436
- function DeleteCard(props: { toolName: string; title: string; text: string }): React.ReactElement {
437
- const body = props.title.length > 0
438
- ? React.createElement('div', { className: 'dsh-tw-toolcard-empty' }, `已删除 tiddler「${props.title}」`)
439
- : React.createElement('pre', { className: 'dsh-tw-toolcard-fallback' }, props.text || '(已删除)')
440
- return React.createElement(ToolCardShell, { toolName: props.toolName, title: props.title.length > 0 ? props.title : undefined }, body)
441
- }
442
-
443
- /* ── top-level dispatcher ── */
444
-
445
- /** A title for the card header, where the tool has one. */
446
- function headerTitle(toolName: string, args: Record<string, unknown>): string {
447
- switch (toolName) {
448
- case 'tiddlywiki_get':
449
- case 'tiddlywiki_put':
450
- case 'tiddlywiki_delete':
451
- return str(args.title)
452
- case 'tiddlywiki_rename':
453
- return str(args.newTitle)
454
- case 'tiddlywiki_search':
455
- return str(args.query)
456
- case 'tiddlywiki_recent':
457
- case 'tiddlywiki_list_tags':
458
- case 'tiddlywiki_batch_put':
459
- case 'tiddlywiki_git_sync':
460
- case 'tiddlywiki_git_resolve':
461
- return ''
462
- default:
463
- return ''
464
- }
465
- }
466
-
467
- /** The one component registered under every `tiddlywiki_*` toolview key. */
468
- export function TiddlywikiToolView(props: ToolCallOwnerProps): React.ReactNode {
469
- const block = props.block
470
- const toolName = props.toolName
471
- const settled = isSettled(block)
472
- const call = callArgs(block)
473
- const name = toolName !== undefined && toolName.length > 0 ? toolName : call?.name ?? ''
474
- const args = parseArgs(call?.argsRaw ?? '')
475
- const text = settled ? contentText(block.content) : ''
476
-
477
- if (call === null) {
478
- return React.createElement(
479
- ToolCardShell,
480
- { toolName: name },
481
- React.createElement('pre', { className: 'dsh-tw-toolcard-fallback' }, text || '(无调用信息)'),
482
- )
483
- }
484
-
485
- if (!settled) {
486
- return React.createElement(
487
- ToolCardShell,
488
- { toolName: name, title: headerTitle(name, args) },
489
- React.createElement('div', { className: 'dsh-tw-toolcard-pending' }, '处理中…'),
490
- )
491
- }
492
-
493
- if (block.isError === true) {
494
- return React.createElement(
495
- ToolCardShell,
496
- { toolName: name, title: headerTitle(name, args) },
497
- React.createElement('div', { className: 'dsh-tw-toolcard-error' }, text || '工具调用失败'),
498
- )
499
- }
500
-
501
- switch (name) {
502
- case 'tiddlywiki_get':
503
- return React.createElement(TiddlerBodyCard, { toolName: name, title: str(args.title), subtitle: '读取' })
504
- case 'tiddlywiki_put':
505
- return React.createElement(TiddlerBodyCard, { toolName: name, title: str(args.title), subtitle: '已写入' })
506
- case 'tiddlywiki_rename':
507
- return React.createElement(TiddlerBodyCard, { toolName: name, title: str(args.newTitle), subtitle: `已重命名「${str(args.oldTitle)}」→` })
508
- case 'tiddlywiki_delete':
509
- return React.createElement(DeleteCard, { toolName: name, title: str(args.title), text })
510
- case 'tiddlywiki_batch_put':
511
- return React.createElement(BatchCard, { toolName: name, args, text })
512
- case 'tiddlywiki_search':
513
- return React.createElement(SearchCard, { toolName: name, args, text })
514
- case 'tiddlywiki_recent':
515
- return React.createElement(RecentCard, { toolName: name, args, text })
516
- case 'tiddlywiki_list_tags':
517
- return React.createElement(TagsCard, { toolName: name, text })
518
- case 'tiddlywiki_git_sync':
519
- case 'tiddlywiki_git_resolve':
520
- return React.createElement(GitCard, { toolName: name, text })
521
- default:
522
- return React.createElement(
523
- ToolCardShell,
524
- { toolName: name, title: headerTitle(name, args) },
525
- React.createElement('pre', { className: 'dsh-tw-toolcard-fallback' }, text || '(完成)'),
526
- )
527
- }
528
- }
529
-
530
- /** All `tiddlywiki_*` wire tool names the keyed slot should own. */
531
- export const TOOL_VIEW_KEYS: readonly string[] = [
532
- 'tiddlywiki_get',
533
- 'tiddlywiki_search',
534
- 'tiddlywiki_recent',
535
- 'tiddlywiki_list_tags',
536
- 'tiddlywiki_put',
537
- 'tiddlywiki_batch_put',
538
- 'tiddlywiki_rename',
539
- 'tiddlywiki_delete',
540
- 'tiddlywiki_git_sync',
541
- 'tiddlywiki_git_resolve',
542
- ]
543
-
544
- /**
545
- * Register the tool card under every key. Returns an array of disposers.
546
- *
547
- * `tool.call.toolview` is a CHILD slot (declared by an entry inside
548
- * `conversation.chat.node`), so a bare `slots.register` throws
549
- * `slot "tool.call.toolview" is not declared (a parent entry's children table
550
- * must declare it)` when it runs before the parent declaration. Each key must
551
- * therefore be wrapped in `slots.inject('tool.call.toolview', cb)`, which runs
552
- * `cb` synchronously when the declaration already exists and otherwise waits
553
- * for it — the same pattern `settings.section` uses.
554
- */
555
- export function registerToolViews(slots: {
556
- inject(key: string, callback: () => () => void): (() => void) | undefined
557
- register(opts: { name: string; id: string; key: string; order?: number; label?: string }, component: unknown): () => void
558
- }): Array<() => void> {
559
- const disposers: Array<() => void> = []
560
- for (const key of TOOL_VIEW_KEYS) {
561
- try {
562
- const remove = slots.inject('tool.call.toolview', () =>
563
- slots.register(
564
- { name: 'tool.call.toolview', id: `dsh-tiddlywiki-toolview-${key}`, key, order: 20, label: `TiddlyWiki: ${key}` },
565
- TiddlywikiToolView,
566
- ),
567
- )
568
- if (remove !== undefined) disposers.push(remove)
569
- } catch (error) {
570
- console.error(`[dsh-tiddlywiki] register toolview ${key} failed:`, error)
571
- }
572
- }
573
- return disposers
574
- }
575
-
576
- /**
577
- * Document-level click interceptor (capture, additive): any anchor whose href
578
- * is the same-origin TW proxy hash (`/dsh-tiddlywiki/tw/#<title>`) — the agent
579
- * convention `[标题](/dsh-tiddlywiki/tw/#标题)` and the links inside render
580
- * fragments — opens the center TW panel at that tiddler instead of navigating
581
- * the DSH page. Returns a disposer.
582
- */
583
- export function installWikiLinkInterceptor(): () => void {
584
- const onDocumentClick = (event: MouseEvent): void => {
585
- const target = event.target
586
- if (!(target instanceof Element)) return
587
- const anchor = target.closest('a')
588
- if (anchor === null) return
589
- const href = anchor.getAttribute('href') ?? ''
590
- const match = href.match(/^\/dsh-tiddlywiki\/tw\/#(.+)$/)
591
- if (match === null || match[1] === undefined) return
592
- event.preventDefault()
593
- event.stopPropagation()
594
- let title = match[1]
595
- try {
596
- title = decodeURIComponent(title)
597
- } catch {
598
- /* keep the raw hash when decoding fails */
599
- }
600
- openTiddler(title)
601
- }
602
- document.addEventListener('click', onDocumentClick, true)
603
- return () => document.removeEventListener('click', onDocumentClick, true)
604
- }
1
+ /**
2
+ * Reply-stream native TiddlyWiki tool views (design 「回复流原生 TW wiki」 ②③).
3
+ *
4
+ * Registers the `tool.call.toolview` KEYED slot (key = each `tiddlywiki_*`
5
+ * wire tool name, domain open — verified via Slots Inspect that none of our
6
+ * keys are taken) so a tool call's card in the conversation flow renders the
7
+ * wiki content in its NATIVE form:
8
+ *
9
+ * title (from block.call.argsRaw) → GET /dsh-tiddlywiki/get (full tiddler)
10
+ * → POST /dsh-tiddlywiki/tw/render (native
11
+ * HTML fragment, links rewritten to the
12
+ * same-origin proxy hash) → card body
13
+ *
14
+ * The whole component is plain React (`React.createElement`, no JSX — the
15
+ * client bundle is not transpiled); `react` is never bundled and resolves from
16
+ * the web app at runtime. The native fragment is injected via
17
+ * `dangerouslySetInnerHTML` — acceptable because the HTML comes from the LOCAL
18
+ * wiki (same trust as the embedded center-column editor).
19
+ *
20
+ * The render fragment + the agent's `[标题](/dsh-tiddlywiki/tw/#标题)` markdown
21
+ * links are handled by a document-level click interceptor → openTiddler()
22
+ * (open the center panel + set the iframe hash → TW native page).
23
+ *
24
+ * @module dsh-tiddlywiki/client/tool-views
25
+ */
26
+ import * as React from 'react'
27
+ import { openTiddler } from './panel.ts'
28
+
29
+ /** Same-origin TW proxy base (mirrors host TW_PROXY_PATH, client can't import it). */
30
+ const TW_PROXY_BASE = '/dsh-tiddlywiki/tw/'
31
+
32
+ const GET_ENDPOINT = '/dsh-tiddlywiki/get'
33
+ const SEARCH_ENDPOINT = '/dsh-tiddlywiki/search'
34
+ const RECENT_ENDPOINT = '/dsh-tiddlywiki/recent'
35
+ const TAGS_ENDPOINT = '/dsh-tiddlywiki/tags'
36
+ const RENDER_ENDPOINT = '/dsh-tiddlywiki/tw/render'
37
+
38
+ /** Chinese label for each tool (card badge). */
39
+ const TOOL_LABELS: Record<string, string> = {
40
+ tiddlywiki_get: '读取笔记',
41
+ tiddlywiki_search: '检索笔记',
42
+ tiddlywiki_recent: '最近修改',
43
+ tiddlywiki_list_tags: '标签列表',
44
+ tiddlywiki_put: '写入笔记',
45
+ tiddlywiki_batch_put: '批量写入',
46
+ tiddlywiki_rename: '重命名笔记',
47
+ tiddlywiki_delete: '删除笔记',
48
+ tiddlywiki_git_sync: 'git 同步',
49
+ tiddlywiki_git_resolve: 'git 冲突解决',
50
+ }
51
+
52
+ /* ── structural types (subset of dsh-client-ui-conversation records) ── */
53
+
54
+ interface CallHead {
55
+ name: string
56
+ argsRaw: string
57
+ }
58
+
59
+ interface RunningCallLike {
60
+ name?: string
61
+ argsRaw?: string
62
+ }
63
+
64
+ interface SettledLike {
65
+ kind?: string
66
+ call?: CallHead | null
67
+ content?: readonly ContentBlockLike[]
68
+ isError?: boolean
69
+ }
70
+
71
+ interface ContentBlockLike {
72
+ kind?: string
73
+ text?: unknown
74
+ }
75
+
76
+ /** Owner props the shell passes to a keyed tool view (verified via Inspect). */
77
+ interface ToolCallOwnerProps {
78
+ callId: string
79
+ toolName: string
80
+ block: RunningCallLike | SettledLike
81
+ cwd?: string
82
+ home?: string
83
+ openFile: (path: string) => void
84
+ inspect?: () => void
85
+ }
86
+
87
+ function isSettled(block: RunningCallLike | SettledLike): block is SettledLike {
88
+ return typeof block === 'object' && block !== null && (block as SettledLike).kind === 'tool-result'
89
+ }
90
+
91
+ /** The call identity: settled nodes carry it under `call`, running nodes top-level. */
92
+ function callArgs(block: RunningCallLike | SettledLike): CallHead | null {
93
+ if (typeof block !== 'object' || block === null) return null
94
+ const settled = block as SettledLike
95
+ if (typeof settled.call === 'object' && settled.call !== null) {
96
+ const c = settled.call as CallHead
97
+ if (typeof c.name === 'string' && typeof c.argsRaw === 'string') return c
98
+ }
99
+ const running = block as RunningCallLike
100
+ if (typeof running.name === 'string' && typeof running.argsRaw === 'string') {
101
+ return { name: running.name, argsRaw: running.argsRaw }
102
+ }
103
+ return null
104
+ }
105
+
106
+ function parseArgs(argsRaw: string): Record<string, unknown> {
107
+ try {
108
+ const parsed = JSON.parse(argsRaw) as unknown
109
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
110
+ } catch {
111
+ return {}
112
+ }
113
+ }
114
+
115
+ /** Model-visible rendered text of a settled node (fallback when service is down). */
116
+ function contentText(content: readonly ContentBlockLike[] | undefined): string {
117
+ if (!Array.isArray(content)) return ''
118
+ return content
119
+ .filter((b): b is ContentBlockLike & { text: string } => typeof b?.text === 'string')
120
+ .map((b) => b.text as string)
121
+ .join('\n')
122
+ }
123
+
124
+ function str(value: unknown): string {
125
+ return typeof value === 'string' ? value : ''
126
+ }
127
+
128
+ /* ── fetch helpers (same-origin JSON / fragment) ── */
129
+
130
+ async function fetchJson(url: string): Promise<Record<string, unknown> | null> {
131
+ try {
132
+ const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
133
+ if (!res.ok) return null
134
+ const data = (await res.json()) as unknown
135
+ return typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : null
136
+ } catch {
137
+ return null
138
+ }
139
+ }
140
+
141
+ async function fetchRender(title: string): Promise<string | null> {
142
+ try {
143
+ const res = await fetch(RENDER_ENDPOINT, {
144
+ method: 'POST',
145
+ // TW's server gates every POST behind the writer CSRF header
146
+ // (core-server/server.js: POST → "writers", requires X-Requested-With).
147
+ // Same-origin requests may set it freely; the header is what makes the
148
+ // request land instead of a 403.
149
+ headers: { 'content-type': 'application/json', 'x-requested-with': 'TiddlyWiki' },
150
+ body: JSON.stringify({ title }),
151
+ signal: AbortSignal.timeout(10_000),
152
+ })
153
+ if (!res.ok) return null
154
+ return await res.text()
155
+ } catch {
156
+ return null
157
+ }
158
+ }
159
+
160
+ /** Lightweight async-state hook for one loader keyed by `deps`. */
161
+ function useAsync<T>(factory: () => Promise<T | null>, deps: readonly unknown[]): { loading: boolean; data: T | null } {
162
+ const [state, setState] = React.useState<{ loading: boolean; data: T | null }>({ loading: true, data: null })
163
+ React.useEffect(() => {
164
+ let alive = true
165
+ setState({ loading: true, data: null })
166
+ Promise.resolve(factory()).then(
167
+ (data) => {
168
+ if (alive) setState({ loading: false, data })
169
+ },
170
+ () => {
171
+ if (alive) setState({ loading: false, data: null })
172
+ },
173
+ )
174
+ return () => {
175
+ alive = false
176
+ }
177
+ // factory identity is not a dep on purpose — callers pass stable closures
178
+ // eslint-disable-next-line react-hooks/exhaustive-deps
179
+ }, deps)
180
+ return state
181
+ }
182
+
183
+ /** Open a tiddler in the center TW panel (shared by rows + 「在 TW 打开」). */
184
+ function openTw(title: string): (event: React.MouseEvent) => void {
185
+ return (event) => {
186
+ event.preventDefault()
187
+ event.stopPropagation()
188
+ openTiddler(title)
189
+ }
190
+ }
191
+
192
+ /* ── shared card shell ── */
193
+
194
+ function ToolCardShell(props: {
195
+ toolName: string
196
+ title?: string
197
+ subtitle?: string
198
+ tags?: readonly string[]
199
+ onOpen?: (event: React.MouseEvent) => void
200
+ foot?: React.ReactNode
201
+ children?: React.ReactNode
202
+ }): React.ReactElement {
203
+ const label = TOOL_LABELS[props.toolName] ?? props.toolName
204
+ const head = React.createElement(
205
+ 'div',
206
+ { className: 'dsh-tw-toolcard-head' },
207
+ React.createElement('span', { className: 'dsh-tw-toolcard-badge' }, label),
208
+ props.title !== undefined && props.title.length > 0
209
+ ? React.createElement('span', { className: 'dsh-tw-toolcard-title', title: props.title }, props.title)
210
+ : null,
211
+ props.onOpen !== undefined
212
+ ? React.createElement('button', { type: 'button', className: 'dsh-tw-toolcard-open', onClick: props.onOpen }, '在 TW 打开')
213
+ : null,
214
+ )
215
+ const meta = props.subtitle !== undefined || (props.tags !== undefined && props.tags.length > 0)
216
+ ? React.createElement(
217
+ 'div',
218
+ { className: 'dsh-tw-toolcard-meta' },
219
+ props.subtitle !== undefined ? React.createElement('span', { className: 'dsh-tw-toolcard-sub' }, props.subtitle) : null,
220
+ props.tags !== undefined && props.tags.length > 0
221
+ ? React.createElement(
222
+ 'span',
223
+ { className: 'dsh-tw-toolcard-tags' },
224
+ ...props.tags.map((tag) => React.createElement('span', { className: 'dsh-tw-toolcard-tag', key: tag }, tag)),
225
+ )
226
+ : null,
227
+ )
228
+ : null
229
+ return React.createElement(
230
+ 'div',
231
+ { className: 'dsh-tw-toolcard' },
232
+ head,
233
+ meta,
234
+ React.createElement('div', { className: 'dsh-tw-toolcard-body' }, props.children),
235
+ props.foot !== undefined && props.foot !== null ? React.createElement('div', { className: 'dsh-tw-toolcard-foot' }, props.foot) : null,
236
+ )
237
+ }
238
+
239
+ /* ── tiddler card (get / put / rename): native-rendered body ── */
240
+
241
+ function TiddlerBodyCard(props: { toolName: string; title: string; subtitle: string }): React.ReactElement {
242
+ const { title } = props
243
+ const both = useAsync(
244
+ async () => {
245
+ const [get, html] = await Promise.all([fetchJson(`${GET_ENDPOINT}?title=${encodeURIComponent(title)}`), fetchRender(title)])
246
+ return { get, html }
247
+ },
248
+ [title],
249
+ )
250
+ const { loading, data } = both
251
+ const get = data?.get ?? null
252
+ const html = data?.html ?? null
253
+
254
+ let body: React.ReactNode
255
+ if (loading) {
256
+ body = React.createElement('div', { className: 'dsh-tw-toolcard-loading' }, '渲染中…')
257
+ } else if (get !== null && get.notFound === true) {
258
+ body = React.createElement('div', { className: 'dsh-tw-toolcard-empty' }, `tiddler「${title}」不存在`)
259
+ } else if (typeof html === 'string' && html.length > 0) {
260
+ body = React.createElement('div', {
261
+ className: 'dsh-tw-toolcard-native',
262
+ dangerouslySetInnerHTML: { __html: html },
263
+ })
264
+ } else if (get !== null && typeof get.text === 'string' && get.text.length > 0) {
265
+ // Native render unavailable (wiki up but route missing) → raw text.
266
+ body = React.createElement('pre', { className: 'dsh-tw-toolcard-fallback' }, get.text)
267
+ } else {
268
+ body = React.createElement('div', { className: 'dsh-tw-toolcard-empty' }, 'wiki 服务不可用')
269
+ }
270
+
271
+ const tags = Array.isArray(get?.tags) ? (get.tags as unknown[]).filter((t): t is string => typeof t === 'string') : []
272
+ const modified = typeof get?.modified === 'string' ? (get.modified as string) : undefined
273
+
274
+ return React.createElement(
275
+ ToolCardShell,
276
+ {
277
+ toolName: props.toolName,
278
+ title,
279
+ subtitle: props.subtitle,
280
+ tags,
281
+ onOpen: openTw(title),
282
+ foot: modified !== undefined ? `修改于 ${modified}` : undefined,
283
+ },
284
+ body,
285
+ )
286
+ }
287
+
288
+ /* ── list cards (search / recent / batch) ── */
289
+
290
+ function HitRow(props: { title: string; tags?: readonly string[]; modified?: string | null; snippet?: string }): React.ReactElement {
291
+ return React.createElement(
292
+ 'a',
293
+ {
294
+ className: 'dsh-tw-toolcard-row',
295
+ href: `${TW_PROXY_BASE}#${encodeURIComponent(props.title)}`,
296
+ onClick: openTw(props.title),
297
+ title: props.title,
298
+ },
299
+ React.createElement('span', { className: 'dsh-tw-toolcard-row-title' }, props.title),
300
+ props.tags !== undefined && props.tags.length > 0
301
+ ? React.createElement('span', { className: 'dsh-tw-toolcard-row-tags' }, props.tags.slice(0, 4).join(' · '))
302
+ : null,
303
+ typeof props.modified === 'string' && props.modified.length > 0
304
+ ? React.createElement('span', { className: 'dsh-tw-toolcard-row-meta' }, props.modified)
305
+ : null,
306
+ )
307
+ }
308
+
309
+ function ListCard(props: {
310
+ toolName: string
311
+ title: string
312
+ subtitle: string
313
+ rows: readonly { title: string; tags?: readonly string[]; modified?: string | null; snippet?: string }[]
314
+ fallbackText: string
315
+ }): React.ReactElement {
316
+ const rows = props.rows.slice(0, 60)
317
+ let body: React.ReactNode
318
+ if (rows.length === 0) {
319
+ body = React.createElement('div', { className: 'dsh-tw-toolcard-empty' }, props.fallbackText)
320
+ } else {
321
+ body = React.createElement(
322
+ 'div',
323
+ { className: 'dsh-tw-toolcard-list' },
324
+ ...rows.map((row, index) => React.createElement(HitRow, { key: `${row.title}-${index}`, ...row })),
325
+ )
326
+ }
327
+ return React.createElement(ToolCardShell, { toolName: props.toolName, title: props.title, subtitle: props.subtitle }, body)
328
+ }
329
+
330
+ function SearchCard(props: { toolName: string; args: Record<string, unknown>; text: string }): React.ReactElement {
331
+ const query = str(props.args.query)
332
+ const params = new URLSearchParams()
333
+ if (query.length > 0) params.set('query', query)
334
+ if (Array.isArray(props.args.tags)) {
335
+ for (const t of props.args.tags) if (typeof t === 'string' && t.length > 0) params.append('tags', t)
336
+ }
337
+ if (typeof props.args.tag === 'string' && props.args.tag.length > 0) params.set('tag', props.args.tag)
338
+ if (typeof props.args.since === 'string' && props.args.since.length > 0) params.set('since', props.args.since)
339
+ if (typeof props.args.type === 'string' && props.args.type.length > 0) params.set('type', props.args.type)
340
+ if (typeof props.args.limit === 'number') params.set('limit', String(props.args.limit))
341
+ const paramsKey = params.toString()
342
+ const data = useAsync(() => fetchJson(`${SEARCH_ENDPOINT}?${paramsKey}`), [paramsKey])
343
+ const payload = data.data
344
+ const items = Array.isArray(payload?.items) ? (payload.items as Record<string, unknown>[]) : []
345
+ const rows = items.map((item) => ({
346
+ title: str(item.title),
347
+ tags: Array.isArray(item.tags) ? (item.tags as unknown[]).filter((t): t is string => typeof t === 'string') : [],
348
+ modified: typeof item.modified === 'string' ? (item.modified as string) : null,
349
+ }))
350
+ const total = typeof payload?.total === 'number' ? (payload.total as number) : undefined
351
+ const subtitle = `关键词「${query || '(全部)'}」${total !== undefined ? ` · 共 ${total} 条` : ''}`
352
+ return React.createElement(ListCard, {
353
+ toolName: props.toolName,
354
+ title: query.length > 0 ? query : '检索',
355
+ subtitle,
356
+ rows,
357
+ fallbackText: '没有匹配的笔记',
358
+ })
359
+ }
360
+
361
+ function RecentCard(props: { toolName: string; args: Record<string, unknown>; text: string }): React.ReactElement {
362
+ const params = new URLSearchParams()
363
+ if (typeof props.args.limit === 'number') params.set('limit', String(props.args.limit))
364
+ if (typeof props.args.since === 'string' && props.args.since.length > 0) params.set('since', props.args.since)
365
+ const paramsKey = params.toString()
366
+ const data = useAsync(() => fetchJson(`${RECENT_ENDPOINT}?${paramsKey}`), [paramsKey])
367
+ const payload = data.data
368
+ const items = Array.isArray(payload?.items) ? (payload.items as Record<string, unknown>[]) : []
369
+ const rows = items.map((item) => ({
370
+ title: str(item.title),
371
+ tags: Array.isArray(item.tags) ? (item.tags as unknown[]).filter((t): t is string => typeof t === 'string') : [],
372
+ modified: typeof item.modified === 'string' ? (item.modified as string) : null,
373
+ }))
374
+ return React.createElement(ListCard, {
375
+ toolName: props.toolName,
376
+ title: '最近修改',
377
+ subtitle: rows.length > 0 ? `最近 ${rows.length} 条` : '',
378
+ rows,
379
+ fallbackText: '暂无笔记',
380
+ })
381
+ }
382
+
383
+ function BatchCard(props: { toolName: string; args: Record<string, unknown>; text: string }): React.ReactElement {
384
+ const rawItems = Array.isArray(props.args.items) ? (props.args.items as unknown[]) : []
385
+ const rows = rawItems
386
+ .map((item): string | null => (typeof item === 'object' && item !== null && typeof (item as Record<string, unknown>).title === 'string' ? str((item as Record<string, unknown>).title) : null))
387
+ .filter((t): t is string => t !== null && t.length > 0)
388
+ .map((title) => ({ title }))
389
+ return React.createElement(ListCard, {
390
+ toolName: props.toolName,
391
+ title: '批量写入',
392
+ subtitle: rows.length > 0 ? `写入 ${rows.length} 篇` : '(参数中没有可解析的 items)',
393
+ rows,
394
+ fallbackText: '没有可展示的笔记',
395
+ })
396
+ }
397
+
398
+ /* ── tags card ── */
399
+
400
+ function TagsCard(props: { toolName: string; text: string }): React.ReactElement {
401
+ const data = useAsync(() => fetchJson(TAGS_ENDPOINT), [])
402
+ const payload = data.data
403
+ const items = Array.isArray(payload?.items) ? (payload.items as Record<string, unknown>[]) : []
404
+ const chips = items.map((item) => ({
405
+ tag: str(item.tag),
406
+ count: typeof item.count === 'number' ? (item.count as number) : 0,
407
+ }))
408
+ let body: React.ReactNode
409
+ if (chips.length === 0) {
410
+ body = React.createElement('div', { className: 'dsh-tw-toolcard-empty' }, '暂无标签')
411
+ } else {
412
+ body = React.createElement(
413
+ 'div',
414
+ { className: 'dsh-tw-toolcard-tags-wrap' },
415
+ ...chips.map((chip) =>
416
+ React.createElement(
417
+ 'span',
418
+ { className: 'dsh-tw-toolcard-tag', key: chip.tag },
419
+ `${chip.tag} · ${chip.count}`,
420
+ ),
421
+ ),
422
+ )
423
+ }
424
+ return React.createElement(ToolCardShell, { toolName: props.toolName, title: '标签', subtitle: `共 ${chips.length} 个` }, body)
425
+ }
426
+
427
+ /* ── git / delete cards (no native render — show the model-visible text) ── */
428
+
429
+ function GitCard(props: { toolName: string; text: string }): React.ReactElement {
430
+ const body = props.text.trim().length > 0
431
+ ? React.createElement('pre', { className: 'dsh-tw-toolcard-fallback' }, props.text)
432
+ : React.createElement('div', { className: 'dsh-tw-toolcard-empty' }, '(无结果)')
433
+ return React.createElement(ToolCardShell, { toolName: props.toolName }, body)
434
+ }
435
+
436
+ function DeleteCard(props: { toolName: string; title: string; text: string }): React.ReactElement {
437
+ const body = props.title.length > 0
438
+ ? React.createElement('div', { className: 'dsh-tw-toolcard-empty' }, `已删除 tiddler「${props.title}」`)
439
+ : React.createElement('pre', { className: 'dsh-tw-toolcard-fallback' }, props.text || '(已删除)')
440
+ return React.createElement(ToolCardShell, { toolName: props.toolName, title: props.title.length > 0 ? props.title : undefined }, body)
441
+ }
442
+
443
+ /* ── top-level dispatcher ── */
444
+
445
+ /** A title for the card header, where the tool has one. */
446
+ function headerTitle(toolName: string, args: Record<string, unknown>): string {
447
+ switch (toolName) {
448
+ case 'tiddlywiki_get':
449
+ case 'tiddlywiki_put':
450
+ case 'tiddlywiki_delete':
451
+ return str(args.title)
452
+ case 'tiddlywiki_rename':
453
+ return str(args.newTitle)
454
+ case 'tiddlywiki_search':
455
+ return str(args.query)
456
+ case 'tiddlywiki_recent':
457
+ case 'tiddlywiki_list_tags':
458
+ case 'tiddlywiki_batch_put':
459
+ case 'tiddlywiki_git_sync':
460
+ case 'tiddlywiki_git_resolve':
461
+ return ''
462
+ default:
463
+ return ''
464
+ }
465
+ }
466
+
467
+ /** The one component registered under every `tiddlywiki_*` toolview key. */
468
+ export function TiddlywikiToolView(props: ToolCallOwnerProps): React.ReactNode {
469
+ const block = props.block
470
+ const toolName = props.toolName
471
+ const settled = isSettled(block)
472
+ const call = callArgs(block)
473
+ const name = toolName !== undefined && toolName.length > 0 ? toolName : call?.name ?? ''
474
+ const args = parseArgs(call?.argsRaw ?? '')
475
+ const text = settled ? contentText(block.content) : ''
476
+
477
+ if (call === null) {
478
+ return React.createElement(
479
+ ToolCardShell,
480
+ { toolName: name },
481
+ React.createElement('pre', { className: 'dsh-tw-toolcard-fallback' }, text || '(无调用信息)'),
482
+ )
483
+ }
484
+
485
+ if (!settled) {
486
+ return React.createElement(
487
+ ToolCardShell,
488
+ { toolName: name, title: headerTitle(name, args) },
489
+ React.createElement('div', { className: 'dsh-tw-toolcard-pending' }, '处理中…'),
490
+ )
491
+ }
492
+
493
+ if (block.isError === true) {
494
+ return React.createElement(
495
+ ToolCardShell,
496
+ { toolName: name, title: headerTitle(name, args) },
497
+ React.createElement('div', { className: 'dsh-tw-toolcard-error' }, text || '工具调用失败'),
498
+ )
499
+ }
500
+
501
+ switch (name) {
502
+ case 'tiddlywiki_get':
503
+ return React.createElement(TiddlerBodyCard, { toolName: name, title: str(args.title), subtitle: '读取' })
504
+ case 'tiddlywiki_put':
505
+ return React.createElement(TiddlerBodyCard, { toolName: name, title: str(args.title), subtitle: '已写入' })
506
+ case 'tiddlywiki_rename':
507
+ return React.createElement(TiddlerBodyCard, { toolName: name, title: str(args.newTitle), subtitle: `已重命名「${str(args.oldTitle)}」→` })
508
+ case 'tiddlywiki_delete':
509
+ return React.createElement(DeleteCard, { toolName: name, title: str(args.title), text })
510
+ case 'tiddlywiki_batch_put':
511
+ return React.createElement(BatchCard, { toolName: name, args, text })
512
+ case 'tiddlywiki_search':
513
+ return React.createElement(SearchCard, { toolName: name, args, text })
514
+ case 'tiddlywiki_recent':
515
+ return React.createElement(RecentCard, { toolName: name, args, text })
516
+ case 'tiddlywiki_list_tags':
517
+ return React.createElement(TagsCard, { toolName: name, text })
518
+ case 'tiddlywiki_git_sync':
519
+ case 'tiddlywiki_git_resolve':
520
+ return React.createElement(GitCard, { toolName: name, text })
521
+ default:
522
+ return React.createElement(
523
+ ToolCardShell,
524
+ { toolName: name, title: headerTitle(name, args) },
525
+ React.createElement('pre', { className: 'dsh-tw-toolcard-fallback' }, text || '(完成)'),
526
+ )
527
+ }
528
+ }
529
+
530
+ /** All `tiddlywiki_*` wire tool names the keyed slot should own. */
531
+ export const TOOL_VIEW_KEYS: readonly string[] = [
532
+ 'tiddlywiki_get',
533
+ 'tiddlywiki_search',
534
+ 'tiddlywiki_recent',
535
+ 'tiddlywiki_list_tags',
536
+ 'tiddlywiki_put',
537
+ 'tiddlywiki_batch_put',
538
+ 'tiddlywiki_rename',
539
+ 'tiddlywiki_delete',
540
+ 'tiddlywiki_git_sync',
541
+ 'tiddlywiki_git_resolve',
542
+ ]
543
+
544
+ /**
545
+ * Register the tool card under every key. Returns an array of disposers.
546
+ *
547
+ * `tool.call.toolview` is a CHILD slot (declared by an entry inside
548
+ * `conversation.chat.node`), so a bare `slots.register` throws
549
+ * `slot "tool.call.toolview" is not declared (a parent entry's children table
550
+ * must declare it)` when it runs before the parent declaration. Each key must
551
+ * therefore be wrapped in `slots.inject('tool.call.toolview', cb)`, which runs
552
+ * `cb` synchronously when the declaration already exists and otherwise waits
553
+ * for it — the same pattern `settings.section` uses.
554
+ */
555
+ export function registerToolViews(slots: {
556
+ inject(key: string, callback: () => () => void): (() => void) | undefined
557
+ register(opts: { name: string; id: string; key: string; order?: number; label?: string }, component: unknown): () => void
558
+ }): Array<() => void> {
559
+ const disposers: Array<() => void> = []
560
+ for (const key of TOOL_VIEW_KEYS) {
561
+ try {
562
+ const remove = slots.inject('tool.call.toolview', () =>
563
+ slots.register(
564
+ { name: 'tool.call.toolview', id: `dsh-tiddlywiki-toolview-${key}`, key, order: 20, label: `TiddlyWiki: ${key}` },
565
+ TiddlywikiToolView,
566
+ ),
567
+ )
568
+ if (remove !== undefined) disposers.push(remove)
569
+ } catch (error) {
570
+ console.error(`[dsh-tiddlywiki] register toolview ${key} failed:`, error)
571
+ }
572
+ }
573
+ return disposers
574
+ }
575
+
576
+ /**
577
+ * Document-level click interceptor (capture, additive): any anchor whose href
578
+ * is the same-origin TW proxy hash (`/dsh-tiddlywiki/tw/#<title>`) — the agent
579
+ * convention `[标题](/dsh-tiddlywiki/tw/#标题)` and the links inside render
580
+ * fragments — opens the center TW panel at that tiddler instead of navigating
581
+ * the DSH page. Returns a disposer.
582
+ */
583
+ export function installWikiLinkInterceptor(): () => void {
584
+ const onDocumentClick = (event: MouseEvent): void => {
585
+ const target = event.target
586
+ if (!(target instanceof Element)) return
587
+ const anchor = target.closest('a')
588
+ if (anchor === null) return
589
+ const href = anchor.getAttribute('href') ?? ''
590
+ const match = href.match(/^\/dsh-tiddlywiki\/tw\/#(.+)$/)
591
+ if (match === null || match[1] === undefined) return
592
+ event.preventDefault()
593
+ event.stopPropagation()
594
+ let title = match[1]
595
+ try {
596
+ title = decodeURIComponent(title)
597
+ } catch {
598
+ /* keep the raw hash when decoding fails */
599
+ }
600
+ openTiddler(title)
601
+ }
602
+ document.addEventListener('click', onDocumentClick, true)
603
+ return () => document.removeEventListener('click', onDocumentClick, true)
604
+ }