dsh-tiddlywiki 0.16.13 → 0.16.14

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.
@@ -5,14 +5,27 @@
5
5
  * 判定来源是 Host 路由 POST /dsh-tiddlywiki/session/summary:后端用 sessionQuery 读
6
6
  * 本会话(含后代 subagent)的事件日志,按「产生/读取/检索」收集 tiddlywiki_* 笔记,
7
7
  * 组装 TW wikitext 写入 volatile 的 `$:/temp/dsh/session-summary/<会话ID>`(不落盘、
8
- * 不进 git),前端只需把一个同源代理 iframe 指向
9
- * `<origin>/dsh-tiddlywiki/tw/#$:/temp/dsh/session-summary/<会话ID>`——TW 用自己
10
- * 主题/链接/导航原生渲染,不另造 UI(见 src/host/session-summary.ts)。
8
+ * 不进 git),见 src/host/session-summary.ts。
9
+ *
10
+ * 渲染(v0.16.14 关键机制——为什么不能直接 `#<标题>` 直达):浏览器端 TW 的同步
11
+ * 机制天生看不到 `$:/temp`——服务端 recipe 列表默认 `[all[tiddlers]!is[system]]`
12
+ * (get-tiddlers-json.js),tiddlyweb adaptor 的请求过滤器又显式
13
+ * `-[prefix[$:/temp/]]`(tiddlywebadaptor.js getSkinnyTiddlers),而 lazyLoad 只补
14
+ * 「已知 skinny」不拉「完全缺失」——所以 iframe 里的 TW 永远拿不到 volatile 汇总
15
+ * 条目,`#<标题>` 直达只会渲染「佚失条目」(v0.16.11–13 的根因)。因此由本组件在
16
+ * iframe 就绪后【注入】:同源 `/get` 读回汇总字段 →
17
+ * `iframe.contentWindow.$tw.wiki.addTiddler(...)` 写进 iframe 的 TW store → 设置
18
+ * `contentWindow.location.hash` 走 TW 原生 hash 导航(与中央面板 openTiddler 同一
19
+ * 机制,落地前重试绕开 boot 时序竞争)。汇总条目始终只存在于服务端内存与 iframe
20
+ * 内存——不落盘、不进 git、重启即消失(§四 设计不变)。
21
+ *
22
+ * 自愈:30s 探测——① 服务端丢失(TW 重启清空 `$:/temp`)→ 重新生成(连续
23
+ * MAX_MISSES 次仍缺失交还手动「🔄 刷新」);② 服务端仍在而 iframe store 丢失
24
+ * (iframe 自身重载/重连)→ 重新注入并导航,不打扰用户在 iframe 内的浏览。
11
25
  *
12
26
  * 槽位注册(探查定稿):`conversation.view` kind=list scope=session replaceRisk=none,
13
27
  * 官方留给第三方扩展;用新 id = 在「对话 | 轨迹」旁新增 tab(order 20),label 用
14
- * thunk 跟随 `ui.tabLabel` 配置(默认「知识库」)。进入 tab 自动触发生成,顶栏提供
15
- * 手动「刷新」。
28
+ * thunk 跟随 `ui.tabLabel` 配置(默认「知识库」)。
16
29
  *
17
30
  * @module dsh-tiddlywiki/client/session-summary
18
31
  */
@@ -25,6 +38,16 @@ const SUMMARY_ENDPOINT = '/dsh-tiddlywiki/session/summary'
25
38
  /** Same-origin TW proxy base(与 host TW_PROXY_PATH 一致,client 不能 import 它)。 */
26
39
  const TW_PROXY_BASE = '/dsh-tiddlywiki/tw/'
27
40
  const SESSION_SUMMARY_LABEL_DEFAULT = '知识库'
41
+ /** iframe 内 TW 就绪探测节奏与上限(250ms × 80 ≈ 20s)。 */
42
+ const TW_READY_POLL_MS = 250
43
+ const TW_READY_TIMEOUT_MS = 20_000
44
+ /** hash 导航落地重试节奏与上限(500ms × 20 ≈ 10s)。 */
45
+ const NAV_RETRY_MS = 500
46
+ const NAV_RETRY_MAX = 20
47
+ /** 自愈探测周期。 */
48
+ const SELF_HEAL_MS = 30_000
49
+ /** 连续多少次「生成后服务端仍缺失」后停止自动重试,交还手动「🔄 刷新」。 */
50
+ const MAX_MISSES = 3
28
51
 
