@flamingo-stack/openframe-frontend-core 0.0.207 → 0.0.208

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 (132) hide show
  1. package/dist/{chunk-Z3GQGR5E.js → chunk-2HMZSCJY.js} +3158 -2074
  2. package/dist/chunk-2HMZSCJY.js.map +1 -0
  3. package/dist/chunk-4XLJWX2N.js +12 -0
  4. package/dist/chunk-4XLJWX2N.js.map +1 -0
  5. package/dist/{chunk-APM6KBPU.cjs → chunk-C5EC5AZM.cjs} +1644 -560
  6. package/dist/chunk-C5EC5AZM.cjs.map +1 -0
  7. package/dist/chunk-VFKQMAUF.cjs +12 -0
  8. package/dist/chunk-VFKQMAUF.cjs.map +1 -0
  9. package/dist/components/chat/embeddable-chat.d.ts +35 -2
  10. package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
  11. package/dist/components/chat/hooks/index.d.ts +3 -0
  12. package/dist/components/chat/hooks/index.d.ts.map +1 -1
  13. package/dist/components/chat/hooks/use-embedded-chat.d.ts +10 -169
  14. package/dist/components/chat/hooks/use-embedded-chat.d.ts.map +1 -1
  15. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +85 -0
  16. package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -0
  17. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +124 -0
  18. package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -0
  19. package/dist/components/chat/hooks/use-unified-chat.d.ts +33 -0
  20. package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -0
  21. package/dist/components/chat/index.cjs +8 -2
  22. package/dist/components/chat/index.cjs.map +1 -1
  23. package/dist/components/chat/index.js +11 -5
  24. package/dist/components/chat/types/index.d.ts +1 -0
  25. package/dist/components/chat/types/index.d.ts.map +1 -1
  26. package/dist/components/chat/types/unified-chat-state.types.d.ts +185 -0
  27. package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -0
  28. package/dist/components/features/index.cjs +3 -2
  29. package/dist/components/features/index.cjs.map +1 -1
  30. package/dist/components/features/index.js +2 -1
  31. package/dist/components/index.cjs +26 -2
  32. package/dist/components/index.cjs.map +1 -1
  33. package/dist/components/index.d.ts +4 -0
  34. package/dist/components/index.d.ts.map +1 -1
  35. package/dist/components/index.js +27 -3
  36. package/dist/components/navigation/index.cjs +3 -2
  37. package/dist/components/navigation/index.cjs.map +1 -1
  38. package/dist/components/navigation/index.js +2 -1
  39. package/dist/components/shared/delivery/delivery-lists.d.ts +16 -0
  40. package/dist/components/shared/delivery/delivery-lists.d.ts.map +1 -0
  41. package/dist/components/shared/delivery/delivery-table.d.ts +12 -0
  42. package/dist/components/shared/delivery/delivery-table.d.ts.map +1 -0
  43. package/dist/components/shared/delivery/index.d.ts +3 -0
  44. package/dist/components/shared/delivery/index.d.ts.map +1 -0
  45. package/dist/components/shared/dev-section/dev-section-page.d.ts +31 -0
  46. package/dist/components/shared/dev-section/dev-section-page.d.ts.map +1 -0
  47. package/dist/components/shared/dev-section/dev-section-view.d.ts +34 -0
  48. package/dist/components/shared/dev-section/dev-section-view.d.ts.map +1 -0
  49. package/dist/components/shared/dev-section/index.d.ts +3 -0
  50. package/dist/components/shared/dev-section/index.d.ts.map +1 -0
  51. package/dist/components/shared/legal-document/index.d.ts +10 -0
  52. package/dist/components/shared/legal-document/index.d.ts.map +1 -0
  53. package/dist/components/shared/legal-document/legal-document-page.d.ts +66 -0
  54. package/dist/components/shared/legal-document/legal-document-page.d.ts.map +1 -0
  55. package/dist/components/shared/legal-document/use-legal-docs.d.ts +40 -0
  56. package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -0
  57. package/dist/components/shared/product-release/index.d.ts +2 -1
  58. package/dist/components/shared/product-release/index.d.ts.map +1 -1
  59. package/dist/components/shared/product-release/release-detail-page.d.ts +11 -7
  60. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  61. package/dist/components/shared/roadmap/index.d.ts +18 -0
  62. package/dist/components/shared/roadmap/index.d.ts.map +1 -0
  63. package/dist/components/shared/roadmap/roadmap-grid-skeleton.d.ts +24 -0
  64. package/dist/components/shared/roadmap/roadmap-grid-skeleton.d.ts.map +1 -0
  65. package/dist/components/shared/roadmap/roadmap-grid.d.ts +18 -0
  66. package/dist/components/shared/roadmap/roadmap-grid.d.ts.map +1 -0
  67. package/dist/components/shared/roadmap/use-roadmap-voting.d.ts +25 -0
  68. package/dist/components/shared/roadmap/use-roadmap-voting.d.ts.map +1 -0
  69. package/dist/components/ui/index.cjs +8 -2
  70. package/dist/components/ui/index.cjs.map +1 -1
  71. package/dist/components/ui/index.js +11 -5
  72. package/dist/components/ui/release-changelog-section.d.ts +13 -2
  73. package/dist/components/ui/release-changelog-section.d.ts.map +1 -1
  74. package/dist/embed-shims/index.cjs +1 -6
  75. package/dist/embed-shims/index.cjs.map +1 -1
  76. package/dist/embed-shims/index.js +1 -6
  77. package/dist/embed-shims/index.js.map +1 -1
  78. package/dist/index.cjs +18 -2
  79. package/dist/index.cjs.map +1 -1
  80. package/dist/index.js +19 -3
  81. package/dist/types/delivery.d.ts +49 -0
  82. package/dist/types/delivery.d.ts.map +1 -0
  83. package/dist/types/index.cjs +13 -0
  84. package/dist/types/index.cjs.map +1 -1
  85. package/dist/types/index.d.ts +1 -0
  86. package/dist/types/index.d.ts.map +1 -1
  87. package/dist/types/index.js +12 -1
  88. package/dist/types/index.js.map +1 -1
  89. package/dist/utils/dev-sections/index.d.ts +11 -0
  90. package/dist/utils/dev-sections/index.d.ts.map +1 -0
  91. package/dist/utils/dev-sections/openframe-dev-sections.d.ts +209 -0
  92. package/dist/utils/dev-sections/openframe-dev-sections.d.ts.map +1 -0
  93. package/dist/utils/index.cjs +82 -0
  94. package/dist/utils/index.cjs.map +1 -1
  95. package/dist/utils/index.d.ts +1 -0
  96. package/dist/utils/index.d.ts.map +1 -1
  97. package/dist/utils/index.js +81 -2
  98. package/dist/utils/index.js.map +1 -1
  99. package/package.json +1 -1
  100. package/src/components/chat/embeddable-chat.tsx +123 -8
  101. package/src/components/chat/hooks/index.ts +9 -2
  102. package/src/components/chat/hooks/use-embedded-chat.ts +18 -1016
  103. package/src/components/chat/hooks/use-nats-chat-adapter.ts +372 -0
  104. package/src/components/chat/hooks/use-sse-chat-adapter.ts +1058 -0
  105. package/src/components/chat/hooks/use-unified-chat.ts +171 -0
  106. package/src/components/chat/types/index.ts +1 -0
  107. package/src/components/chat/types/unified-chat-state.types.ts +215 -0
  108. package/src/components/index.ts +8 -0
  109. package/src/components/shared/delivery/delivery-lists.tsx +199 -0
  110. package/src/components/shared/delivery/delivery-table.tsx +174 -0
  111. package/src/components/shared/delivery/index.ts +9 -0
  112. package/src/components/shared/dev-section/dev-section-page.tsx +72 -0
  113. package/src/components/shared/dev-section/dev-section-view.tsx +129 -0
  114. package/src/components/shared/dev-section/index.ts +2 -0
  115. package/src/components/shared/legal-document/index.ts +19 -0
  116. package/src/components/shared/legal-document/legal-document-page.tsx +178 -0
  117. package/src/components/shared/legal-document/use-legal-docs.ts +123 -0
  118. package/src/components/shared/product-release/index.ts +14 -3
  119. package/src/components/shared/product-release/release-detail-page.tsx +45 -7
  120. package/src/components/shared/roadmap/index.ts +23 -0
  121. package/src/components/shared/roadmap/roadmap-grid-skeleton.tsx +74 -0
  122. package/src/components/shared/roadmap/roadmap-grid.tsx +106 -0
  123. package/src/components/shared/roadmap/use-roadmap-voting.ts +163 -0
  124. package/src/components/ui/release-changelog-section.tsx +113 -32
  125. package/src/stories/EmbeddableChat.stories.tsx +186 -0
  126. package/src/types/delivery.ts +54 -0
  127. package/src/types/index.ts +1 -0
  128. package/src/utils/dev-sections/index.ts +17 -0
  129. package/src/utils/dev-sections/openframe-dev-sections.ts +148 -0
  130. package/src/utils/index.ts +6 -1
  131. package/dist/chunk-APM6KBPU.cjs.map +0 -1
  132. package/dist/chunk-Z3GQGR5E.js.map +0 -1
