@unitninety/site-kit 0.0.0-stage → 1.6.1

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.
Files changed (204) hide show
  1. package/README.md +12 -2
  2. package/package.json +79 -4
  3. package/src/bridge/DraftBlocks.tsx +70 -0
  4. package/src/bridge/PreviewCommitBoundary.tsx +19 -0
  5. package/src/bridge/U90Bridge.tsx +54 -0
  6. package/src/bridge/caret.ts +317 -0
  7. package/src/bridge/client.tsx +66 -0
  8. package/src/bridge/createBridge.ts +1010 -0
  9. package/src/bridge/hooks.tsx +56 -0
  10. package/src/bridge/index.ts +9 -0
  11. package/src/bridge/lexical.ts +177 -0
  12. package/src/bridge/lexicalDom.ts +212 -0
  13. package/src/bridge/preserveBlockMoves.ts +88 -0
  14. package/src/bridge/preview.ts +179 -0
  15. package/src/bridge/protocol.ts +271 -0
  16. package/src/bridge/registry.ts +27 -0
  17. package/src/bridge/styles.ts +137 -0
  18. package/src/components/DateText.tsx +9 -0
  19. package/src/components/File.tsx +9 -0
  20. package/src/components/Image.tsx +30 -0
  21. package/src/components/Link.tsx +24 -0
  22. package/src/components/NumberText.tsx +9 -0
  23. package/src/components/RichText.tsx +47 -0
  24. package/src/components/Text.tsx +15 -0
  25. package/src/components/Video.tsx +12 -0
  26. package/src/components/VideoPlayback.tsx +19 -0
  27. package/src/components/draft.ts +9 -0
  28. package/src/components/imageProps.ts +26 -0
  29. package/src/components/index.ts +12 -0
  30. package/src/components/videoProps.ts +6 -0
  31. package/src/elements/containers/group.ts +33 -0
  32. package/src/elements/containers/list.ts +31 -0
  33. package/src/elements/containers/zone.ts +41 -0
  34. package/src/elements/declaration.ts +118 -0
  35. package/src/elements/define.ts +195 -0
  36. package/src/elements/ids.ts +50 -0
  37. package/src/elements/index.ts +35 -0
  38. package/src/elements/jsonSchema.ts +36 -0
  39. package/src/elements/leaf.ts +98 -0
  40. package/src/elements/leaves/boolean.ts +30 -0
  41. package/src/elements/leaves/choice.ts +53 -0
  42. package/src/elements/leaves/date.ts +39 -0
  43. package/src/elements/leaves/file.ts +56 -0
  44. package/src/elements/leaves/image.ts +160 -0
  45. package/src/elements/leaves/link.ts +149 -0
  46. package/src/elements/leaves/number.ts +52 -0
  47. package/src/elements/leaves/query.ts +101 -0
  48. package/src/elements/leaves/relation.ts +69 -0
  49. package/src/elements/leaves/rich-text.ts +155 -0
  50. package/src/elements/leaves/selection.ts +40 -0
  51. package/src/elements/leaves/single.ts +29 -0
  52. package/src/elements/leaves/text.ts +103 -0
  53. package/src/elements/leaves/video.ts +108 -0
  54. package/src/elements/ops/expand.ts +94 -0
  55. package/src/elements/ops/overlay.ts +59 -0
  56. package/src/elements/ops/refs.ts +45 -0
  57. package/src/elements/ops/seed.ts +185 -0
  58. package/src/elements/ops/text.ts +20 -0
  59. package/src/elements/ops/translatable.ts +19 -0
  60. package/src/elements/ops/upgrade.ts +43 -0
  61. package/src/elements/ops/validate.ts +56 -0
  62. package/src/elements/order.ts +10 -0
  63. package/src/elements/path.ts +43 -0
  64. package/src/elements/prefix.ts +2 -0
  65. package/src/elements/registry.ts +48 -0
  66. package/src/elements/tokens.ts +14 -0
  67. package/src/elements/walk.ts +42 -0
  68. package/src/features/client/AskAiProvider.tsx +551 -0
  69. package/src/features/client/AskAiWidget.tsx +222 -0
  70. package/src/features/client/ConsentRuntime.tsx +95 -0
  71. package/src/features/client/FeedbackProvider.tsx +231 -0
  72. package/src/features/client/FeedbackWidget.tsx +101 -0
  73. package/src/features/client/PageReporter.tsx +26 -0
  74. package/src/features/client/README.md +131 -0
  75. package/src/features/client/SiteFeatures.tsx +250 -0
  76. package/src/features/client/askAiRecovery.ts +92 -0
  77. package/src/features/client/askAiStream.ts +59 -0
  78. package/src/features/client/context.tsx +24 -0
  79. package/src/features/client/index.ts +24 -0
  80. package/src/features/client/overlay.tsx +129 -0
  81. package/src/features/client/styles.css +337 -0
  82. package/src/features/client/transport.ts +310 -0
  83. package/src/features/client/useAskAI.ts +45 -0
  84. package/src/features/client/useConsent.ts +15 -0
  85. package/src/features/client/useFeedback.ts +33 -0
  86. package/src/features/client/useSiteSearch.ts +50 -0
  87. package/src/features/client/verification.ts +141 -0
  88. package/src/features/consent/README.md +70 -0
  89. package/src/features/consent/cookieScript.ts +53 -0
  90. package/src/features/consent/custom.ts +89 -0
  91. package/src/features/consent/defaults.ts +40 -0
  92. package/src/features/consent/drivers.ts +233 -0
  93. package/src/features/consent/loader.ts +248 -0
  94. package/src/features/consent/registry.ts +398 -0
  95. package/src/features/contracts/askAi.ts +166 -0
  96. package/src/features/contracts/askAiSettings.ts +42 -0
  97. package/src/features/contracts/base.ts +107 -0
  98. package/src/features/contracts/consent.ts +150 -0
  99. package/src/features/contracts/feedback.ts +22 -0
  100. package/src/features/contracts/feedbackSettings.ts +49 -0
  101. package/src/features/contracts/index.ts +61 -0
  102. package/src/features/contracts/knowledge.ts +99 -0
  103. package/src/features/contracts/knowledgePolicy.ts +249 -0
  104. package/src/features/contracts/search.ts +24 -0
  105. package/src/features/contracts/verification.ts +52 -0
  106. package/src/features/contracts/verificationConfig.ts +9 -0
  107. package/src/features/server/ConsentDefaults.tsx +15 -0
  108. package/src/features/server/README.md +185 -0
  109. package/src/features/server/gateway.ts +318 -0
  110. package/src/features/server/http.ts +179 -0
  111. package/src/features/server/index.ts +6 -0
  112. package/src/features/server/session.ts +23 -0
  113. package/src/features/server/verification.ts +39 -0
  114. package/src/features/server/verifyVisitor.ts +69 -0
  115. package/src/languages/config.d.mts +5 -0
  116. package/src/languages/config.mjs +53 -0
  117. package/src/languages/decide.ts +72 -0
  118. package/src/languages/index.ts +4 -0
  119. package/src/languages/proxy.ts +108 -0
  120. package/src/languages/routing.ts +93 -0
  121. package/src/languages/wire.ts +19 -0
  122. package/src/legacy/bridge.tsx +1987 -0
  123. package/src/legacy/caret.ts +246 -0
  124. package/src/legacy/cli.mjs +189 -0
  125. package/src/legacy/content.ts +993 -0
  126. package/src/legacy/contract.ts +664 -0
  127. package/src/legacy/defineBlocks.ts +1602 -0
  128. package/src/legacy/defineSite.ts +623 -0
  129. package/src/legacy/defineSite.types.ts +270 -0
  130. package/src/legacy/draft.tsx +128 -0
  131. package/src/legacy/image.ts +433 -0
  132. package/src/legacy/index.ts +209 -0
  133. package/src/legacy/language-switcher.tsx +58 -0
  134. package/src/legacy/languageHints.ts +153 -0
  135. package/src/legacy/languages/fields.ts +131 -0
  136. package/src/legacy/languages/hash.ts +98 -0
  137. package/src/legacy/languages/health.ts +97 -0
  138. package/src/legacy/languages/identity.ts +247 -0
  139. package/src/legacy/languages/links.ts +81 -0
  140. package/src/legacy/languages/resolution.ts +56 -0
  141. package/src/legacy/languages/routing.ts +169 -0
  142. package/src/legacy/languages/runtime.ts +216 -0
  143. package/src/legacy/languages/seo.ts +95 -0
  144. package/src/legacy/languages/settings.ts +211 -0
  145. package/src/legacy/languages/types.ts +85 -0
  146. package/src/legacy/languages.ts +8 -0
  147. package/src/legacy/lexical.ts +170 -0
  148. package/src/legacy/lexicalDom.ts +166 -0
  149. package/src/legacy/media.ts +272 -0
  150. package/src/legacy/nav.ts +176 -0
  151. package/src/legacy/preview.ts +79 -0
  152. package/src/legacy/redirects.ts +474 -0
  153. package/src/legacy/richText.tsx +104 -0
  154. package/src/legacy/routes.ts +414 -0
  155. package/src/legacy/seo.ts +327 -0
  156. package/src/legacy/shape.ts +412 -0
  157. package/src/legacy/uploads.ts +233 -0
  158. package/src/legacy/verification.ts +42 -0
  159. package/src/preview-lock/contracts.ts +42 -0
  160. package/src/preview-lock/index.ts +3 -0
  161. package/src/preview-lock/screen.tsx +244 -0
  162. package/src/preview-lock/server.ts +416 -0
  163. package/src/redirects/config.d.mts +5 -0
  164. package/src/redirects/config.mjs +22 -0
  165. package/src/redirects/index.ts +3 -0
  166. package/src/redirects/location.ts +31 -0
  167. package/src/redirects/match.ts +199 -0
  168. package/src/redirects/proxy.ts +91 -0
  169. package/src/redirects/wire.ts +30 -0
  170. package/src/seo/jsonLd.ts +70 -0
  171. package/src/seo/metadata.ts +127 -0
  172. package/src/seo/robots.ts +14 -0
  173. package/src/seo/sitemap.ts +22 -0
  174. package/src/seo/title.ts +22 -0
  175. package/src/server/adapters.ts +73 -0
  176. package/src/server/createU90.ts +96 -0
  177. package/src/server/handlers.ts +164 -0
  178. package/src/server/index.ts +6 -0
  179. package/src/server/read.ts +207 -0
  180. package/src/server/render.tsx +46 -0
  181. package/src/server/routes.tsx +171 -0
  182. package/src/site/assemble.ts +109 -0
  183. package/src/site/defineSite.ts +132 -0
  184. package/src/site/documents.ts +18 -0
  185. package/src/site/fields.ts +59 -0
  186. package/src/site/index.ts +14 -0
  187. package/src/site/replies.ts +118 -0
  188. package/src/site/store.ts +45 -0
  189. package/src/site/types.ts +75 -0
  190. package/src/site/version.ts +1 -0
  191. package/src/site/wire.ts +125 -0
  192. package/src/switcher/LanguageSwitcher.tsx +42 -0
  193. package/src/test-preview/README.md +90 -0
  194. package/src/test-preview/binding.ts +54 -0
  195. package/src/test-preview/bridge.ts +16 -0
  196. package/src/test-preview/guard.ts +186 -0
  197. package/src/test-preview/media.ts +23 -0
  198. package/src/test-preview/protocol.d.mts +67 -0
  199. package/src/test-preview/protocol.mjs +222 -0
  200. package/src/test-preview/runtime.ts +168 -0
  201. package/src/test-preview/server.ts +10 -0
  202. package/src/testing/fake.ts +148 -0
  203. package/src/testing/index.ts +2 -0
  204. package/src/testing/seed.ts +2 -0