29
52
  /** Tab 显示名缓存:客户端从 /status 读到 `ui.tabLabel` 后更新;默认「知识库」。 */
30
53
  let tabLabel = SESSION_SUMMARY_LABEL_DEFAULT
@@ -45,16 +68,57 @@ interface SessionSummaryViewProps {
45
68
  completeViewRequest?: () => void
46
69
  }
47
70
 
48
- /** View 组件:生成汇总 → iframe 打开 TW 原生页;顶栏带手动刷新。 */
71
+ /** `/get` 读回的汇总字段子集(够渲染即可)。 */
72
+ interface SummaryFields {
73
+ title: string
74
+ text?: string
75
+ type?: string
76
+ tags?: string[]
77
+ }
78
+
79
+ /**
80
+ * iframe 内 TW 运行时的极小结构面——只声明注入/探测用到的成员(theme-sync 已有
81
+ * 访问 `iframe.contentWindow.$tw` 的先例,同一访问模式)。
82
+ */
83
+ interface TwRuntime {
84
+ Tiddler: new (fields: Record<string, unknown>) => unknown
85
+ wiki: {
86
+ addTiddler(tiddler: unknown): void
87
+ tiddlerExists(title: string): boolean
88
+ getTiddlerList(title: string): string[]
89
+ }
90
+ }
91
+
92
+ /** 读取 iframe 内的 TW 运行时;未就绪(未启动/缺成员)返回 undefined。 */
93
+ function readTwRuntime(el: HTMLIFrameElement | null | undefined): TwRuntime | undefined {
94
+ const w = el?.contentWindow as (Window & { $tw?: unknown }) | null | undefined
95
+ const tw = w?.$tw as TwRuntime | undefined
96
+ if (tw === undefined || tw.wiki === undefined) return undefined
97
+ if (typeof tw.Tiddler !== 'function') return undefined
98
+ if (typeof tw.wiki.addTiddler !== 'function' || typeof tw.wiki.tiddlerExists !== 'function' || typeof tw.wiki.getTiddlerList !== 'function') {
99
+ return undefined
100
+ }
101
+ return tw
102
+ }
103
+
104
+ function sleep(ms: number): Promise<void> {
105
+ return new Promise((resolve) => window.setTimeout(resolve, ms))
106
+ }
107
+
108
+ /** View 组件:生成汇总 → iframe 载入 TW → 注入 volatile 条目 → 原生 hash 导航。 */
49
109
  function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement {
50
110
  const { sessionId, viewRequest, completeViewRequest } = props
51
111
  const [phase, setPhase] = React.useState<'loading' | 'ready' | 'error'>('loading')
52
- const [twUrl, setTwUrl] = React.useState<string | null>(null)
112
+ const [frameSrc, setFrameSrc] = React.useState<string | null>(null)
53
113
  const [summaryTitle, setSummaryTitle] = React.useState<string | null>(null)
114
+ /** true = 汇总已在 iframe 内落地显示;false = 盖一层加载浮层(遮住 TW 默认页)。 */
115
+ const [navigated, setNavigated] = React.useState(false)
54
116
  const [error, setError] = React.useState('')
55
117
  const [frameNonce, setFrameNonce] = React.useState(0)
56
118
  const genRef = React.useRef(0)
119
+ const missesRef = React.useRef(0)
57
120
  const themeDisposeRef = React.useRef<(() => void) | undefined>(undefined)
121
+ const frameElRef = React.useRef<HTMLIFrameElement | null>(null)
58
122
 
59
123
  const generate = React.useCallback(async (): Promise<void> => {
60
124
  const gen = ++genRef.current
@@ -80,11 +144,18 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
80
144
  }
81
145
  if (typeof data.title === 'string' && data.title.length > 0) {
82
146
  setSummaryTitle(data.title)
83
- setTwUrl(new URL(TW_PROXY_BASE, window.location.origin).href + '#' + encodeURIComponent(data.title))
84
- // 新 nonce 强制 iframe 重挂载 → 用最新汇总重新加载(含刷新场景)。
147
+ // 注意:不带 #hash 挂载——volatile 条目不在浏览器 store,boot 期 hash 直达
148
+ // 只会渲染「佚失条目」;就绪后由 injectAndNavigate 注入再原生导航。
149
+ setFrameSrc(new URL(TW_PROXY_BASE, window.location.origin).href)
150
+ // 新 nonce 强制 iframe 重挂载 → 重新注入最新汇总(含刷新场景)。
85
151
  setFrameNonce((n) => n + 1)
152
+ setNavigated(false)
153
+ setPhase('ready')
154
+ } else {
155
+ // ok 但缺 title:防御性报错,避免空 iframe + 永久加载浮层。
156
+ setPhase('error')
157
+ setError('响应缺少汇总标题')
86
158
  }
87
- setPhase('ready')
88
159
  } catch (err) {
89
160
  if (gen !== genRef.current) return
90
161
  setPhase('error')
@@ -97,30 +168,135 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
97
168
  void generate()
98
169
  }, [generate])
