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,279 +1,279 @@
1
- /**
2
- * 会话顶部「知识库」Tab(conversation.view 槽位,id `dsh-tiddlywiki-summary`)——
3
- * 把当前会话相关的 wiki 笔记汇总以 TW 原生渲染展示。
4
- *
5
- * 汇总内容由 Host 路由 POST /dsh-tiddlywiki/session/summary 生成:后端用
6
- * sessionQuery 读本会话(含后代 subagent)的事件日志,按「产生/读取/检索」收集
7
- * tiddlywiki_* 笔记,组装 TW wikitext 写入 volatile 的
8
- * `$:/temp/dsh/session-summary/<会话ID>`(不落盘、不进 git),见
9
- * src/host/session-summary.ts。
10
- *
11
- * 渲染(v0.16.19 修掉的坑——为什么不能走 iframe + TW story view):TW 5.4.1 核心
12
- * 的视图模板级联($:/config/ViewTemplateBodyFilters/system 的 system 规则,multids
13
- * 展开为独立 tiddler)把一切 `$:/temp/` 前缀 tiddler 一律按「代码块」渲染
14
- * ($:/core/ui/ViewTemplate/body/code → <pre><code>)——已用 headless $tw 实测:
15
- * 对 `$:/temp/dsh/session-summary/…` 渲染 body 级联得到 `<pre><code>…</code></pre>`
16
- * 而非 `<h1>`。因此**无论**是 v0.16.11–13 的「hash 直达」(浏览器端 TW 同步天生
17
- * 排除 `$:/temp`,只出「佚失条目」)还是 v0.16.14–18 的「注入 iframe store +
18
- * 原生 hash 导航」(条目进了浏览器 store 后,story view 仍被 system 级联规则按
19
- * 代码块展示),汇总都呈现为「整篇 wikitext 源码、像包在代码标签里」——这正是
20
- * 用户看到的现象。修复:客户端**不再用 iframe / story view**,改走与回复流工具卡
21
- * 同一条原生渲染管线——`POST /tw/render { title }`(服务端 renderText 把 wikitext
22
- * 块解析成 HTML 片段,内部 [[链接]] 重写为同源代理 hash /dsh-tiddlywiki/tw/#标题)
23
- * → dangerouslySetInnerHTML 注入滚动容器;主题与样式复用 .dsh-tw-toolcard-native
24
- * 的 tc-* 重主题(与工具卡视觉一致);片段内链接由全局 wiki-link 拦截器打开中央
25
- * TW 面板。无 iframe → 无 story view → 无编辑按钮/草稿(v0.16.16 的三层防误编辑
26
- * 机制随之不再需要)。
27
- *
28
- * 槽位注册(探查定稿):`conversation.view` kind=list scope=session replaceRisk=none,
29
- * 官方留给第三方扩展;用新 id = 在「对话 | 轨迹」旁新增 tab(order 20),label 用
30
- * thunk 跟随 `ui.tabLabel` 配置(默认「知识库」)。进入 tab 自动触发生成,顶栏提供
31
- * 手动「刷新」。
32
- *
33
- * @module dsh-tiddlywiki/client/session-summary
34
- */
35
- import * as React from 'react'
36
- import { GET_ENDPOINT } from './endpoints.ts'
37
-
38
- export const SESSION_SUMMARY_VIEW_ID = 'dsh-tiddlywiki-summary'
39
- const SUMMARY_ENDPOINT = '/dsh-tiddlywiki/session/summary'
40
- const RENDER_ENDPOINT = '/dsh-tiddlywiki/tw/render'
41
- const SESSION_SUMMARY_LABEL_DEFAULT = '知识库'
42
- /** 自愈探测周期:服务端 volatile 条目被清(TW 重启)→ 自动重新生成。 */
43
- const SELF_HEAL_MS = 30_000
44
- /** 连续多少次「生成后服务端仍缺失」后停止自动重试,交还手动「🔄 刷新」。 */
45
- const MAX_MISSES = 3
46
-
47
- /** Tab 显示名缓存:客户端从 /status 读到 `ui.tabLabel` 后更新;默认「知识库」。 */
48
- let tabLabel = SESSION_SUMMARY_LABEL_DEFAULT
49
- export function setSessionSummaryTabLabel(label: string): void {
50
- tabLabel = typeof label === 'string' && label.trim().length > 0 ? label.trim() : SESSION_SUMMARY_LABEL_DEFAULT
51
- }
52
-
53
- /** 槽位 label thunk:每次投影重读,配置变更即时生效。 */
54
- function labelThunk(): string {
55
- return tabLabel
56
- }
57
-
58
- /** conversation.view 标准 props(scope=session)的极小子集 + ownerProps。 */
59
- interface SessionSummaryViewProps {
60
- sessionId?: string
61
- /** ownerProps:一次性 focus 请求(本视图无子 focus 目标,直接确认)。 */
62
- viewRequest?: { view?: string; focus?: string } | null
63
- completeViewRequest?: () => void
64
- }
65
-
66
- /** POST /tw/render:把汇总 tiddler 的 wikitext 块解析成原生 HTML 片段(失败返回 null)。 */
67
- async function fetchRenderFragment(title: string): Promise<string | null> {
68
- try {
69
- const res = await fetch(RENDER_ENDPOINT, {
70
- method: 'POST',
71
- // TW 服务器对 POST 一律走 writers + CSRF(X-Requested-With)门禁,与
72
- // tool-views 相同;同源请求可自由携带该头,缺了会 403。
73
- headers: { 'content-type': 'application/json', 'x-requested-with': 'TiddlyWiki' },
74
- body: JSON.stringify({ title }),
75
- signal: AbortSignal.timeout(15_000),
76
- })
77
- if (!res.ok) return null
78
- const text = await res.text()
79
- return text.length > 0 ? text : null
80
- } catch {
81
- return null
82
- }
83
- }
84
-
85
- /** GET /get:确认 volatile 汇总条目是否还在(404/notFound = 已被清掉)。 */
86
- async function tiddlerExists(title: string): Promise<boolean> {
87
- try {
88
- const res = await fetch(`${GET_ENDPOINT}?title=${encodeURIComponent(title)}`, { signal: AbortSignal.timeout(8_000) })
89
- if (res.status === 404) return false
90
- const data = (await res.json().catch(() => null)) as { notFound?: boolean } | null
91
- return data?.notFound !== true
92
- } catch {
93
- return true // 查询失败时不误判为缺失,交由自愈定时器处理
94
- }
95
- }
96
-
97
- /** View 组件:生成汇总 → /tw/render 原生片段;顶栏带手动刷新。 */
98
- function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement {
99
- const { sessionId, viewRequest, completeViewRequest } = props
100
- const [phase, setPhase] = React.useState<'loading' | 'ready' | 'error'>('loading')
101
- const [html, setHtml] = React.useState<string | null>(null)
102
- const [summaryTitle, setSummaryTitle] = React.useState<string | null>(null)
103
- const [error, setError] = React.useState('')
104
- const genRef = React.useRef(0)
105
- const missesRef = React.useRef(0)
106
- /** 渲染失败且条目缺失时自动重建一次的有界开关(防循环)。 */
107
- const autoRetriedRef = React.useRef(false)
108
-
109
- const generate = React.useCallback(async (): Promise<void> => {
110
- const gen = ++genRef.current
111
- setPhase('loading')
112
- if (typeof sessionId !== 'string' || sessionId.length === 0) {
113
- setPhase('error')
114
- setError('缺少会话 ID')
115
- return
116
- }
117
- try {
118
- const res = await fetch(SUMMARY_ENDPOINT, {
119
- method: 'POST',
120
- headers: { 'content-type': 'application/json' },
121
- body: JSON.stringify({ session: sessionId }),
122
- signal: AbortSignal.timeout(25_000),
123
- })
124
- const data = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; error?: string } | null
125
- if (gen !== genRef.current) return
126
- if (!res.ok || data?.ok !== true) {
127
- setPhase('error')
128
- setError(data?.error ?? `HTTP ${res.status}`)
129
- return
130
- }
131
- if (typeof data.title !== 'string' || data.title.length === 0) {
132
- setPhase('error')
133
- setError('汇总生成失败:缺少标题')
134
- return
135
- }
136
- setSummaryTitle(data.title)
137
- // 原生渲染:wikitext → HTML 片段(块解析,标题/表格/列表/引用齐全,
138
- // 链接已重写为 /dsh-tiddlywiki/tw/#标题,点击由全局拦截器接管)。
139
- const fragment = await fetchRenderFragment(data.title)
140
- if (gen !== genRef.current) return
141
- if (fragment === null) {
142
- // 区分「条目被清(TW 重启把 $:/temp 冲掉了)」与「渲染服务不可用」:
143
- // 前者自动重建一次(有界),后者直接报错交还手动重试。
144
- const exists = await tiddlerExists(data.title)
145
- if (gen !== genRef.current) return
146
- if (!exists) {
147
- if (autoRetriedRef.current) {
148
- setPhase('error')
149
- setError('汇总条目不存在,自动重建失败,请重试')
150
- return
151
- }
152
- autoRetriedRef.current = true
153
- missesRef.current++
154
- void generate()
155
- return
156
- }
157
- setPhase('error')
158
- setError('渲染失败:wiki 渲染服务不可用(/tw/render)')
159
- return
160
- }
161
- autoRetriedRef.current = false
162
- missesRef.current = 0
163
- setHtml(fragment)
164
- setPhase('ready')
165
- } catch (err) {
166
- if (gen !== genRef.current) return
167
- setPhase('error')
168
- setError(err instanceof Error ? err.message : String(err))
169
- }
170
- }, [sessionId])
171
-
172
- // 进入 tab 自动触发生成;切走再切回会重新挂载 → 再次生成(总是最新)。
173
- React.useEffect(() => {
174
- void generate()
175
- }, [generate])
176
-
177
- // 自愈:汇总条目是 volatile 的 `$:/temp`,TW 重启即消失;若已就绪但条目
178
- // 被清掉(/get 404),自动重新生成(含重新渲染)。连续 MAX_MISSES 次仍缺失
179
- // (例如服务端写不进去)就停止自动重试,交还手动「🔄 刷新」。
180
- React.useEffect(() => {
181
- if (phase !== 'ready' || summaryTitle === null) return
182
- const timer = window.setInterval(() => {
183
- void (async () => {
184
- let serverHas = false
185
- try {
186
- const res = await fetch(`${GET_ENDPOINT}?title=${encodeURIComponent(summaryTitle)}`, { signal: AbortSignal.timeout(8_000) })
187
- const data = (await res.json().catch(() => null)) as { notFound?: boolean } | null
188
- serverHas = res.status !== 404 && data?.notFound !== true
189
- } catch {
190
- return // 探测失败保持现状,下个周期再试
191
- }
192
- if (!serverHas) {
193
- missesRef.current++
194
- if (missesRef.current < MAX_MISSES) void generate()
195
- } else {
196
- missesRef.current = 0
197
- }
198
- })()
199
- }, SELF_HEAL_MS)
200
- return () => window.clearInterval(timer)
201
- }, [phase, summaryTitle, generate])
202
-
203
- // 一次性 focus 请求直接确认(本视图无可聚焦子目标,避免 shell 挂起)。
204
- React.useEffect(() => {
205
- if (viewRequest !== null && viewRequest !== undefined) completeViewRequest?.()
206
- }, [viewRequest, completeViewRequest])
207
-
208
- const refresh = (): void => {
209
- void generate()
210
- }
211
-
212
- let content: React.ReactNode
213
- if (phase === 'loading') {
214
- content = React.createElement(
215
- 'div',
216
- { className: 'dsh-tw-summary-state' },
217
- React.createElement('div', { className: 'dsh-tw-summary-state-spin' }, '⏳'),
218
- React.createElement('div', null, '正在生成会话 wiki 汇总…'),
219
- )
220
- } else if (phase === 'error') {
221
- content = React.createElement(
222
- 'div',
223
- { className: 'dsh-tw-summary-state' },
224
- React.createElement('div', { className: 'dsh-tw-summary-state-title' }, '知识库汇总暂不可用'),
225
- React.createElement('div', { className: 'dsh-tw-summary-state-detail' }, error),
226
- React.createElement('button', { type: 'button', className: 'dsh-tw-summary-btn', onClick: refresh }, '重试'),
227
- )
228
- } else {
229
- // 原生 TW 片段:HTML 来自本地 wiki(与嵌入式编辑器和工具卡同一信任级别);
230
- // 片段内 /dsh-tiddlywiki/tw/#标题 链接由全局拦截器打开中央 TW 面板。
231
- content = React.createElement('div', {
232
- className: 'dsh-tw-summary-native dsh-tw-toolcard-native',
233
- dangerouslySetInnerHTML: { __html: html ?? '' },
234
- })
235
- }
236
-
237
- return React.createElement(
238
- 'div',
239
- { className: 'dsh-tw-summary', 'data-dsh-tw-summary': 'true' },
240
- React.createElement(
241
- 'div',
242
- { className: 'dsh-tw-summary-bar' },
243
- React.createElement('span', { className: 'dsh-tw-summary-bar-title' }, '📚 本会话知识库'),
244
- React.createElement('span', { className: 'dsh-tw-summary-bar-hint' }, '产生 / 读取 / 检索过的笔记(含子代理)'),
245
- React.createElement('span', { className: 'dsh-tw-summary-bar-spacer' }),
246
- React.createElement(
247
- 'button',
248
- { type: 'button', className: 'dsh-tw-summary-btn', onClick: refresh, disabled: phase === 'loading', title: '重新生成并刷新汇总' },
249
- '🔄 刷新',
250
- ),
251
- ),
252
- content,
253
- )
254
- }
255
-
256
- /** 槽位注册所需的最小 slots 脸(与 client/index.ts 的 ClientContextFace 一致)。 */
257
- interface SlotsFace {
258
- inject(name: string, register: () => unknown): (() => void) | undefined
259
- register(
260
- opts: { name: string; id: string; order?: number; label?: string | (() => string) },
261
- component: unknown,
262
- ): () => void
263
- }
264
-
265
- /**
266
- * 挂载「知识库」Tab(conversation.view 槽位)。受 `ui.showSessionTab` 控制
267
- * (默认开);tab 名跟随 `ui.tabLabel`(默认「知识库」)。
268
- * @returns disposer(槽位未注入时 undefined)。
269
- */
270
- export function mountSessionSummaryView(slots: SlotsFace | undefined, cfg: { tabLabel: string; showSessionTab: boolean }): (() => void) | undefined {
271
- if (slots === undefined || cfg.showSessionTab === false) return undefined
272
- setSessionSummaryTabLabel(cfg.tabLabel)
273
- return slots.inject('conversation.view', () =>
274
- slots.register(
275
- { name: 'conversation.view', id: SESSION_SUMMARY_VIEW_ID, order: 20, label: labelThunk },
276
- SessionSummaryView,
277
- ),
278
- )
279
- }
1
+ /**
2
+ * 会话顶部「知识库」Tab(conversation.view 槽位,id `dsh-tiddlywiki-summary`)——
3
+ * 把当前会话相关的 wiki 笔记汇总以 TW 原生渲染展示。
4
+ *
5
+ * 汇总内容由 Host 路由 POST /dsh-tiddlywiki/session/summary 生成:后端用
6
+ * sessionQuery 读本会话(含后代 subagent)的事件日志,按「产生/读取/检索」收集
7
+ * tiddlywiki_* 笔记,组装 TW wikitext 写入 volatile 的
8
+ * `$:/temp/dsh/session-summary/<会话ID>`(不落盘、不进 git),见
9
+ * src/host/session-summary.ts。
10
+ *
11
+ * 渲染(v0.16.19 修掉的坑——为什么不能走 iframe + TW story view):TW 5.4.1 核心
12
+ * 的视图模板级联($:/config/ViewTemplateBodyFilters/system 的 system 规则,multids
13
+ * 展开为独立 tiddler)把一切 `$:/temp/` 前缀 tiddler 一律按「代码块」渲染
14
+ * ($:/core/ui/ViewTemplate/body/code → <pre><code>)——已用 headless $tw 实测:
15
+ * 对 `$:/temp/dsh/session-summary/…` 渲染 body 级联得到 `<pre><code>…</code></pre>`
16
+ * 而非 `<h1>`。因此**无论**是 v0.16.11–13 的「hash 直达」(浏览器端 TW 同步天生
17
+ * 排除 `$:/temp`,只出「佚失条目」)还是 v0.16.14–18 的「注入 iframe store +
18
+ * 原生 hash 导航」(条目进了浏览器 store 后,story view 仍被 system 级联规则按
19
+ * 代码块展示),汇总都呈现为「整篇 wikitext 源码、像包在代码标签里」——这正是
20
+ * 用户看到的现象。修复:客户端**不再用 iframe / story view**,改走与回复流工具卡
21
+ * 同一条原生渲染管线——`POST /tw/render { title }`(服务端 renderText 把 wikitext
22
+ * 块解析成 HTML 片段,内部 [[链接]] 重写为同源代理 hash /dsh-tiddlywiki/tw/#标题)
23
+ * → dangerouslySetInnerHTML 注入滚动容器;主题与样式复用 .dsh-tw-toolcard-native
24
+ * 的 tc-* 重主题(与工具卡视觉一致);片段内链接由全局 wiki-link 拦截器打开中央
25
+ * TW 面板。无 iframe → 无 story view → 无编辑按钮/草稿(v0.16.16 的三层防误编辑
26
+ * 机制随之不再需要)。
27
+ *
28
+ * 槽位注册(探查定稿):`conversation.view` kind=list scope=session replaceRisk=none,
29
+ * 官方留给第三方扩展;用新 id = 在「对话 | 轨迹」旁新增 tab(order 20),label 用
30
+ * thunk 跟随 `ui.tabLabel` 配置(默认「知识库」)。进入 tab 自动触发生成,顶栏提供
31
+ * 手动「刷新」。
32
+ *
33
+ * @module dsh-tiddlywiki/client/session-summary
34
+ */
35
+ import * as React from 'react'
36
+ import { GET_ENDPOINT } from './endpoints.ts'
37
+
38
+ export const SESSION_SUMMARY_VIEW_ID = 'dsh-tiddlywiki-summary'
39
+ const SUMMARY_ENDPOINT = '/dsh-tiddlywiki/session/summary'
40
+ const RENDER_ENDPOINT = '/dsh-tiddlywiki/tw/render'
41
+ const SESSION_SUMMARY_LABEL_DEFAULT = '知识库'
42
+ /** 自愈探测周期:服务端 volatile 条目被清(TW 重启)→ 自动重新生成。 */
43
+ const SELF_HEAL_MS = 30_000
44
+ /** 连续多少次「生成后服务端仍缺失」后停止自动重试,交还手动「🔄 刷新」。 */
45
+ const MAX_MISSES = 3
46
+
47
+ /** Tab 显示名缓存:客户端从 /status 读到 `ui.tabLabel` 后更新;默认「知识库」。 */
48
+ let tabLabel = SESSION_SUMMARY_LABEL_DEFAULT
49
+ export function setSessionSummaryTabLabel(label: string): void {
50
+ tabLabel = typeof label === 'string' && label.trim().length > 0 ? label.trim() : SESSION_SUMMARY_LABEL_DEFAULT
51
+ }
52
+
53
+ /** 槽位 label thunk:每次投影重读,配置变更即时生效。 */
54
+ function labelThunk(): string {
55
+ return tabLabel
56
+ }
57
+
58
+ /** conversation.view 标准 props(scope=session)的极小子集 + ownerProps。 */
59
+ interface SessionSummaryViewProps {
60
+ sessionId?: string
61
+ /** ownerProps:一次性 focus 请求(本视图无子 focus 目标,直接确认)。 */
62
+ viewRequest?: { view?: string; focus?: string } | null
63
+ completeViewRequest?: () => void
64
+ }
65
+
66
+ /** POST /tw/render:把汇总 tiddler 的 wikitext 块解析成原生 HTML 片段(失败返回 null)。 */
67
+ async function fetchRenderFragment(title: string): Promise<string | null> {
68
+ try {
69
+ const res = await fetch(RENDER_ENDPOINT, {
70
+ method: 'POST',
71
+ // TW 服务器对 POST 一律走 writers + CSRF(X-Requested-With)门禁,与
72
+ // tool-views 相同;同源请求可自由携带该头,缺了会 403。
73
+ headers: { 'content-type': 'application/json', 'x-requested-with': 'TiddlyWiki' },
74
+ body: JSON.stringify({ title }),
75
+ signal: AbortSignal.timeout(15_000),
76
+ })
77
+ if (!res.ok) return null
78
+ const text = await res.text()
79
+ return text.length > 0 ? text : null
80
+ } catch {
81
+ return null
82
+ }
83
+ }
84
+
85
+ /** GET /get:确认 volatile 汇总条目是否还在(404/notFound = 已被清掉)。 */
86
+ async function tiddlerExists(title: string): Promise<boolean> {
87
+ try {
88
+ const res = await fetch(`${GET_ENDPOINT}?title=${encodeURIComponent(title)}`, { signal: AbortSignal.timeout(8_000) })
89
+ if (res.status === 404) return false
90
+ const data = (await res.json().catch(() => null)) as { notFound?: boolean } | null
91
+ return data?.notFound !== true
92
+ } catch {
93
+ return true // 查询失败时不误判为缺失,交由自愈定时器处理
94
+ }
95
+ }
96
+
97
+ /** View 组件:生成汇总 → /tw/render 原生片段;顶栏带手动刷新。 */
98
+ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement {
99
+ const { sessionId, viewRequest, completeViewRequest } = props
100
+ const [phase, setPhase] = React.useState<'loading' | 'ready' | 'error'>('loading')
101
+ const [html, setHtml] = React.useState<string | null>(null)
102
+ const [summaryTitle, setSummaryTitle] = React.useState<string | null>(null)
103
+ const [error, setError] = React.useState('')
104
+ const genRef = React.useRef(0)
105
+ const missesRef = React.useRef(0)
106
+ /** 渲染失败且条目缺失时自动重建一次的有界开关(防循环)。 */
107
+ const autoRetriedRef = React.useRef(false)
108
+
109
+ const generate = React.useCallback(async (): Promise<void> => {
110
+ const gen = ++genRef.current
111
+ setPhase('loading')
112
+ if (typeof sessionId !== 'string' || sessionId.length === 0) {
113
+ setPhase('error')
114
+ setError('缺少会话 ID')
115
+ return
116
+ }
117
+ try {
118
+ const res = await fetch(SUMMARY_ENDPOINT, {
119
+ method: 'POST',
120
+ headers: { 'content-type': 'application/json' },
121
+ body: JSON.stringify({ session: sessionId }),
122
+ signal: AbortSignal.timeout(25_000),
123
+ })
124
+ const data = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; error?: string } | null
125
+ if (gen !== genRef.current) return
126
+ if (!res.ok || data?.ok !== true) {
127
+ setPhase('error')
128
+ setError(data?.error ?? `HTTP ${res.status}`)
129
+ return
130
+ }
131
+ if (typeof data.title !== 'string' || data.title.length === 0) {
132
+ setPhase('error')
133
+ setError('汇总生成失败:缺少标题')
134
+ return
135
+ }
136
+ setSummaryTitle(data.title)
137
+ // 原生渲染:wikitext → HTML 片段(块解析,标题/表格/列表/引用齐全,
138
+ // 链接已重写为 /dsh-tiddlywiki/tw/#标题,点击由全局拦截器接管)。
139
+ const fragment = await fetchRenderFragment(data.title)
140
+ if (gen !== genRef.current) return
141
+ if (fragment === null) {
142
+ // 区分「条目被清(TW 重启把 $:/temp 冲掉了)」与「渲染服务不可用」:
143
+ // 前者自动重建一次(有界),后者直接报错交还手动重试。
144
+ const exists = await tiddlerExists(data.title)
145
+ if (gen !== genRef.current) return
146
+ if (!exists) {
147
+ if (autoRetriedRef.current) {
148
+ setPhase('error')
149
+ setError('汇总条目不存在,自动重建失败,请重试')
150
+ return
151
+ }
152
+ autoRetriedRef.current = true
153
+ missesRef.current++
154
+ void generate()
155
+ return
156
+ }
157
+ setPhase('error')
158
+ setError('渲染失败:wiki 渲染服务不可用(/tw/render)')
159
+ return
160
+ }
161
+ autoRetriedRef.current = false
162
+ missesRef.current = 0
163
+ setHtml(fragment)
164
+ setPhase('ready')
165
+ } catch (err) {
166
+ if (gen !== genRef.current) return
167
+ setPhase('error')
168
+ setError(err instanceof Error ? err.message : String(err))
169
+ }
170
+ }, [sessionId])
171
+
172
+ // 进入 tab 自动触发生成;切走再切回会重新挂载 → 再次生成(总是最新)。
173
+ React.useEffect(() => {
174
+ void generate()
175
+ }, [generate])
176
+
177
+ // 自愈:汇总条目是 volatile 的 `$:/temp`,TW 重启即消失;若已就绪但条目
178
+ // 被清掉(/get 404),自动重新生成(含重新渲染)。连续 MAX_MISSES 次仍缺失
179
+ // (例如服务端写不进去)就停止自动重试,交还手动「🔄 刷新」。
180
+ React.useEffect(() => {
181
+ if (phase !== 'ready' || summaryTitle === null) return
182
+ const timer = window.setInterval(() => {
183
+ void (async () => {
184
+ let serverHas = false
185
+ try {
186
+ const res = await fetch(`${GET_ENDPOINT}?title=${encodeURIComponent(summaryTitle)}`, { signal: AbortSignal.timeout(8_000) })
187
+ const data = (await res.json().catch(() => null)) as { notFound?: boolean } | null
188
+ serverHas = res.status !== 404 && data?.notFound !== true
189
+ } catch {
190
+ return // 探测失败保持现状,下个周期再试
191
+ }
192
+ if (!serverHas) {
193
+ missesRef.current++
194
+ if (missesRef.current < MAX_MISSES) void generate()
195
+ } else {
196
+ missesRef.current = 0
197
+ }
198
+ })()
199
+ }, SELF_HEAL_MS)
200
+ return () => window.clearInterval(timer)
201
+ }, [phase, summaryTitle, generate])
202
+
203
+ // 一次性 focus 请求直接确认(本视图无可聚焦子目标,避免 shell 挂起)。
204
+ React.useEffect(() => {
205
+ if (viewRequest !== null && viewRequest !== undefined) completeViewRequest?.()
206
+ }, [viewRequest, completeViewRequest])
207
+
208
+ const refresh = (): void => {
209
+ void generate()
210
+ }
211
+
212
+ let content: React.ReactNode
213
+ if (phase === 'loading') {
214
+ content = React.createElement(
215
+ 'div',
216
+ { className: 'dsh-tw-summary-state' },
217
+ React.createElement('div', { className: 'dsh-tw-summary-state-spin' }, '⏳'),
218
+ React.createElement('div', null, '正在生成会话 wiki 汇总…'),
219
+ )
220
+ } else if (phase === 'error') {
221
+ content = React.createElement(
222
+ 'div',
223
+ { className: 'dsh-tw-summary-state' },
224
+ React.createElement('div', { className: 'dsh-tw-summary-state-title' }, '知识库汇总暂不可用'),
225
+ React.createElement('div', { className: 'dsh-tw-summary-state-detail' }, error),
226
+ React.createElement('button', { type: 'button', className: 'dsh-tw-summary-btn', onClick: refresh }, '重试'),
227
+ )
228
+ } else {
229
+ // 原生 TW 片段:HTML 来自本地 wiki(与嵌入式编辑器和工具卡同一信任级别);
230
+ // 片段内 /dsh-tiddlywiki/tw/#标题 链接由全局拦截器打开中央 TW 面板。
231
+ content = React.createElement('div', {
232
+ className: 'dsh-tw-summary-native dsh-tw-toolcard-native',
233
+ dangerouslySetInnerHTML: { __html: html ?? '' },
234
+ })
235
+ }
236
+
237
+ return React.createElement(
238
+ 'div',
239
+ { className: 'dsh-tw-summary', 'data-dsh-tw-summary': 'true' },
240
+ React.createElement(
241
+ 'div',
242
+ { className: 'dsh-tw-summary-bar' },
243
+ React.createElement('span', { className: 'dsh-tw-summary-bar-title' }, '📚 本会话知识库'),
244
+ React.createElement('span', { className: 'dsh-tw-summary-bar-hint' }, '产生 / 读取 / 检索过的笔记(含子代理)'),
245
+ React.createElement('span', { className: 'dsh-tw-summary-bar-spacer' }),
246
+ React.createElement(
247
+ 'button',
248
+ { type: 'button', className: 'dsh-tw-summary-btn', onClick: refresh, disabled: phase === 'loading', title: '重新生成并刷新汇总' },
249
+ '🔄 刷新',
250
+ ),
251
+ ),
252
+ content,
253
+ )
254
+ }
255
+
256
+ /** 槽位注册所需的最小 slots 脸(与 client/index.ts 的 ClientContextFace 一致)。 */
257
+ interface SlotsFace {
258
+ inject(name: string, register: () => unknown): (() => void) | undefined
259
+ register(
260
+ opts: { name: string; id: string; order?: number; label?: string | (() => string) },
261
+ component: unknown,
262
+ ): () => void
263
+ }
264
+
265
+ /**
266
+ * 挂载「知识库」Tab(conversation.view 槽位)。受 `ui.showSessionTab` 控制
267
+ * (默认开);tab 名跟随 `ui.tabLabel`(默认「知识库」)。
268
+ * @returns disposer(槽位未注入时 undefined)。
269
+ */
270
+ export function mountSessionSummaryView(slots: SlotsFace | undefined, cfg: { tabLabel: string; showSessionTab: boolean }): (() => void) | undefined {
271
+ if (slots === undefined || cfg.showSessionTab === false) return undefined
272
+ setSessionSummaryTabLabel(cfg.tabLabel)
273
+ return slots.inject('conversation.view', () =>
274
+ slots.register(
275
+ { name: 'conversation.view', id: SESSION_SUMMARY_VIEW_ID, order: 20, label: labelThunk },
276
+ SessionSummaryView,
277
+ ),
278
+ )
279
+ }
@@ -254,6 +254,7 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
254
254
  textField('ui.darkPalette', '暗色时 TW palette(tiddler 标题)', typeof ui.darkPalette === 'string' && ui.darkPalette.trim().length > 0 ? ui.darkPalette.trim() : '$:/palettes/CupertinoDark')
