@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.
- package/dist/{chunk-Z3GQGR5E.js → chunk-2HMZSCJY.js} +3158 -2074
- package/dist/chunk-2HMZSCJY.js.map +1 -0
- package/dist/chunk-4XLJWX2N.js +12 -0
- package/dist/chunk-4XLJWX2N.js.map +1 -0
- package/dist/{chunk-APM6KBPU.cjs → chunk-C5EC5AZM.cjs} +1644 -560
- package/dist/chunk-C5EC5AZM.cjs.map +1 -0
- package/dist/chunk-VFKQMAUF.cjs +12 -0
- package/dist/chunk-VFKQMAUF.cjs.map +1 -0
- package/dist/components/chat/embeddable-chat.d.ts +35 -2
- package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/index.d.ts +3 -0
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-embedded-chat.d.ts +10 -169
- package/dist/components/chat/hooks/use-embedded-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +85 -0
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -0
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +124 -0
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -0
- package/dist/components/chat/hooks/use-unified-chat.d.ts +33 -0
- package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -0
- package/dist/components/chat/index.cjs +8 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +11 -5
- package/dist/components/chat/types/index.d.ts +1 -0
- package/dist/components/chat/types/index.d.ts.map +1 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts +185 -0
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -0
- package/dist/components/features/index.cjs +3 -2
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +2 -1
- package/dist/components/index.cjs +26 -2
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +27 -3
- package/dist/components/navigation/index.cjs +3 -2
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.js +2 -1
- package/dist/components/shared/delivery/delivery-lists.d.ts +16 -0
- package/dist/components/shared/delivery/delivery-lists.d.ts.map +1 -0
- package/dist/components/shared/delivery/delivery-table.d.ts +12 -0
- package/dist/components/shared/delivery/delivery-table.d.ts.map +1 -0
- package/dist/components/shared/delivery/index.d.ts +3 -0
- package/dist/components/shared/delivery/index.d.ts.map +1 -0
- package/dist/components/shared/dev-section/dev-section-page.d.ts +31 -0
- package/dist/components/shared/dev-section/dev-section-page.d.ts.map +1 -0
- package/dist/components/shared/dev-section/dev-section-view.d.ts +34 -0
- package/dist/components/shared/dev-section/dev-section-view.d.ts.map +1 -0
- package/dist/components/shared/dev-section/index.d.ts +3 -0
- package/dist/components/shared/dev-section/index.d.ts.map +1 -0
- package/dist/components/shared/legal-document/index.d.ts +10 -0
- package/dist/components/shared/legal-document/index.d.ts.map +1 -0
- package/dist/components/shared/legal-document/legal-document-page.d.ts +66 -0
- package/dist/components/shared/legal-document/legal-document-page.d.ts.map +1 -0
- package/dist/components/shared/legal-document/use-legal-docs.d.ts +40 -0
- package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -0
- package/dist/components/shared/product-release/index.d.ts +2 -1
- package/dist/components/shared/product-release/index.d.ts.map +1 -1
- package/dist/components/shared/product-release/release-detail-page.d.ts +11 -7
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/shared/roadmap/index.d.ts +18 -0
- package/dist/components/shared/roadmap/index.d.ts.map +1 -0
- package/dist/components/shared/roadmap/roadmap-grid-skeleton.d.ts +24 -0
- package/dist/components/shared/roadmap/roadmap-grid-skeleton.d.ts.map +1 -0
- package/dist/components/shared/roadmap/roadmap-grid.d.ts +18 -0
- package/dist/components/shared/roadmap/roadmap-grid.d.ts.map +1 -0
- package/dist/components/shared/roadmap/use-roadmap-voting.d.ts +25 -0
- package/dist/components/shared/roadmap/use-roadmap-voting.d.ts.map +1 -0
- package/dist/components/ui/index.cjs +8 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +11 -5
- package/dist/components/ui/release-changelog-section.d.ts +13 -2
- package/dist/components/ui/release-changelog-section.d.ts.map +1 -1
- package/dist/embed-shims/index.cjs +1 -6
- package/dist/embed-shims/index.cjs.map +1 -1
- package/dist/embed-shims/index.js +1 -6
- package/dist/embed-shims/index.js.map +1 -1
- package/dist/index.cjs +18 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +19 -3
- package/dist/types/delivery.d.ts +49 -0
- package/dist/types/delivery.d.ts.map +1 -0
- package/dist/types/index.cjs +13 -0
- package/dist/types/index.cjs.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +12 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/dev-sections/index.d.ts +11 -0
- package/dist/utils/dev-sections/index.d.ts.map +1 -0
- package/dist/utils/dev-sections/openframe-dev-sections.d.ts +209 -0
- package/dist/utils/dev-sections/openframe-dev-sections.d.ts.map +1 -0
- package/dist/utils/index.cjs +82 -0
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +81 -2
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/embeddable-chat.tsx +123 -8
- package/src/components/chat/hooks/index.ts +9 -2
- package/src/components/chat/hooks/use-embedded-chat.ts +18 -1016
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +372 -0
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +1058 -0
- package/src/components/chat/hooks/use-unified-chat.ts +171 -0
- package/src/components/chat/types/index.ts +1 -0
- package/src/components/chat/types/unified-chat-state.types.ts +215 -0
- package/src/components/index.ts +8 -0
- package/src/components/shared/delivery/delivery-lists.tsx +199 -0
- package/src/components/shared/delivery/delivery-table.tsx +174 -0
- package/src/components/shared/delivery/index.ts +9 -0
- package/src/components/shared/dev-section/dev-section-page.tsx +72 -0
- package/src/components/shared/dev-section/dev-section-view.tsx +129 -0
- package/src/components/shared/dev-section/index.ts +2 -0
- package/src/components/shared/legal-document/index.ts +19 -0
- package/src/components/shared/legal-document/legal-document-page.tsx +178 -0
- package/src/components/shared/legal-document/use-legal-docs.ts +123 -0
- package/src/components/shared/product-release/index.ts +14 -3
- package/src/components/shared/product-release/release-detail-page.tsx +45 -7
- package/src/components/shared/roadmap/index.ts +23 -0
- package/src/components/shared/roadmap/roadmap-grid-skeleton.tsx +74 -0
- package/src/components/shared/roadmap/roadmap-grid.tsx +106 -0
- package/src/components/shared/roadmap/use-roadmap-voting.ts +163 -0
- package/src/components/ui/release-changelog-section.tsx +113 -32
- package/src/stories/EmbeddableChat.stories.tsx +186 -0
- package/src/types/delivery.ts +54 -0
- package/src/types/index.ts +1 -0
- package/src/utils/dev-sections/index.ts +17 -0
- package/src/utils/dev-sections/openframe-dev-sections.ts +148 -0
- package/src/utils/index.ts +6 -1
- package/dist/chunk-APM6KBPU.cjs.map +0 -1
- package/dist/chunk-Z3GQGR5E.js.map +0 -1
|
@@ -1,1023 +1,25 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Backward-compatibility shim.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 `<` → `<` preserves
|
|
185
|
-
* the visible character without breaking blockquote `>` markers.
|
|
186
|
-
*/
|
|
187
|
-
function escapeThinkingTags(text: string): string {
|
|
188
|
-
return text.replace(/</g, '<')
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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'
|