99
170
 
100
- // 自愈:汇总条目是 volatile 的 `$:/temp`,TW 重启即消失;若 iframe 已就绪但条目
101
- // 被清掉(/get 404),自动重新生成,避免停在 TW 的「佚失条目」页。连续 3 次仍
102
- // 缺失(例如服务端写不进去)就停止自动重试,交还手动「🔄 刷新」。
171
+ /** 服务端读回汇总字段(volatile 条目对 host 的 /get 永远可见)。 */
172
+ const fetchSummaryFields = React.useCallback(async (title: string): Promise<SummaryFields | 'missing' | null> => {
173
+ try {
174
+ const res = await fetch(`${GET_ENDPOINT}?title=${encodeURIComponent(title)}`, { signal: AbortSignal.timeout(8_000) })
175
+ if (res.status === 404) return 'missing'
176
+ const data = (await res.json().catch(() => null)) as { ok?: boolean; title?: string; text?: string; type?: string; tags?: string[] } | null
177
+ if (data?.ok !== true || typeof data.title !== 'string') return null
178
+ return { title: data.title, text: data.text, type: data.type, tags: data.tags }
179
+ } catch {
180
+ return null
181
+ }
182
+ }, [])
183
+
184
+ /**
185
+ * 注入 + 原生导航:读回汇总字段 → 等 iframe 内 TW 启动 → addTiddler 进 iframe 的
186
+ * store → 设 contentWindow.location.hash 走 TW 原生导航,落地重试直到条目进入
187
+ * `$:/StoryList`(绕开「hash 设置早于 story 监听器注册」的 boot 时序竞争)。
188
+ */
189
+ const injectAndNavigate = React.useCallback(async (): Promise<'ok' | 'missing' | 'timeout'> => {
190
+ if (summaryTitle === null) return 'missing'
191
+ const fields = await fetchSummaryFields(summaryTitle)
192
+ if (fields === 'missing') return 'missing'
193
+ if (fields === null) return 'timeout'
194
+ // 等 TW 启动(iframe 重挂载后 boot 需要一点时间)。
195
+ let tw: TwRuntime | undefined
196
+ const deadline = Date.now() + TW_READY_TIMEOUT_MS
197
+ while (Date.now() < deadline) {
198
+ tw = readTwRuntime(frameElRef.current)
199
+ if (tw !== undefined) break
200
+ await sleep(TW_READY_POLL_MS)
201
+ }
202
+ if (tw === undefined) {
203
+ setNavigated(true) // TW 一直没就绪:揭开 iframe 看真实状态(代理错误页等)
204
+ return 'timeout'
205
+ }
206
+ // 注入(幂等:同标题覆盖;SyncFilter 排除 $:/temp → iframe 不会把它存回服务端)。
207
+ try {
208
+ tw.wiki.addTiddler(new tw.Tiddler({
209
+ title: fields.title,
210
+ text: fields.text ?? '',
211
+ type: fields.type ?? 'text/vnd.tiddlywiki',
212
+ tags: fields.tags ?? [],
213
+ }))
214
+ } catch {
215
+ /* 注入失败也继续尝试导航(store 里可能有旧版本可显示) */
216
+ }
217
+ // hash 导航 + 落地重试。
218
+ const hash = '#' + encodeURIComponent(summaryTitle)
219
+ for (let i = 0; i < NAV_RETRY_MAX; i++) {
220
+ let landed = false
221
+ try {
222
+ landed = tw.wiki.getTiddlerList('$:/StoryList').indexOf(summaryTitle) !== -1
223
+ } catch {
224
+ landed = false
225
+ }
226
+ if (landed) {
227
+ setNavigated(true)
228
+ return 'ok'
229
+ }
230
+ const w = frameElRef.current?.contentWindow
231
+ if (w !== null && w !== undefined) {
232
+ try {
233
+ const cur = w.location.hash
234
+ // TW 的 permaview 会把地址栏改写成 `#<标题>:<story>`;只要当前 hash 不是
235
+ // 以我们的标题开头就重设,避免和地址栏更新互相打架。
236
+ if (cur !== hash && !cur.startsWith(hash + ':')) w.location.hash = hash
237
+ } catch {
238
+ /* 跨文档瞬间设置失败 → 下轮重试 */
239
+ }
240
+ }
241
+ await sleep(NAV_RETRY_MS)
242
+ }
243
+ setNavigated(true)
244
+ return 'timeout'
245
+ }, [summaryTitle, fetchSummaryFields])
246
+
247
+ // iframe 挂载后注入并导航;每次重挂载(生成/刷新)都重跑。服务端条目丢失
248
+ // (TW 重启)→ 计入 misses 自动重新生成,连续 MAX_MISSES 次仍缺失交还手动刷新。
249
+ React.useEffect(() => {
250
+ if (phase !== 'ready' || summaryTitle === null) return
251
+ let disposed = false
252
+ void (async () => {
253
+ const outcome = await injectAndNavigate()
254
+ if (disposed) return
255
+ if (outcome === 'missing') {
256
+ missesRef.current++
257
+ if (missesRef.current < MAX_MISSES) void generate()
258
+ }
259
+ })()
260
+ return () => {
261
+ disposed = true
262
+ }
263
+ }, [phase, summaryTitle, frameNonce, injectAndNavigate, generate])
264
+
265
+ // 自愈探测(30s):① 服务端丢失(TW 重启清空 volatile 条目)→ 重新生成;
266
+ // ② 服务端仍在而 iframe store 丢失(iframe 自身重载/重连)→ 重新注入并导航。
103
267
  React.useEffect(() => {
104
268
  if (phase !== 'ready' || summaryTitle === null) return
105
- let misses = 0
106
269
  const timer = window.setInterval(() => {
107
270
  void (async () => {
271
+ let serverHas = false
108
272
  try {
109
273
  const res = await fetch(`${GET_ENDPOINT}?title=${encodeURIComponent(summaryTitle)}`, { signal: AbortSignal.timeout(8_000) })
110
- const data = (await res.json().catch(() => null)) as { notFound?: boolean } | null
111
- if (res.status === 404 || data?.notFound === true) {
112
- misses++
113
- if (misses < 3) void generate()
114
- } else {
115
- misses = 0
116
- }
274
+ serverHas = res.status !== 404
117
275
  } catch {
118
- /* 检查失败保持现状,下个周期再试 */
276
+ return // 探测失败保持现状,下个周期再试
277
+ }
278
+ if (!serverHas) {
279
+ missesRef.current++
280
+ if (missesRef.current < MAX_MISSES) void generate()
281
+ return
282
+ }
283
+ missesRef.current = 0
284
+ const tw = readTwRuntime(frameElRef.current)
285
+ if (tw === undefined) return
286
+ let present = true
287
+ try {
288
+ present = tw.wiki.tiddlerExists(summaryTitle)
289
+ } catch {
290
+ return // store 暂不可读,保守不动
291
+ }
292
+ if (!present) {
293
+ setNavigated(false)
294
+ void injectAndNavigate()
119
295
  }
120
296
  })()
121
- }, 30_000)
297
+ }, SELF_HEAL_MS)
122
298
  return () => window.clearInterval(timer)