@@ -0,0 +1,551 @@
1
+ 'use client'
2
+ import {
3
+ Suspense,
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react'
11
+ import {
12
+ AskAiInputSchema,
13
+ AskAiWidgetSettingsSchema,
14
+ type AskAiInput,
15
+ type AiSource,
16
+ } from '../contracts'
17
+ import { AskAiContext, type AskAiController } from './useAskAI'
18
+ import { useSiteFeatures } from './context'
19
+ import { PageReporter } from './PageReporter'
20
+ import { emptyRecovery, mergeRecoveryPage, type RecoveryState } from './askAiRecovery'
21
+ import { FeatureClientError, type createFeatureTransport } from './transport'
22
+ const defaults = AskAiWidgetSettingsSchema.parse({})
23
+ type Transport = ReturnType<typeof createFeatureTransport>
24
+ const missing = (cause: unknown) =>
25
+ cause instanceof FeatureClientError &&
26
+ ['not_found', 'unauthorized', 'verification_required'].includes(cause.code)
27
+ function errorCopy(cause: unknown, settings: typeof defaults) {
28
+ const code = cause instanceof FeatureClientError ? cause.code : null
29
+ if (code === 'budget_exceeded') return 'The Ask AI allowance has been reached. Please contact us.'
30
+ if (code === 'rate_limited') return 'Too many requests. Please wait before trying again.'
31
+ if (code === 'conflict')
32
+ return 'This request is still running or its history changed. Check the saved answer before retrying.'
33
+ if (missing(cause)) return 'This conversation is no longer available. Start a new conversation.'
34
+ if (typeof navigator !== 'undefined' && navigator.onLine === false)
35
+ return 'You are offline. Your partial answer is preserved. Reconnect to check the saved answer.'
36
+ return (
37
+ settings.errorMessage +
38
+ ' The answer may be incomplete. Check the saved answer or retry the same request.'
39
+ )
40
+ }
41
+ /** Root-owned state: presentations and pathname navigation do not own a paid request. */
42
+ export function AskAiProvider({
43
+ children,
44
+ endpoint,
45
+ getTransport,
46
+ }: {
47
+ children: ReactNode
48
+ endpoint: string
49
+ getTransport: () => Transport
50
+ }) {
51
+ const runtime = useSiteFeatures(),
52
+ committed = useRef(runtime)
53
+ useLayoutEffect(() => {
54
+ committed.current = runtime
55
+ }, [runtime])
56
+ const [view, setView] = useState({
57
+ messages: [] as RecoveryState['messages'],
58
+ hasEarlier: false,
59
+ canRecover: false,
60
+ awaitingAnswer: false,
61
+ canRetry: false,
62
+ canNewAttempt: false,
63
+ }),
64
+ [draft, setDraft] = useState(''),
65
+ [status, setStatus] = useState<AskAiController['status']>('idle'),
66
+ [error, setError] = useState<string | null>(null),
67
+ [ratingError, setRatingError] = useState<string | null>(null),
68
+ [recovering, setRecovering] = useState(false),
69
+ [ratings, setRatings] = useState<Record<string, 'helpful' | 'unhelpful'>>({})
70
+ const data = useRef<RecoveryState>(emptyRecovery('')),
71
+ attempt = useRef<AskAiInput | null>(null),
72
+ terminal = useRef(false),
73
+ alive = useRef(false),
74
+ operation = useRef<AbortController | null>(null),
75
+ pending = useRef<Promise<void> | null>(null),
76
+ epoch = useRef(0),
77
+ page = useRef<string | null>(null),
78
+ titleReady = useRef(false),
79
+ ratingWork = useRef(
80
+ new Map<
81
+ string,
82
+ { requestId: string; rating: 'helpful' | 'unhelpful'; pending?: Promise<void> }
83
+ >(),
84
+ )
85
+ const storageKey = 'u90.askAi:' + endpoint
86
+ const redraw = useCallback(
87
+ () =>
88
+ setView({
89
+ messages: [...data.current.messages],
90
+ hasEarlier: Boolean(data.current.cursor),
91
+ canRecover: Boolean(data.current.conversationId),
92
+ awaitingAnswer:
93
+ !attempt.current &&
94
+ data.current.messages.some(
95
+ (message) =>
96
+ message.role === 'assistant' && ['queued', 'streaming'].includes(message.state),
97
+ ),
98
+ canRetry: Boolean(attempt.current && !terminal.current && !pending.current),
99
+ canNewAttempt: Boolean(attempt.current && terminal.current && !pending.current),
100
+ }),
101
+ [],
102
+ )
103
+ const invalidate = useCallback(() => {
104
+ epoch.current++
105
+ }, [])
106
+ const persist = useCallback(
107
+ (id: string) => {
108
+ try {
109
+ if (id) window.sessionStorage.setItem(storageKey, id)
110
+ else window.sessionStorage.removeItem(storageKey)
111
+ } catch {}
112
+ },
113
+ [storageKey],
114
+ )
115
+ const clear = useCallback(() => {
116
+ epoch.current++
117
+ operation.current?.abort()
118
+ operation.current = null
119
+ pending.current = null
120
+ attempt.current = null
121
+ terminal.current = false
122
+ data.current = emptyRecovery('')
123
+ ratingWork.current.clear()
124
+ persist('')
125
+ setRatings({})
126
+ setRatingError(null)
127
+ setDraft('')
128
+ setStatus('idle')
129
+ setRecovering(false)
130
+ setError(null)
131
+ redraw()
132
+ }, [persist, redraw])
133
+ useLayoutEffect(() => {
134
+ alive.current = true
135
+ return () => {
136
+ alive.current = false
137
+ invalidate()
138
+ operation.current?.abort()
139
+ }
140
+ }, [invalidate])
141
+ const settings = runtime.config?.askAi ?? defaults
142
+ const enabled = Boolean(runtime.config?.enabled.askAi)
143
+ useLayoutEffect(() => {
144
+ if (!enabled && operation.current) {
145
+ epoch.current++
146
+ operation.current.abort()
147
+ operation.current = null
148
+ pending.current = null
149
+ setRecovering(false)
150
+ setStatus('error')
151
+ setError('Ask AI is currently unavailable. Your answer may be incomplete.')
152
+ redraw()
153
+ }
154
+ }, [enabled, redraw])
155
+ const report = useCallback((path: string) => {
156
+ if (page.current !== path) {
157
+ titleReady.current = page.current === null
158
+ page.current = path
159
+ }
160
+ }, [])
161
+ const titleChanged = useCallback(() => {
162
+ titleReady.current = page.current === window.location.pathname
163
+ }, [])
164
+ const read = useCallback(
165
+ async (signal: AbortSignal, current: () => boolean, continuation = false) => {
166
+ if (!data.current.conversationId) return
167
+ setRecovering(true)
168
+ if (!continuation)
169
+ data.current = { ...data.current, cursor: null, snapshot: null, fragments: new Map() }
170
+ let bytes = 0
171
+ for (let count = 0; count < 4; count++) {
172
+ const id = data.current.conversationId,
173
+ cursor = data.current.cursor
174
+ let result
175
+ try {
176
+ result = await getTransport().aiConversation(id, cursor, signal)
177
+ } catch (cause) {
178
+ if (current() && cause instanceof FeatureClientError && cause.code === 'conflict') {
179
+ data.current = { ...data.current, cursor: null, snapshot: null, fragments: new Map() }
180
+ setError('History changed. Check the saved answer again.')
181
+ return
182
+ }
183
+ throw cause
184
+ }
185
+ if (!current() || id !== data.current.conversationId) return
186
+ bytes += new TextEncoder().encode(JSON.stringify(result)).length
187
+ if (bytes > 524288) throw new FeatureClientError('too_large')
188
+ data.current = mergeRecoveryPage(data.current, result)
189
+ redraw()
190
+ if (!data.current.cursor) break
191
+ }
192
+ const last =
193
+ attempt.current &&
194
+ data.current.messages.find(
195
+ (m) => m.requestId === attempt.current!.requestId && m.role === 'assistant',
196
+ )
197
+ if (last && ['completed', 'failed', 'aborted', 'unknown'].includes(last.state)) {
198
+ terminal.current = true
199
+ if (last.state === 'completed') {
200
+ attempt.current = null
201
+ setStatus('idle')
202
+ setError(null)
203
+ }
204
+ }
205
+ if (!attempt.current) {
206
+ setStatus('idle')
207
+ setError(null)
208
+ }
209
+ setRecovering(false)
210
+ },
211
+ [getTransport, redraw],
212
+ )
213
+ const recover = useCallback(
214
+ (continuation = false): Promise<void> => {
215
+ if (
216
+ pending.current ||
217
+ !alive.current ||
218
+ !committed.current.config?.enabled.askAi ||
219
+ !data.current.conversationId
220
+ )
221
+ return pending.current ?? Promise.resolve()
222
+ const controller = new AbortController(),
223
+ generation = epoch.current
224
+ operation.current = controller
225
+ const current = () =>
226
+ alive.current &&
227
+ generation === epoch.current &&
228
+ operation.current === controller &&
229
+ !controller.signal.aborted &&
230
+ Boolean(committed.current.config?.enabled.askAi)
231
+ const work = Promise.resolve()
232
+ .then(() => read(controller.signal, current, continuation))
233
+ .catch((cause) => {
234
+ if (current()) {
235
+ if (missing(cause)) clear()
236
+ setError(errorCopy(cause, committed.current.config?.askAi ?? defaults))
237
+ setStatus('error')
238
+ }
239
+ })
240
+ .finally(() => {
241
+ if (operation.current === controller) {
242
+ operation.current = null
243
+ pending.current = null
244
+ setRecovering(false)
245
+ redraw()
246
+ }
247
+ })
248
+ pending.current = work
249
+ redraw()
250
+ return work
251
+ },
252
+ [read, clear, redraw],
253
+ )
254
+ const resumed = useRef(false)
255
+ useEffect(() => {
256
+ if (!enabled || resumed.current) return
257
+ resumed.current = true
258
+ let id: string | null = null
259
+ try {
260
+ id = window.sessionStorage.getItem(storageKey)
261
+ } catch {}
262
+ if (id && /^[A-Za-z0-9_-]{1,128}$/.test(id)) {
263
+ data.current = emptyRecovery(id)
264
+ void recover()
265
+ } else persist('')
266
+ }, [enabled, storageKey, recover, persist])
267
+ const run = useCallback((): Promise<void> => {
268
+ if (
269
+ pending.current ||
270
+ !attempt.current ||
271
+ terminal.current ||
272
+ !alive.current ||
273
+ !committed.current.config?.enabled.askAi
274
+ )
275
+ return pending.current ?? Promise.resolve()
276
+ const body = attempt.current,
277
+ controller = new AbortController(),
278
+ generation = epoch.current,
279
+ signal = AbortSignal.any([controller.signal, AbortSignal.timeout(90000)])
280
+ // Keep the last usable display while a same-ID replay assembles independently.
281
+ // Only its terminal result (or authoritative GET recovery) replaces prior content.
282
+ const retainDisplay = data.current.messages.some(
283
+ (message) =>
284
+ message.requestId === body.requestId &&
285
+ message.role === 'assistant' &&
286
+ (message.text.length > 0 || message.sources.length > 0),
287
+ )
288
+ let replayText = ''
289
+ let replaySources: AiSource[] = []
290
+ operation.current = controller
291
+ setStatus('sending')
292
+ setError(null)
293
+ const current = () =>
294
+ alive.current &&
295
+ generation === epoch.current &&
296
+ operation.current === controller &&
297
+ !signal.aborted &&
298
+ Boolean(committed.current.config?.enabled.askAi)
299
+ const work = Promise.resolve()
300
+ .then(async () => {
301
+ await committed.current.ensureSession(signal)
302
+ if (!current()) return
303
+ await getTransport().aiMessage(body, signal, (event) => {
304
+ if (!current()) return
305
+ if (event.type === 'start') {
306
+ if (data.current.conversationId && data.current.conversationId !== event.conversationId)
307
+ throw new FeatureClientError()
308
+ data.current.conversationId = event.conversationId
309
+ persist(event.conversationId)
310
+ data.current.messages = data.current.messages.map((m) =>
311
+ m.requestId === body.requestId && m.role === 'assistant'
312
+ ? { ...m, id: event.messageId, state: 'streaming' }
313
+ : m,
314
+ )
315
+ setStatus('streaming')
316
+ } else if (
317
+ event.type === 'delta' ||
318
+ event.type === 'sources' ||
319
+ event.type === 'done' ||
320
+ event.type === 'error'
321
+ ) {
322
+ if (event.type === 'delta') replayText += event.text
323
+ if (event.type === 'sources') replaySources = event.sources
324
+ data.current.messages = data.current.messages.map((m) =>
325
+ m.requestId === body.requestId && m.role === 'assistant'
326
+ ? {
327
+ ...m,
328
+ ...(event.type === 'delta'
329
+ ? retainDisplay
330
+ ? {}
331
+ : { text: replayText }
332
+ : event.type === 'sources'
333
+ ? retainDisplay
334
+ ? {}
335
+ : { sources: replaySources }
336
+ : { text: replayText, sources: replaySources, state: event.state }),
337
+ }
338
+ : m,
339
+ )
340
+ if (event.type === 'done') {
341
+ terminal.current = true
342
+ attempt.current = null
343
+ setStatus('idle')
344
+ committed.current.announce('Answer complete.')
345
+ }
346
+ if (event.type === 'error') {
347
+ terminal.current = true
348
+ setStatus('error')
349
+ setError(
350
+ errorCopy(
351
+ new FeatureClientError(event.code),
352
+ committed.current.config?.askAi ?? defaults,
353
+ ),
354
+ )
355
+ }
356
+ }
357
+ redraw()
358
+ })
359
+ })
360
+ .catch(async (cause) => {
361
+ if (!alive.current || generation !== epoch.current || operation.current !== controller)
362
+ return
363
+ if (missing(cause)) {
364
+ clear()
365
+ setError(errorCopy(cause, committed.current.config?.askAi ?? defaults))
366
+ return
367
+ }
368
+ data.current.messages = data.current.messages.map((m) =>
369
+ m.requestId === body.requestId &&
370
+ m.role === 'assistant' &&
371
+ !['completed', 'failed', 'aborted'].includes(m.state)
372
+ ? { ...m, state: 'unknown' }
373
+ : m,
374
+ )
375
+ setStatus('error')
376
+ setError(errorCopy(cause, committed.current.config?.askAi ?? defaults))
377
+ redraw()
378
+ if (current() && data.current.conversationId)
379
+ try {
380
+ await read(signal, current)
381
+ } catch (recoveryError) {
382
+ if (current() && missing(recoveryError)) {
383
+ clear()
384
+ setError(errorCopy(recoveryError, committed.current.config?.askAi ?? defaults))
385
+ }
386
+ }
387
+ })
388
+ .finally(() => {
389
+ if (operation.current === controller) {
390
+ operation.current = null
391
+ pending.current = null
392
+ setRecovering(false)
393
+ redraw()
394
+ }
395
+ })
396
+ pending.current = work
397
+ redraw()
398
+ return work
399
+ }, [getTransport, persist, redraw, clear, read])
400
+ const send = useCallback(
401
+ (message: string, newAttempt = false): Promise<void> => {
402
+ if (pending.current) return pending.current
403
+ if (attempt.current) {
404
+ setError('Check or retry the current answer, or explicitly start a new attempt.')
405
+ return Promise.resolve()
406
+ }
407
+ if (!alive.current || !committed.current.config?.enabled.askAi) return Promise.resolve()
408
+ if (!page.current || page.current !== window.location.pathname) {
409
+ setError('The page is changing. Please try again once it is ready.')
410
+ return Promise.resolve()
411
+ }
412
+ const parsed = AskAiInputSchema.safeParse({
413
+ requestId: crypto.randomUUID(),
414
+ ...(data.current.conversationId ? { conversationId: data.current.conversationId } : {}),
415
+ message,
416
+ page: {
417
+ path: page.current,
418
+ ...(titleReady.current && document.title ? { title: document.title.slice(0, 200) } : {}),
419
+ },
420
+ })
421
+ if (!parsed.success) {
422
+ setError('Enter a message of up to 2,000 characters.')
423
+ return Promise.resolve()
424
+ }
425
+ terminal.current = false
426
+ attempt.current = parsed.data
427
+ const sequence = Math.max(-1, ...data.current.messages.map((m) => m.sequence)) + 1
428
+ data.current.messages.push(
429
+ {
430
+ id: 'user-' + parsed.data.requestId,
431
+ requestId: parsed.data.requestId,
432
+ role: 'user',
433
+ text: parsed.data.message,
434
+ sources: [],
435
+ state: 'completed',
436
+ sequence,
437
+ newAttempt,
438
+ },
439
+ {
440
+ id: 'assistant-' + parsed.data.requestId,
441
+ requestId: parsed.data.requestId,
442
+ role: 'assistant',
443
+ text: '',
444
+ sources: [],
445
+ state: 'queued',
446
+ sequence: sequence + 1,
447
+ },
448
+ )
449
+ setDraft('')
450
+ redraw()
451
+ return run()
452
+ },
453
+ [run, redraw],
454
+ )
455
+ const retryAnswer = useCallback(() => {
456
+ if (!terminal.current || !attempt.current || pending.current) return Promise.resolve()
457
+ const message = attempt.current.message
458
+ attempt.current = null
459
+ return send(message, true)
460
+ }, [send])
461
+ const stop = useCallback(() => {
462
+ operation.current?.abort()
463
+ setStatus('error')
464
+ setError('Stopped. Your answer may be incomplete; check the saved answer before retrying.')
465
+ data.current.messages = data.current.messages.map((m) =>
466
+ m.state === 'streaming' || m.state === 'queued' ? { ...m, state: 'unknown' } : m,
467
+ )
468
+ redraw()
469
+ }, [redraw])
470
+ const rate = useCallback(
471
+ (id: string, rating: 'helpful' | 'unhelpful'): Promise<void> => {
472
+ const message = data.current.messages.find(
473
+ (m) =>
474
+ m.id === id &&
475
+ m.role === 'assistant' &&
476
+ ['completed', 'failed', 'aborted', 'unknown'].includes(m.state),
477
+ )
478
+ if (!message || !committed.current.config?.enabled.askAi) return Promise.resolve()
479
+ const existing = ratingWork.current.get(id)
480
+ if (existing?.pending) return existing.pending
481
+ const receipt =
482
+ existing?.rating === rating ? existing : { requestId: crypto.randomUUID(), rating }
483
+ ratingWork.current.set(id, receipt)
484
+ const generation = epoch.current
485
+ const current = () =>
486
+ alive.current &&
487
+ generation === epoch.current &&
488
+ Boolean(committed.current.config?.enabled.askAi)
489
+ const work = getTransport()
490
+ .aiRating(
491
+ id,
492
+ { requestId: receipt.requestId, rating: receipt.rating },
493
+ AbortSignal.timeout(10000),
494
+ )
495
+ .then(() => {
496
+ if (current()) {
497
+ setRatings((r) => ({ ...r, [id]: rating }))
498
+ setRatingError(null)
499
+ ratingWork.current.delete(id)
500
+ }
501
+ })
502
+ .catch((cause) => {
503
+ if (current()) {
504
+ if (missing(cause)) clear()
505
+ setRatingError(errorCopy(cause, committed.current.config?.askAi ?? defaults))
506
+ }
507
+ })
508
+ .finally(() => {
509
+ delete receipt.pending
510
+ })
511
+ receipt.pending = work
512
+ return work
513
+ },
514
+ [getTransport, clear],
515
+ )
516
+ const value: Omit<AskAiController, 'launcherRef'> = {
517
+ open: runtime.overlay.active === 'askAi',
518
+ messages: view.messages,
519
+ status,
520
+ error,
521
+ ratingError,
522
+ settings,
523
+ draft,
524
+ setDraft,
525
+ recovering,
526
+ hasEarlier: view.hasEarlier,
527
+ canRecover: view.canRecover,
528
+ awaitingAnswer: view.awaitingAnswer,
529
+ canRetry: view.canRetry,
530
+ canNewAttempt: view.canNewAttempt,
531
+ ratings,
532
+ show: () => runtime.overlay.open('askAi'),
533
+ hide: runtime.overlay.close,
534
+ send,
535
+ stop,
536
+ startNewConversation: clear,
537
+ retry: run,
538
+ retryAnswer,
539
+ recover: () => recover(),
540
+ loadEarlier: () => recover(true),
541
+ rate,
542
+ }
543
+ return (
544
+ <AskAiContext.Provider value={value}>
545
+ <Suspense fallback={null}>
546
+ <PageReporter report={report} titleChanged={titleChanged} />
547
+ </Suspense>
548
+ {children}
549
+ </AskAiContext.Provider>
550
+ )
551
+ }