@@ -1,1023 +1,25 @@
1
1
  'use client'
2
2
 
3
3
  /**
4
- * useEmbeddedChat — the streamed-AI-chat hook for the OSS-lib chat panel.
4
+ * Backward-compatibility shim.
5
5
  *
6
- * Two key contracts vs hub-side chat hooks:
6
+ * The canonical SSE/Guide-mode chat adapter has been renamed to
7
+ * `useSseChatAdapter` and moved to `./use-sse-chat-adapter`. This module
8
+ * re-exports it under the legacy `useEmbeddedChat` name plus the legacy
9
+ * options type, so external consumers and barrel imports keep working
10
+ * without churn.
7
11
  *
8
- * 1. `source` is READ FROM THE RUNTIME, not a parameter. Throws loudly
9
- * if `runtime.source` is empty so embedders that forget to wire
10
- * identity get an immediate signal rather than a silently-empty
11
- * localStorage namespace.
12
- *
13
- * 2. Navigation is runtime-provided. The hub-side `useDocSearch` keeps
14
- * its own `decideNewTab` + `useDocNavigation.navigate` plumbing for
15
- * the search autocomplete (it calls `router.push`). The chat body
16
- * doesn't navigate via this hook — click handlers on rendered
17
- * cards/chips go through `handleChatNavClick` instead.
18
- *
19
- * 3. `tableIdForDocumentType` is INJECTED via the optional
20
- * `tableIdForDocumentType` parameter. Hub callers pass the
21
- * registry-backed lookup from `lib/config/rag-table-config`;
22
- * embedders that don't supply one fall back to
23
- * `defaultTableIdForDocumentType` (the lib-baked map covering
24
- * every documentType currently registered in the hub) so the
25
- * `displayRef` / `discussRef` Ask + Display buttons WORK out of
26
- * the box. Override only when you have polymorphic / per-tenant
27
- * types the default doesn't cover.
28
- *
29
- * Wire format and SSE-parser logic mirror the hub original byte-for-byte
30
- * so server-side and client-side stay in lockstep across the migration.
31
- */
32
-
33
- import { useState, useEffect, useCallback, useMemo, useRef, type MutableRefObject } from 'react'
34
- import { useChat, type Message, type StreamFnExtraOptions } from './use-chat'
35
- import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
36
- import type { ChatRef } from '../chat-ref.types'
37
- import { buildChatRefKey } from '../types/chat.types'
38
- import type { MessageSegment } from '../types/message.types'
39
- import { fetchSlashCommands, type SlashCommandSummary } from './use-slash-commands'
40
- import { getChatProxyAuth } from '../utils/chat-proxy-auth-storage'
41
- import { chatAuthedFetch } from '../utils/chat-authed-fetch'
42
- import { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor'
43
- import { AUTO_CONTINUATION_DIRECTIVE_PREFIX } from '../utils/auto-continuation-directive'
44
- import { flattenAssistantContent } from '../utils/flatten-assistant-content'
45
- import { sanitizeTitleForChat } from '../utils/slash-dispatch-utils'
46
- import { defaultTableIdForDocumentType } from '../utils/source-icons'
47
-
48
- // =============================================================================
49
- // Public types
50
- // =============================================================================
51
-
52
- /** Source identifier — opaque string ID (registry lookup happens in the
53
- * hub-side platform-utils, not in lib). */
54
- export type DocSource = string
55
-
56
- export interface ChatSource {
57
- index: number
58
- name: string
59
- path: string
60
- documentType: string
61
- externalUrl?: string
62
- /** Platform that owns the destination at `externalUrl`. */
63
- targetPlatform?: string | null
64
- /** Primary-key value for single-row chips. */
65
- id?: string
66
- /** Per-row items for grouped chips. */
67
- items?: Array<{
68
- id: string
69
- documentType: string
70
- name: string
71
- externalUrl?: string
72
- targetPlatform?: string | null
73
- /** In-app doc-tree path for markdown / data-room-doc rows so the
74
- * grouped chip's anchor can trigger an in-page doc-tree swap via
75
- * `handleChatNavClick` (parity with single-row chips + cards). */
76
- path?: string | null
77
- }>
78
- /** RagTableConfig.id for this source. */
79
- sourceRepo?: string
80
- /** Optional display label override returned by the chat API. */
81
- label?: string
82
- }
83
-
84
- export interface DocChatMessage {
85
- id: string
86
- role: 'user' | 'assistant'
87
- /** String form for legacy callers that just want the answer text; structured
88
- * segments include thinking blocks too, which the lib's
89
- * ChatMessageEnhanced renders as <ThinkingDisplay> cards. */
90
- content: string
91
- /** Structured segments. When set, callers should prefer this over `content`. */
92
- segments?: MessageSegment[]
93
- sources?: ChatSource[]
94
- /** Per-row refs for inline object-card rendering. Keyed by
95
- * `<documentType>:<primaryKey>`. Populated for assistant messages only. */
96
- chatRefs?: Record<string, ChatRef>
97
- /** Per-message viewport-positioning hint emitted by the server. */
98
- scrollAnchor?: ScrollAnchor
99
- /** When true the message is part of the conversation history but is
100
- * NOT rendered in the chat UI. */
101
- hidden?: boolean
102
- }
103
-
104
- /** Streaming phase exposed to the chat shell so it can render a "Thinking with
105
- * Sonnet 4.6 · 200K context window" status row above the input box.
106
- * - 'idle' → no in-flight turn
107
- * - 'thinking' → server emitted the leading {status:'thinking'} frame; no
108
- * text token has arrived yet (adaptive-thinking phase)
109
- * - 'streaming' → first text token has arrived; model is generating answer */
110
- export type StreamingPhase = 'idle' | 'thinking' | 'streaming'
111
-
112
- /** Per-turn metadata extracted from the streamed metadata frame. */
113
- export interface ChatTurnMeta {
114
- /** Provider key recognized by `<ModelDisplay>` for icon
115
- * selection: 'anthropic' | 'openai' | 'google' (case-insensitive). */
116
- provider: string | null
117
- modelLabel: string | null
118
- contextWindowMaxTokens: number | null
119
- /** Input tokens (from message_start). Includes cached tokens. */
120
- inputTokens: number | null
121
- /** Output tokens (from message_delta). Only known after stream end. */
122
- outputTokens: number | null
123
- /** Cache hit % (read / total-input × 100). Only known after stream end. */
124
- cacheHitRatePct: number | null
125
- /** Cross-call usage breakdown extracted from the trailing usage frame. */
126
- breakdown: {
127
- haikuRewriter?: { input: number; output: number }
128
- haikuClassifier?: { input: number; output: number }
129
- haikuSummarizer?: { input: number; output: number }
130
- routedAnswer?: { model: string; complexity: string; thinkingBudget: number }
131
- } | null
132
- /** Per-message viewport-positioning hint. */
133
- scrollAnchor: ScrollAnchor | null
134
- /** Routing decision from the server's `decideRoute`. */
135
- routedComplexity: string | null
136
- routedThinkingBudget: number | null
137
- }
138
-
139
- /**
140
- * Optional dependency-injection options for `useEmbeddedChat`.
141
- *
142
- * - `tableIdForDocumentType` — looks up the RagTableConfig.id for an
143
- * LLM document type. Used by `displayRef` + `discussRef` to translate
144
- * an inline-card click into a server-side entity-id filter.
145
- *
146
- * **Defaults to `defaultTableIdForDocumentType` from
147
- * `src/utils/source-icons.ts`** — a lib-baked map covering every
148
- * documentType currently registered in the hub's RAG_TABLE_CONFIGS.
149
- * This makes Ask / Display work out of the box in embedders without
150
- * any callback wiring.
151
- *
152
- * Override only when you have polymorphic / per-tenant document
153
- * types that map to a different tableId than the default
154
- * (hub-canonical) registry — pass your own `(docType) => tableId`
155
- * callback and it wins over the default.
156
- */
157
- export interface UseEmbeddedChatOptions {
158
- tableIdForDocumentType?: (documentType: string) => string | null
159
- }
160
-
161
- // =============================================================================
162
- // Internal helpers
163
- // =============================================================================
164
-
165
- /** Single source of truth for a fresh `ChatTurnMeta` row. */
166
- function createEmptyTurnMeta(): ChatTurnMeta {
167
- return {
168
- provider: null,
169
- modelLabel: null,
170
- contextWindowMaxTokens: null,
171
- inputTokens: null,
172
- outputTokens: null,
173
- cacheHitRatePct: null,
174
- breakdown: null,
175
- scrollAnchor: null,
176
- routedComplexity: null,
177
- routedThinkingBudget: null,
178
- }
179
- }
180
-
181
- /**
182
- * Escape `<` so the lib's `SimpleMarkdownRenderer` (which uses `rehypeRaw`
183
- * to pass HTML through) doesn't treat XML-like tokens in Claude's
184
- * thinking output as React components. Escaping `<` → `&lt;` preserves
185
- * the visible character without breaking blockquote `>` markers.
186
- */
187
- function escapeThinkingTags(text: string): string {
188
- return text.replace(/</g, '&lt;')
189
- }
190
-
191
- function createDocStreamFn(
192
- source: DocSource,
193
- endpoints: { chatStreamUrl: string; approvalToolUrl: string },
194
- messagesRef: MutableRefObject<Message[]>,
195
- sourcesMapRef: MutableRefObject<Map<number, ChatSource[]>>,
196
- refsMapRef: MutableRefObject<Map<number, Record<string, ChatRef>>>,
197
- metaMapRef: MutableRefObject<Map<number, ChatTurnMeta>>,
198
- setStreamingPhase: (phase: StreamingPhase) => void,
199
- bumpMetaTick: () => void,
200
- sendCountRef: MutableRefObject<number>,
201
- ) {
202
- // CRITICAL: the decoder + buffer MUST live INSIDE the returned async-
203
- // generator function (per-call closure), NOT at the factory level. A
204
- // rapid send-stop-send sequence with hook-level state would feed the
205
- // second stream's first chunk into the first stream's tail buffer,
206
- // corrupting metadata-frame parsing.
207
- return async function* (
208
- message: string,
209
- signal?: AbortSignal,
210
- extra?: StreamFnExtraOptions,
211
- ): AsyncGenerator<MessageSegment> {
212
- const currentMessages = messagesRef.current || []
213
- // Filter `hidden:true` messages out of the API history. The approval-
214
- // action turn injects a hidden user message with `content=''`.
215
- // `flattenAssistantContent` joins text-segment arrays into a single
216
- // string so the server sees the receipt + auto-continuation Qs.
217
- const apiMessages = [
218
- ...currentMessages
219
- .filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.hidden)
220
- .map((m) => ({
221
- role: m.role,
222
- content:
223
- typeof m.content === 'string' ? m.content : flattenAssistantContent(m.content),
224
- })),
225
- { role: 'user', content: message },
226
- ]
227
-
228
- // URL + body branch — approvalAction routes to the approval-tool
229
- // endpoint, the standard chat path routes to the chat-stream endpoint.
230
- const targetPath = extra?.approvalAction
231
- ? endpoints.approvalToolUrl
232
- : endpoints.chatStreamUrl
233
- // `source` is INTENTIONALLY NOT in the body. The chat route resolves
234
- // it server-side via its own platform-detection — tamper-proof binding
235
- // so a client on one platform can't POST a different platform's
236
- // conversation.
237
- const requestBody = extra?.approvalAction
238
- ? {
239
- proposal_id: extra.approvalAction.proposalId,
240
- action: extra.approvalAction.action,
241
- messages: currentMessages
242
- .filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.hidden)
243
- .map((m) => ({
244
- role: m.role,
245
- content:
246
- typeof m.content === 'string'
247
- ? m.content
248
- : flattenAssistantContent(m.content),
249
- })),
250
- }
251
- : {
252
- messages: apiMessages,
253
- ...(extra?.commandOverride ? { commandOverride: extra.commandOverride } : {}),
254
- ...(extra?.pendingAttachments && extra.pendingAttachments.length > 0
255
- ? { pendingAttachments: extra.pendingAttachments }
256
- : {}),
257
- }
258
- // `chatAuthedFetch` carries the bearer-act-as headers (+ Supabase
259
- // session cookies) — same wrapper `use-chat-attachments` and
260
- // `use-chat-identity` use, so all three chat-side fetch sites share
261
- // one identity-propagation primitive.
262
- const response = await chatAuthedFetch(targetPath, {
263
- method: 'POST',
264
- body: JSON.stringify(requestBody),
265
- signal,
266
- })
267
-
268
- if (!response.ok) {
269
- throw new Error(`Chat request failed: ${response.status}`)
270
- }
271
-
272
- const reader = response.body?.getReader()
273
- if (!reader) throw new Error('No response body')
274
-
275
- const decoder = new TextDecoder()
276
- let buffer = ''
277
- let inText = false
278
- // Live thinking accumulator — coalesced to ~20fps to avoid a re-render
279
- // storm (Anthropic emits 30-60 deltas/sec).
280
- let thinkingAccum = ''
281
- let lastThinkingYieldTime = 0
282
- let pendingThinkingYield = false
283
- const THINKING_YIELD_INTERVAL_MS = 50
284
- let trailerBuffer = ''
285
- let inTrailer = false
286
-
287
- const sendIdx = sendCountRef.current - 1
288
- while (true) {
289
- const { done, value } = await reader.read()
290
- if (done) break
291
-
292
- const chunk = decoder.decode(value, { stream: true })
293
-
294
- if (!inText) {
295
- buffer += chunk
296
- while (!inText) {
297
- const recIdx = buffer.indexOf('\x1E')
298
- const nullIdx = buffer.indexOf('\0')
299
- if (recIdx !== -1 && (nullIdx === -1 || recIdx < nullIdx)) {
300
- if (pendingThinkingYield) {
301
- pendingThinkingYield = false
302
- yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
303
- }
304
- inText = true
305
- setStreamingPhase('streaming')
306
- const after = buffer.slice(recIdx + 1)
307
- buffer = ''
308
- if (after) {
309
- // The `after` slice may ALSO contain the `\x1F` trailing-
310
- // usage sentinel — common for fixed-answer responses where
311
- // the whole frame sequence arrives in ONE TCP chunk.
312
- const unitIdx = after.indexOf('\x1F')
313
- if (unitIdx === -1) {
314
- yield { type: 'text', text: after }
315
- } else {
316
- const textBefore = after.slice(0, unitIdx)
317
- const trailerAfter = after.slice(unitIdx + 1)
318
- if (textBefore) {
319
- yield { type: 'text', text: textBefore }
320
- }
321
- inTrailer = true
322
- trailerBuffer = trailerAfter
323
- }
324
- }
325
- break
326
- }
327
- if (nullIdx === -1) break // need more bytes
328
- const metaStr = buffer.slice(0, nullIdx)
329
- const remaining = buffer.slice(nullIdx + 1)
330
- let meta: any
331
- try {
332
- meta = JSON.parse(metaStr)
333
- } catch {
334
- // Not JSON — start of answer body.
335
- inText = true
336
- setStreamingPhase('streaming')
337
- if (buffer.length > 0) {
338
- yield { type: 'text', text: buffer }
339
- buffer = ''
340
- }
341
- break
342
- }
343
- if (meta.status === 'thinking') {
344
- setStreamingPhase('thinking')
345
- } else if (meta.kind === 'thinking-delta' && typeof meta.text === 'string') {
346
- thinkingAccum += meta.text
347
- const now = Date.now()
348
- if (now - lastThinkingYieldTime >= THINKING_YIELD_INTERVAL_MS) {
349
- lastThinkingYieldTime = now
350
- pendingThinkingYield = false
351
- yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
352
- } else {
353
- pendingThinkingYield = true
354
- }
355
- } else if (meta.kind === 'usage' && meta.stage === 'start') {
356
- mergeTurnMeta(metaMapRef, sendIdx, {
357
- inputTokens: meta.input_tokens ?? null,
358
- })
359
- bumpMetaTick()
360
- } else if (
361
- meta.kind === 'decision_resolved' &&
362
- typeof meta.action === 'string'
363
- ) {
364
- // Server-driven post-approve / post-reject frame.
365
- const action = meta.action === 'rejected' ? 'rejected' : 'approved'
366
- const toolName =
367
- typeof meta.tool_name === 'string' ? meta.tool_name : undefined
368
- const result = (meta.result ?? null) as
369
- | { ticket_id?: string; status?: string | null; mirror_synced?: boolean }
370
- | null
371
- const card = (meta.card ?? null) as
372
- | { type?: string; marker?: string; ref?: ChatRef }
373
- | null
374
- if (card?.ref?.id && card?.type) {
375
- const existing = refsMapRef.current.get(sendIdx) ?? {}
376
- const key = buildChatRefKey(card.type, card.ref.id)
377
- refsMapRef.current.set(sendIdx, { ...existing, [key]: card.ref })
378
- bumpMetaTick()
379
- }
380
- yield {
381
- type: 'decision_resolved',
382
- action,
383
- ok: meta.ok === true,
384
- willAutoContinue: meta.willAutoContinue === true,
385
- ...(toolName ? { toolName } : {}),
386
- ...(result ? { result } : {}),
387
- ...(card?.marker ? { marker: card.marker } : {}),
388
- ...(card?.ref ? { cardRef: card.ref } : {}),
389
- ...(typeof meta.receiptText === 'string'
390
- ? { receiptText: meta.receiptText }
391
- : {}),
392
- proposalId:
393
- typeof meta.proposalId === 'string' ? meta.proposalId : undefined,
394
- } as any
395
- } else if (meta.kind === 'approval_request' && meta.proposalId) {
396
- // The model called a write tool. Server persisted the
397
- // proposal and sent us a CARD-READY payload.
398
- const proposalId = String(meta.proposalId)
399
- const toolName = String(meta.toolName ?? 'tool')
400
- const headline =
401
- typeof meta.title === 'string' && meta.title.length > 0
402
- ? meta.title
403
- : toolName
404
- const rawFields = Array.isArray(meta.fields)
405
- ? (meta.fields as Array<{ label?: string; value?: string }>)
406
- : []
407
- const fields: Array<{ label: string; value: string }> = []
408
- for (const f of rawFields) {
409
- if (!f || !f.label || !f.value) continue
410
- fields.push({ label: f.label, value: f.value })
411
- }
412
- yield {
413
- type: 'approval_request',
414
- data: {
415
- command: headline,
416
- fields,
417
- requestId: proposalId,
418
- approvalType: toolName,
419
- },
420
- status: 'pending',
421
- } as any
422
- } else if (meta.kind === 'text-leading' && typeof meta.text === 'string') {
423
- // Model's preamble text (the prose written BEFORE the tool_use
424
- // block) — emitted as a standalone text segment.
425
- yield { type: 'text', text: meta.text }
426
- } else if (meta.kind === 'tool_error') {
427
- const msg =
428
- typeof meta.message === 'string' && meta.message.length > 0
429
- ? meta.message
430
- : 'Could not complete the requested action right now.'
431
- yield {
432
- type: 'text',
433
- text: `⚠️ ${msg}`,
434
- } as any
435
- } else if (meta.kind === 'routing') {
436
- if (typeof meta.routedComplexity === 'string') {
437
- mergeTurnMeta(metaMapRef, sendIdx, {
438
- routedComplexity: meta.routedComplexity,
439
- routedThinkingBudget:
440
- typeof meta.routedThinkingBudget === 'number'
441
- ? meta.routedThinkingBudget
442
- : null,
443
- })
444
- bumpMetaTick()
445
- }
446
- } else {
447
- if (meta.sources) {
448
- sourcesMapRef.current.set(sendIdx, meta.sources)
449
- }
450
- // Per-row refs for inline object cards.
451
- if (meta.refs && typeof meta.refs === 'object') {
452
- refsMapRef.current.set(sendIdx, meta.refs as Record<string, ChatRef>)
453
- bumpMetaTick()
454
- }
455
- if (
456
- meta.modelLabel ||
457
- meta.contextWindowMaxTokens ||
458
- meta.provider ||
459
- meta.model
460
- ) {
461
- mergeTurnMeta(metaMapRef, sendIdx, {
462
- provider: meta.provider ?? null,
463
- modelLabel: meta.modelLabel ?? null,
464
- contextWindowMaxTokens: meta.contextWindowMaxTokens ?? null,
465
- })
466
- bumpMetaTick()
467
- }
468
- const parsedAnchor = parseScrollAnchor(meta.scrollAnchor)
469
- if (parsedAnchor !== null) {
470
- mergeTurnMeta(metaMapRef, sendIdx, { scrollAnchor: parsedAnchor })
471
- bumpMetaTick()
472
- }
473
- }
474
- buffer = remaining
475
- }
476
- } else if (inTrailer) {
477
- trailerBuffer += chunk
478
- } else {
479
- setStreamingPhase('streaming')
480
- // Trailing usage frame uses \x1F (Unit Separator) as a sentinel.
481
- const sepIdx = chunk.indexOf('\x1F')
482
- if (sepIdx === -1) {
483
- yield { type: 'text', text: chunk }
484
- } else {
485
- const before = chunk.slice(0, sepIdx)
486
- const after = chunk.slice(sepIdx + 1)
487
- if (before) yield { type: 'text', text: before }
488
- inTrailer = true
489
- trailerBuffer = after
490
- }
491
- }
492
- }
493
- // Stream ended. Flush any pending thinking text that didn't make it
494
- // out before the throttle window expired.
495
- if (pendingThinkingYield) {
496
- pendingThinkingYield = false
497
- yield { type: 'thinking', text: escapeThinkingTags(thinkingAccum) }
498
- }
499
- // Parse trailer if present.
500
- if (trailerBuffer.length > 0) {
501
- try {
502
- const meta = JSON.parse(trailerBuffer)
503
- if (
504
- meta.kind === 'usage' &&
505
- (meta.stage === 'end' || meta.stage === 'display')
506
- ) {
507
- const breakdown =
508
- meta.breakdown && typeof meta.breakdown === 'object'
509
- ? {
510
- haikuRewriter:
511
- meta.breakdown.haikuRewriter &&
512
- typeof meta.breakdown.haikuRewriter.input === 'number'
513
- ? meta.breakdown.haikuRewriter
514
- : undefined,
515
- haikuClassifier:
516
- meta.breakdown.haikuClassifier &&
517
- typeof meta.breakdown.haikuClassifier.input === 'number'
518
- ? meta.breakdown.haikuClassifier
519
- : undefined,
520
- haikuSummarizer:
521
- meta.breakdown.haikuSummarizer &&
522
- typeof meta.breakdown.haikuSummarizer.input === 'number'
523
- ? meta.breakdown.haikuSummarizer
524
- : undefined,
525
- routedAnswer:
526
- meta.breakdown.routedAnswer &&
527
- typeof meta.breakdown.routedAnswer.model === 'string'
528
- ? meta.breakdown.routedAnswer
529
- : undefined,
530
- }
531
- : null
532
- mergeTurnMeta(metaMapRef, sendIdx, {
533
- inputTokens: meta.input_tokens ?? null,
534
- outputTokens: meta.output_tokens ?? null,
535
- cacheHitRatePct:
536
- typeof meta.hit_rate_pct === 'number' ? meta.hit_rate_pct : null,
537
- ...(breakdown ? { breakdown } : {}),
538
- })
539
- bumpMetaTick()
540
- }
541
- } catch {
542
- // Malformed trailer — silently ignore.
543
- }
544
- }
545
- }
546
- }
547
-
548
- /**
549
- * Merge partial fields into the per-turn meta map. Preserves existing
550
- * non-null values so a leading frame's data isn't wiped by a later frame
551
- * that didn't include it.
552
- */
553
- function mergeTurnMeta(
554
- ref: MutableRefObject<Map<number, ChatTurnMeta>>,
555
- sendIdx: number,
556
- partial: Partial<ChatTurnMeta>,
557
- ): void {
558
- const prev = ref.current.get(sendIdx) ?? createEmptyTurnMeta()
559
- const filtered = Object.fromEntries(
560
- Object.entries(partial).filter(([, v]) => v !== undefined && v !== null),
561
- ) as Partial<ChatTurnMeta>
562
- ref.current.set(sendIdx, { ...prev, ...filtered })
563
- }
564
-
565
- // =============================================================================
566
- // localStorage persistence
567
- // =============================================================================
568
-
569
- const CHAT_STORAGE_VERSION = 1
570
-
571
- /** Storage key — includes the proxy-auth impersonation email when
572
- * present so each impersonated customer keeps a SEPARATE chat history. */
573
- const chatStorageKey = (source: DocSource): string => {
574
- const base = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}`
575
- const auth = getChatProxyAuth()
576
- if (auth?.email) {
577
- return `${base}-u-${encodeURIComponent(auth.email.toLowerCase())}`
578
- }
579
- return base
580
- }
581
-
582
- /** Sweep stale per-user chat-history keys. Drops any key whose email
583
- * differs from the CURRENT proxy-auth identity. */
584
- function pruneStaleChatStorage(source: DocSource): void {
585
- if (typeof window === 'undefined') return
586
- try {
587
- const currentKey = chatStorageKey(source)
588
- const prefix = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}-u-`
589
- const legacy = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}`
590
- const toRemove: string[] = []
591
- for (let i = 0; i < window.localStorage.length; i++) {
592
- const k = window.localStorage.key(i)
593
- if (!k) continue
594
- if (!k.startsWith(prefix)) continue
595
- if (k === currentKey) continue
596
- if (k === legacy) continue
597
- toRemove.push(k)
598
- }
599
- for (const k of toRemove) {
600
- window.localStorage.removeItem(k)
601
- }
602
- } catch {
603
- // localStorage access blocked (Safari private mode etc.) — non-fatal.
604
- }
605
- }
606
-
607
- interface PersistedChatState {
608
- messages: Message[]
609
- sources: Array<[number, ChatSource[]]>
610
- /** Per-turn refs for inline object cards. */
611
- refs?: Array<[number, Record<string, ChatRef>]>
612
- sendCount: number
613
- }
614
-
615
- function loadPersistedChat(source: DocSource): PersistedChatState | null {
616
- if (typeof window === 'undefined') return null
617
- try {
618
- const raw = window.localStorage.getItem(chatStorageKey(source))
619
- if (!raw) return null
620
- const parsed = JSON.parse(raw) as PersistedChatState
621
- if (!parsed || !Array.isArray(parsed.messages)) return null
622
- // Rehydrate Date objects + run forward-compat migrations.
623
- for (const m of parsed.messages) {
624
- if (typeof m.timestamp === 'string') m.timestamp = new Date(m.timestamp)
625
- // Forward-migration for auto-continuation directive bubbles.
626
- if (
627
- m.role === 'user' &&
628
- !m.hidden &&
629
- typeof m.content === 'string' &&
630
- m.content.startsWith(AUTO_CONTINUATION_DIRECTIVE_PREFIX)
631
- ) {
632
- m.hidden = true
633
- }
634
- }
635
- return parsed
636
- } catch {
637
- return null
638
- }
639
- }
640
-
641
- function savePersistedChat(source: DocSource, state: PersistedChatState) {
642
- if (typeof window === 'undefined') return
643
- try {
644
- window.localStorage.setItem(chatStorageKey(source), JSON.stringify(state))
645
- } catch {
646
- // Quota exceeded or private mode — silently drop.
647
- }
648
- }
649
-
650
- // =============================================================================
651
- // useEmbeddedChat — public hook
652
- // =============================================================================
653
-
654
- /**
655
- * Stream-driven AI chat hook. Returns the message list, send/stop/clear
656
- * controls, and per-turn metadata (model name, token counts, routing
657
- * decision) the lib's `<ChatContainer>` consumes.
658
- *
659
- * Source identity comes from `useRequiredChatRuntime().source` — no
660
- * parameter. Throws if `runtime.source` is empty.
12
+ * Internal lib code should import directly from `./use-sse-chat-adapter`.
13
+ * This shim will be removed once all external consumers migrate to the
14
+ * unified `useChat({ mode })` entry point.
661
15
  */
662
- export function useEmbeddedChat(options?: UseEmbeddedChatOptions) {
663
- // Chat-specific code REQUIRES a runtime — the lib's `<HubRuntimeProvider>`
664
- // (hub) / embedder's provider must wrap the tree.
665
- const runtime = useRequiredChatRuntime()
666
- const source = runtime.source
667
- if (!source) {
668
- throw new Error(
669
- '[useEmbeddedChat] runtime.source is required — got empty string. ' +
670
- 'Wire `source` on your <ChatRuntimeContext.Provider value={...}>. ' +
671
- 'Hub default: <HubRuntimeProvider source={currentPlatform()}>; ' +
672
- 'embedded apps: pass your own platform/tenant identifier.',
673
- )
674
- }
675
- // Fall back to the lib-baked hub-canonical map when the embedder
676
- // didn't supply an override. Keeps Ask + Display working in any
677
- // mount that doesn't have a custom documentType registry. Embedders
678
- // with polymorphic / per-tenant types pass their own callback and it
679
- // wins.
680
- const tableIdForDocumentType =
681
- options?.tableIdForDocumentType ?? defaultTableIdForDocumentType
682
-
683
- // Restore persisted state once on mount.
684
- const persistedRef = useRef<PersistedChatState | null>(null)
685
- if (persistedRef.current === null) {
686
- pruneStaleChatStorage(source)
687
- persistedRef.current =
688
- loadPersistedChat(source) || { messages: [], sources: [], sendCount: 0 }
689
- }
690
-
691
- const sourcesMapRef = useRef<Map<number, ChatSource[]>>(
692
- new Map(persistedRef.current.sources),
693
- )
694
- const refsMapRef = useRef<Map<number, Record<string, ChatRef>>>(
695
- new Map(persistedRef.current.refs ?? []),
696
- )
697
- const metaMapRef = useRef<Map<number, ChatTurnMeta>>(new Map())
698
- const messagesRef = useRef<Message[]>(persistedRef.current.messages)
699
- const sendCountRef = useRef(persistedRef.current.sendCount)
700
- const [streamingPhase, setStreamingPhase] = useState<StreamingPhase>('idle')
701
- const [metaTick, setMetaTick] = useState(0)
702
- const bumpMetaTick = useCallback(() => setMetaTick((t) => t + 1), [])
703
-
704
- const streamFn = useMemo(
705
- () =>
706
- createDocStreamFn(
707
- source,
708
- {
709
- chatStreamUrl: runtime.endpoints.chatStreamUrl,
710
- approvalToolUrl: runtime.endpoints.approvalToolUrl,
711
- },
712
- messagesRef,
713
- sourcesMapRef,
714
- refsMapRef,
715
- metaMapRef,
716
- setStreamingPhase,
717
- bumpMetaTick,
718
- sendCountRef,
719
- ),
720
- [
721
- source,
722
- runtime.endpoints.chatStreamUrl,
723
- runtime.endpoints.approvalToolUrl,
724
- bumpMetaTick,
725
- ],
726
- )
727
-
728
- // Per-source tableId → slash-command-id lookup. Hydrated from the
729
- // commands endpoint on mount. Used by `displayRef` to translate an
730
- // inline-card's Display click into a `/<cmd> display "<value>"`
731
- // invocation, picking the canonical command for the row's RAG table.
732
- const [cmdIdByTableId, setCmdIdByTableId] = useState<Map<string, string>>(
733
- () => new Map(),
734
- )
735
- const commandsUrl = runtime.endpoints.commandsUrl
736
- useEffect(() => {
737
- let cancelled = false
738
- const ctrl = new AbortController()
739
- fetchSlashCommands('', ctrl.signal, commandsUrl)
740
- .then((commands) => {
741
- if (cancelled) return
742
- const buckets = new Map<string, SlashCommandSummary[]>()
743
- for (const cmd of commands) {
744
- if (!cmd.primarySourceId) continue
745
- const arr = buckets.get(cmd.primarySourceId) ?? []
746
- arr.push(cmd)
747
- buckets.set(cmd.primarySourceId, arr)
748
- }
749
- const map = new Map<string, string>()
750
- for (const [tableId, cmds] of buckets) {
751
- const display = cmds.find((c) => c.actions.some((a) => a.id === 'display'))
752
- const picked =
753
- display ??
754
- [...cmds].sort((a, b) => {
755
- const ao = a.displayOrder ?? Number.POSITIVE_INFINITY
756
- const bo = b.displayOrder ?? Number.POSITIVE_INFINITY
757
- return ao - bo
758
- })[0]
759
- if (picked) map.set(tableId, picked.id)
760
- }
761
- setCmdIdByTableId(map)
762
- })
763
- .catch((err) => {
764
- if (!cancelled && (err as Error)?.name !== 'AbortError') {
765
- console.warn(
766
- '[useEmbeddedChat] failed to fetch slash commands for displayRef:',
767
- err,
768
- )
769
- }
770
- })
771
- return () => {
772
- cancelled = true
773
- ctrl.abort()
774
- }
775
- }, [source, commandsUrl])
776
-
777
- // Persist on every messages change. Sources + sendCount live in refs,
778
- // so we read their current values at write time.
779
- const persist = useCallback(
780
- (nextMessages: Message[]) => {
781
- savePersistedChat(source, {
782
- messages: nextMessages,
783
- sources: Array.from(sourcesMapRef.current.entries()),
784
- refs: Array.from(refsMapRef.current.entries()),
785
- sendCount: sendCountRef.current,
786
- })
787
- },
788
- [source],
789
- )
790
-
791
- const {
792
- messages,
793
- isTyping,
794
- isStreaming,
795
- sendMessage: chatSendMessage,
796
- stopMessage: chatStopMessage,
797
- clearMessages: chatClearMessages,
798
- hasMessages,
799
- } = useChat({
800
- useMock: false,
801
- assistantName: 'Mingo AI',
802
- streamFn,
803
- initialMessages: persistedRef.current.messages,
804
- onMessagesChange: persist,
805
- })
806
-
807
- messagesRef.current = messages
808
-
809
- // Index sources/refs/scrollAnchor by USER-SEND count (`sendIdx`), not
810
- // by assistant-message count. Each user send produces exactly ONE
811
- // refsMapRef entry server-side, but it can produce MULTIPLE assistant
812
- // messages on the client (main RAG reply + post-approve card +
813
- // auto-continuation prose). Counting USER sends and mapping every
814
- // following assistant message to that index keeps the lookup stable.
815
- let sendIdx = -1
816
- const docMessages: DocChatMessage[] = messages.map((m) => {
817
- const segments = Array.isArray(m.content) ? (m.content as MessageSegment[]) : undefined
818
- const content =
819
- typeof m.content === 'string'
820
- ? m.content
821
- : Array.isArray(m.content)
822
- ? m.content
823
- .filter((s) => s.type === 'text')
824
- .map((s) => (s as { type: 'text'; text: string }).text)
825
- .join('')
826
- : ''
827
-
828
- let sources: ChatSource[] | undefined
829
- let chatRefs: Record<string, ChatRef> | undefined
830
- let scrollAnchor: ScrollAnchor | undefined
831
- if (m.role === 'user' && !m.hidden) {
832
- sendIdx++
833
- }
834
- if (m.role === 'assistant') {
835
- const lookupIdx = sendIdx >= 0 ? sendIdx : 0
836
- sources = sourcesMapRef.current.get(lookupIdx)
837
- // The receipt path stamps `chatRefs` directly onto the assistant
838
- // message in useChat. Prefer that message-bound copy when present;
839
- // falls back to per-turn refs map.
840
- chatRefs = m.chatRefs ?? refsMapRef.current.get(lookupIdx)
841
- scrollAnchor =
842
- (metaMapRef.current.get(lookupIdx)?.scrollAnchor as ScrollAnchor | null) ??
843
- undefined
844
- }
845
-
846
- return {
847
- id: m.id,
848
- role: m.role as 'user' | 'assistant',
849
- content,
850
- ...(segments ? { segments } : {}),
851
- ...(sources ? { sources } : {}),
852
- ...(chatRefs ? { chatRefs } : {}),
853
- ...(scrollAnchor ? { scrollAnchor } : {}),
854
- ...(m.hidden ? { hidden: true } : {}),
855
- }
856
- })
857
-
858
- type SendMessageOptions = StreamFnExtraOptions
859
-
860
- const sendMessage = useCallback(
861
- (
862
- text: string,
863
- options?: SendMessageOptions,
864
- messageOptions?: { hidden?: boolean },
865
- ) => {
866
- sendCountRef.current++
867
- setStreamingPhase('thinking')
868
- chatSendMessage(text, options, messageOptions)
869
- },
870
- [chatSendMessage],
871
- )
872
-
873
- /**
874
- * "Display" callback for inline cards whose registry entry sets
875
- * `displayAction: true`. Parallel to `discussRef` but emits a
876
- * `/<cmd> display "<title>"` slash command instead of the Discuss
877
- * prose. Resolution of the slash command id for the ref's documentType
878
- * happens via the `cmdIdByTableId` map hydrated from the commands
879
- * endpoint.
880
- *
881
- * `tableIdForDocumentType` is always defined here — either the
882
- * embedder-supplied override OR the lib-baked
883
- * `defaultTableIdForDocumentType` fallback. Unknown documentTypes
884
- * still short-circuit gracefully via the `tableId === null` check
885
- * below.
886
- */
887
- const displayRef = useCallback(
888
- (reference: ChatRef) => {
889
- const tableId = tableIdForDocumentType(reference.type)
890
- if (!tableId) {
891
- console.warn(
892
- `[useEmbeddedChat] displayRef: no tableId for documentType="${reference.type}"; ignoring click`,
893
- )
894
- return
895
- }
896
- const cmdId = cmdIdByTableId.get(tableId)
897
- if (!cmdId) {
898
- console.warn(
899
- `[useEmbeddedChat] displayRef: no slash command for tableId="${tableId}" source="${source}"; ignoring click`,
900
- )
901
- return
902
- }
903
- const refSlug =
904
- typeof reference.metadata?.slug === 'string' &&
905
- reference.metadata.slug.length > 0
906
- ? reference.metadata.slug
907
- : ''
908
- const queryValue =
909
- refSlug || sanitizeTitleForChat(reference.title) || reference.id
910
- // Escape `\` BEFORE `"` so a trailing backslash can't smuggle a
911
- // close-quote past parsers that honor JS-style escapes. Matches
912
- // `formatSingularLookupInvocation`'s pattern in slash-dispatch-utils.
913
- const escaped = queryValue
914
- .replace(/\\/g, '\\\\')
915
- .replace(/"/g, '\\"')
916
- const text = `/${cmdId} display "${escaped}"`
917
- sendMessage(text)
918
- },
919
- [sendMessage, source, cmdIdByTableId, tableIdForDocumentType],
920
- )
921
-
922
- /**
923
- * "Discuss" affordance for ObjectCard. Synthesizes a natural-language
924
- * prompt ("Tell me more about <title>"); the structured
925
- * `commandOverride.entityIdFilter` is sent server-side via the request
926
- * body so retrieval narrows to the named row.
927
- *
928
- * `tableIdForDocumentType` is always defined here — either the
929
- * embedder-supplied override OR the lib-baked default. Unknown
930
- * documentTypes still short-circuit via the `tableId === null` check.
931
- */
932
- const discussRef = useCallback(
933
- (reference: ChatRef) => {
934
- const tableId = tableIdForDocumentType(reference.type)
935
- if (!tableId) {
936
- console.warn(
937
- `[useEmbeddedChat] discussRef: no tableId for documentType="${reference.type}"; ignoring click`,
938
- )
939
- return
940
- }
941
- const refId = (reference.id ?? '').trim()
942
- if (!refId) {
943
- console.warn(
944
- `[useEmbeddedChat] discussRef: empty reference.id for type="${reference.type}"; ignoring click`,
945
- )
946
- return
947
- }
948
- // RETRIEVAL IS STRICTLY PRIMARY-KEY-DRIVEN. The visible prose
949
- // ("Tell me more about <title>") is UX-only; retrieval narrows
950
- // via `entityIdFilter` before the LLM is invoked.
951
- const sanitizedTitle = sanitizeTitleForChat(reference.title)
952
- const prompt = `Tell me more about ${sanitizedTitle || 'this item'}`
953
- sendMessage(prompt, {
954
- commandOverride: { entityIdFilter: { tableId, id: refId } },
955
- })
956
- },
957
- [sendMessage, tableIdForDocumentType],
958
- )
959
-
960
- const stopMessage = useCallback(() => {
961
- chatStopMessage()
962
- setStreamingPhase('idle')
963
- }, [chatStopMessage])
964
-
965
- const clearMessages = useCallback(() => {
966
- sourcesMapRef.current.clear()
967
- refsMapRef.current.clear()
968
- metaMapRef.current.clear()
969
- sendCountRef.current = 0
970
- setStreamingPhase('idle')
971
- // Force the latestMeta useMemo to recompute with the cleared map.
972
- bumpMetaTick()
973
- chatClearMessages()
974
- // Clear persisted state too so the next mount starts fresh.
975
- if (typeof window !== 'undefined') {
976
- try {
977
- window.localStorage.removeItem(chatStorageKey(source))
978
- } catch {}
979
- }
980
- }, [chatClearMessages, source, bumpMetaTick])
981
-
982
- // Reset to idle whenever both flags drop off.
983
- useEffect(() => {
984
- if (!isTyping && !isStreaming && streamingPhase !== 'idle') {
985
- setStreamingPhase('idle')
986
- }
987
- }, [isTyping, isStreaming, streamingPhase])
988
-
989
- // Resolve the active turn's metadata.
990
- const latestMeta = useMemo(
991
- () =>
992
- metaMapRef.current.get(sendCountRef.current - 1) ??
993
- metaMapRef.current.get(sendCountRef.current - 2) ??
994
- null,
995
- [metaTick, streamingPhase],
996
- )
997
16
 