123
- }, [phase, summaryTitle, generate])
299
+ }, [phase, summaryTitle, generate, injectAndNavigate])
124
300
 
125
301
  // 一次性 focus 请求直接确认(本视图无可聚焦子目标,避免 shell 挂起)。
126
302
  React.useEffect(() => {
@@ -130,10 +306,12 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
130
306
  // iframe 主题同步(同源代理,attachThemeSync 自处理 load/reapply)。
131
307
  const frameCallback = React.useCallback((el: HTMLIFrameElement | null) => {
132
308
  if (el === null) {
309
+ frameElRef.current = null
133
310
  themeDisposeRef.current?.()
134
311
  themeDisposeRef.current = undefined
135
312
  return
136
313
  }
314
+ frameElRef.current = el
137
315
  themeDisposeRef.current?.()
138
316
  themeDisposeRef.current = attachThemeSync(el)
139
317
  }, [])
@@ -167,13 +345,27 @@ function SessionSummaryView(props: SessionSummaryViewProps): React.ReactElement
167
345
  React.createElement('button', { type: 'button', className: 'dsh-tw-summary-btn', onClick: refresh }, '重试'),
168
346
  )
169
347
  } else {
170
- content = React.createElement('iframe', {
171
- key: frameNonce,
172
- ref: frameCallback,
173
- className: 'dsh-tw-summary-frame',
174
- src: twUrl ?? undefined,
175
- title: tabLabel,
176
- })
348
+ // iframe 一直在挂载状态启动 TW;注入落地前盖一层加载浮层(遮住 TW 默认页,
349
+ // 避免闪现主页/佚失条目)。TW 就绪超时时揭开浮层让用户看到 iframe 真实状态。
350
+ content = React.createElement(
351
+ 'div',
352
+ { className: 'dsh-tw-summary-body' },
353
+ React.createElement('iframe', {
354
+ key: frameNonce,
355
+ ref: frameCallback,
356
+ className: 'dsh-tw-summary-frame',
357
+ src: frameSrc ?? undefined,
358
+ title: tabLabel,
359
+ }),
360
+ navigated
361
+ ? null
362
+ : React.createElement(
363
+ 'div',
364
+ { className: 'dsh-tw-summary-state dsh-tw-summary-state-over' },
365
+ React.createElement('div', { className: 'dsh-tw-summary-state-spin' }, '⏳'),
366
+ React.createElement('div', null, '正在载入会话 wiki 汇总…'),
367
+ ),
368
+ )
177
369
  }
