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.
- package/LICENSE +21 -21
- package/README.md +8 -1
- package/cordis.patch.yml +13 -13
- package/lib/client.bundle.js +53 -26
- package/lib/client.js +53 -26
- package/lib/index.js +136 -25
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/endpoints.ts +10 -10
- package/src/client/index.ts +36 -0
- package/src/client/markdown-editor.ts +128 -128
- package/src/client/panel.ts +4 -0
- package/src/client/rightbar-tab.ts +440 -0
- package/src/client/session-summary.ts +279 -279
- package/src/client/settings-page.ts +1 -0
- package/src/client/state.ts +39 -39
- package/src/client/styles.ts +27 -0
- package/src/client/theme-sync.ts +312 -312
- package/src/client/toast.ts +22 -22
- package/src/client/tool-views.ts +604 -604
- package/src/client/ui-config.ts +64 -61
- package/src/host/config.ts +7 -0
- package/src/host/http.ts +57 -57
- package/src/host/routes.ts +2 -0
- package/src/host/seed-home.ts +1 -1
- package/src/host/seed-render.ts +66 -66
- package/src/host/seed-send-to-agent.ts +63 -63
- package/src/host/tools.ts +40 -5
- package/src/host/tw-api.ts +108 -16
- package/src/host/wiki.ts +299 -299
- package/src/index.ts +9 -8
- package/src/sdk.ts +204 -204
package/src/client/tool-views.ts
CHANGED
|
@@ -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
|
+
}
|