255
255
  textField('ui.tabLabel', '会话顶部「知识库」Tab 名称', typeof ui.tabLabel === 'string' && ui.tabLabel.trim().length > 0 ? ui.tabLabel.trim() : '知识库')
256
256
  checkField('ui.showSessionTab', '显示会话顶部「知识库」Tab(本会话相关 wiki 笔记汇总)', ui.showSessionTab !== false)
257
+ checkField('ui.showRightbarTab', '在 DSH 右侧边栏提供 TiddlyWiki 入口/Tab(与聊天并排)', ui.showRightbarTab !== false)
257
258
  const sendToAgent = (ui.sendToAgent ?? {}) as Record<string, unknown>
258
259
  checkField('ui.sendToAgent.enabled', '启用「发送给 Agent」(TW 笔记 → DSH 会话注入)', sendToAgent.enabled !== false)
259
260
  textField('ui.sendToAgent.endpoint', 'TW 端请求基址(空=自动取当前 DSH origin)', typeof sendToAgent.endpoint === 'string' ? sendToAgent.endpoint : '')
@@ -1,39 +1,39 @@
1
- /**
2
- * Tiny pub/sub panel state shared by the sidebar entry and the center panel.
3
- *
4
- * @module dsh-tiddlywiki/client/state
5
- */
6
-
7
- export type PanelListener = (open: boolean) => void
8
-
9
- export class PanelState {
10
- private open = false
11
- private readonly listeners = new Set<PanelListener>()
12
-
13
- isOpen(): boolean {
14
- return this.open
15
- }
16
-
17
- toggle(): void {
18
- this.set(!this.open)
19
- }
20
-
21
- openPanel(): void {
22
- this.set(true)
23
- }
24
-
25
- closePanel(): void {
26
- this.set(false)
27
- }
28
-
29
- set(value: boolean): void {
30
- if (this.open === value) return
31
- this.open = value
32
- for (const listener of [...this.listeners]) listener(value)
33
- }
34
-
35
- subscribe(listener: PanelListener): () => void {
36
- this.listeners.add(listener)
37
- return () => { this.listeners.delete(listener) }
38
- }
39
- }
1
+ /**
2
+ * Tiny pub/sub panel state shared by the sidebar entry and the center panel.
3
+ *
4
+ * @module dsh-tiddlywiki/client/state
5
+ */
6
+
7
+ export type PanelListener = (open: boolean) => void
8
+
9
+ export class PanelState {
10
+ private open = false
11
+ private readonly listeners = new Set<PanelListener>()
12
+
13
+ isOpen(): boolean {
14
+ return this.open
15
+ }
16
+
17
+ toggle(): void {
18
+ this.set(!this.open)
19
+ }
20
+
21
+ openPanel(): void {
22
+ this.set(true)
23
+ }
24
+
25
+ closePanel(): void {
26
+ this.set(false)
27
+ }
28
+
29
+ set(value: boolean): void {
30
+ if (this.open === value) return
31
+ this.open = value
32
+ for (const listener of [...this.listeners]) listener(value)
33
+ }
34
+
35
+ subscribe(listener: PanelListener): () => void {
36
+ this.listeners.add(listener)
37
+ return () => { this.listeners.delete(listener) }
38
+ }
39
+ }