178
370
 
179
371
  return React.createElement(
@@ -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
+ }
@@ -593,6 +593,8 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
593
593
  }
594
594
  .dsh-tw-summary-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); }
595
595
  .dsh-tw-summary-btn:disabled { opacity: .55; cursor: default; }
596
+ /* body 包一层 relative:注入落地前用绝对定位浮层遮住 TW 默认页(避免闪现主页)。 */
597
+ .dsh-tw-summary-body { position: relative; flex: 1 1 0; min-height: 0; display: flex; }
596
598
  .dsh-tw-summary-frame {
597
599
  flex: 1 1 0; min-height: 0; width: 100%; border: 0; display: block;
598
600
  background: var(--dsw-alias-bg-layer-1, #fff);
@@ -602,6 +604,10 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
602
604
  align-items: center; justify-content: center; gap: 10px; padding: 24px;
603
605
  text-align: center; color: var(--dsw-alias-label-secondary, #666); font-size: 13px;
604
606
  }
607
+ .dsh-tw-summary-state-over {
608
+ position: absolute; inset: 0; z-index: 1;
609
+ background: var(--dsw-alias-bg-layer-1, #fff);
610
+ }
605
611
  .dsh-tw-summary-state-spin { font-size: 22px; }
606
612
  .dsh-tw-summary-state-title { font-weight: 600; color: var(--dsw-alias-label-primary, #222); }
607
613
  .dsh-tw-summary-state-detail { max-width: 420px; word-break: break-all; font-size: 12px; }