998
- return {
999
- messages: docMessages,
1000
- isLoading: isTyping || isStreaming,
1001
- sendMessage,
1002
- /** "Discuss" affordance for ObjectCard. */
1003
- discussRef,
1004
- /** "Display" counterpart of `discussRef`. */
1005
- displayRef,
1006
- stopMessage,
1007
- clearMessages,
1008
- streamingPhase,
1009
- /** Provider key for the lib's `<ModelDisplay>` icon. */
1010
- currentProvider: latestMeta?.provider ?? null,
1011
- currentModelLabel: latestMeta?.modelLabel ?? null,
1012
- currentContextWindowMaxTokens: latestMeta?.contextWindowMaxTokens ?? null,
1013
- /** Input tokens (known after server's message_start frame; null until). */
1014
- currentInputTokens: latestMeta?.inputTokens ?? null,
1015
- /** Output tokens (known only after server's trailing usage frame). */
1016
- currentOutputTokens: latestMeta?.outputTokens ?? null,
1017
- /** Cache hit % (read / total-input × 100). null during streaming. */
1018
- currentCacheHitRatePct: latestMeta?.cacheHitRatePct ?? null,
1019
- /** Cross-call usage breakdown (Haiku rewriter/classifier/summarizer
1020
- * token counts). null until the trailing usage frame lands. */
1021
- currentUsageBreakdown: latestMeta?.breakdown ?? null,
1022
- }
1023
- }
17
+ export {
18
+ useSseChatAdapter as useEmbeddedChat,
19
+ type UseSseChatAdapterOptions as UseEmbeddedChatOptions,
20
+ type ChatSource,
21
+ type DocChatMessage,
22
+ type DocSource,
23
+ type ChatTurnMeta,
24
+ type StreamingPhase,
25
+ } from './use-sse-chat-adapter'