@flamingo-stack/openframe-frontend-core 0.0.488 → 0.0.489
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-FOQV6RCO.cjs → chunk-2IHN7NNB.cjs} +29 -29
- package/dist/{chunk-FOQV6RCO.cjs.map → chunk-2IHN7NNB.cjs.map} +1 -1
- package/dist/{chunk-GQX6LFFA.cjs → chunk-2RNJYJWO.cjs} +42 -42
- package/dist/{chunk-GQX6LFFA.cjs.map → chunk-2RNJYJWO.cjs.map} +1 -1
- package/dist/{chunk-RZSA6MZL.cjs → chunk-3OIB763B.cjs} +15 -15
- package/dist/{chunk-RZSA6MZL.cjs.map → chunk-3OIB763B.cjs.map} +1 -1
- package/dist/{chunk-LLWBPPZ4.js → chunk-5VSEMWWE.js} +6 -6
- package/dist/{chunk-DX6AOHNU.cjs → chunk-5YMY4BAW.cjs} +77 -77
- package/dist/{chunk-DX6AOHNU.cjs.map → chunk-5YMY4BAW.cjs.map} +1 -1
- package/dist/{chunk-KFQXO7QB.js → chunk-6GT557KN.js} +1 -1
- package/dist/chunk-6GT557KN.js.map +1 -0
- package/dist/{chunk-3BFX6HJM.js → chunk-B4YR342L.js} +6 -6
- package/dist/{chunk-SCCTJ2UI.js → chunk-BGWDDB7Y.js} +4 -4
- package/dist/{chunk-A7IPON2V.cjs → chunk-BIKOJ4FH.cjs} +5 -5
- package/dist/{chunk-A7IPON2V.cjs.map → chunk-BIKOJ4FH.cjs.map} +1 -1
- package/dist/{chunk-PNXNWGBY.js → chunk-EU6QNO4S.js} +2 -2
- package/dist/{chunk-F6BYUO5U.js → chunk-FKPXFTZT.js} +3 -3
- package/dist/{chunk-EUYIFALO.cjs → chunk-H3YRJ3LZ.cjs} +14 -14
- package/dist/{chunk-EUYIFALO.cjs.map → chunk-H3YRJ3LZ.cjs.map} +1 -1
- package/dist/{chunk-5RXKOGH6.cjs → chunk-IN4IO7BL.cjs} +386 -327
- package/dist/chunk-IN4IO7BL.cjs.map +1 -0
- package/dist/{chunk-V3CNNMY3.cjs → chunk-J7H32E3R.cjs} +18 -18
- package/dist/{chunk-V3CNNMY3.cjs.map → chunk-J7H32E3R.cjs.map} +1 -1
- package/dist/{chunk-AJNDDXKC.js → chunk-K7MNWYYI.js} +3 -3
- package/dist/{chunk-PECGZWQR.cjs → chunk-KP5KRRD6.cjs} +3 -3
- package/dist/{chunk-PECGZWQR.cjs.map → chunk-KP5KRRD6.cjs.map} +1 -1
- package/dist/{chunk-2B6773QM.cjs → chunk-LILQJ3UZ.cjs} +1 -1
- package/dist/chunk-LILQJ3UZ.cjs.map +1 -0
- package/dist/{chunk-H7JX2BG7.cjs → chunk-LLEDO4P7.cjs} +75 -75
- package/dist/{chunk-H7JX2BG7.cjs.map → chunk-LLEDO4P7.cjs.map} +1 -1
- package/dist/{chunk-AHDJMEEN.js → chunk-N4MTNTSX.js} +7 -7
- package/dist/{chunk-KLYHZRAD.js → chunk-PB3BTZDU.js} +2 -2
- package/dist/{chunk-YEEHB4T6.cjs → chunk-RO7Q56ZK.cjs} +40 -40
- package/dist/{chunk-YEEHB4T6.cjs.map → chunk-RO7Q56ZK.cjs.map} +1 -1
- package/dist/{chunk-CWQACI4Z.js → chunk-RW2LPL72.js} +2 -2
- package/dist/{chunk-HQYJINFV.js → chunk-TWGYBXYV.js} +4 -4
- package/dist/{chunk-YJSKQ2ZV.js → chunk-VL4LUO32.js} +3 -3
- package/dist/{chunk-FZOBQG6M.cjs → chunk-VLOE7MI2.cjs} +35 -35
- package/dist/{chunk-FZOBQG6M.cjs.map → chunk-VLOE7MI2.cjs.map} +1 -1
- package/dist/{chunk-S52LJNMU.js → chunk-W6ZUOWSB.js} +7 -7
- package/dist/{chunk-PXF5J24T.js → chunk-X2YF5TJS.js} +2 -2
- package/dist/{chunk-77DFPD35.js → chunk-YMJZOANX.js} +268 -209
- package/dist/chunk-YMJZOANX.js.map +1 -0
- package/dist/{chunk-SIKCXKGB.cjs → chunk-Z7Y63IFU.cjs} +7 -7
- package/dist/{chunk-SIKCXKGB.cjs.map → chunk-Z7Y63IFU.cjs.map} +1 -1
- package/dist/{chunk-EYEIVYYF.cjs → chunk-ZIAS5NNL.cjs} +7 -7
- package/dist/{chunk-EYEIVYYF.cjs.map → chunk-ZIAS5NNL.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +10 -10
- package/dist/components/case-studies/index.js +4 -4
- package/dist/components/chat/hooks/use-chat-history-hydration.d.ts +47 -0
- package/dist/components/chat/hooks/use-chat-history-hydration.d.ts.map +1 -0
- package/dist/components/chat/hooks/use-chat.d.ts +1 -0
- package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +4 -0
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +4 -4
- package/dist/components/chat/index.js +3 -3
- package/dist/components/chat/types/unified-chat-state.types.d.ts +5 -0
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
- package/dist/components/chat/utils/chat-conversation-storage.d.ts +34 -0
- package/dist/components/chat/utils/chat-conversation-storage.d.ts.map +1 -0
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +5 -5
- package/dist/components/embeds/index.js +4 -4
- package/dist/components/faq/index.cjs +6 -6
- package/dist/components/faq/index.js +5 -5
- package/dist/components/features/index.cjs +4 -4
- package/dist/components/features/index.js +3 -3
- package/dist/components/help-center-pages/index.cjs +23 -23
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/index.cjs +140 -140
- package/dist/components/index.js +12 -12
- package/dist/components/navigation/index.cjs +4 -4
- package/dist/components/navigation/index.js +3 -3
- package/dist/components/onboarding-guides/index.cjs +8 -8
- package/dist/components/onboarding-guides/index.js +7 -7
- package/dist/components/related-content/index.cjs +6 -6
- package/dist/components/related-content/index.js +5 -5
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/index.cjs +4 -4
- package/dist/components/ui/index.js +3 -3
- package/dist/contexts/chat-runtime-context.d.ts +8 -0
- package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
- package/dist/contexts/index.cjs +2 -2
- package/dist/contexts/index.js +1 -1
- package/dist/hooks/index.cjs +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/index.cjs +4 -4
- package/dist/index.js +3 -3
- package/package.json +1 -1
- package/src/components/chat/hooks/use-chat-history-hydration.ts +142 -0
- package/src/components/chat/hooks/use-chat.ts +12 -0
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +109 -162
- package/src/components/chat/types/unified-chat-state.types.ts +6 -0
- package/src/components/chat/utils/chat-conversation-storage.ts +83 -0
- package/src/contexts/chat-runtime-context.tsx +8 -0
- package/dist/chunk-2B6773QM.cjs.map +0 -1
- package/dist/chunk-5RXKOGH6.cjs.map +0 -1
- package/dist/chunk-77DFPD35.js.map +0 -1
- package/dist/chunk-KFQXO7QB.js.map +0 -1
- /package/dist/{chunk-LLWBPPZ4.js.map → chunk-5VSEMWWE.js.map} +0 -0
- /package/dist/{chunk-3BFX6HJM.js.map → chunk-B4YR342L.js.map} +0 -0
- /package/dist/{chunk-SCCTJ2UI.js.map → chunk-BGWDDB7Y.js.map} +0 -0
- /package/dist/{chunk-PNXNWGBY.js.map → chunk-EU6QNO4S.js.map} +0 -0
- /package/dist/{chunk-F6BYUO5U.js.map → chunk-FKPXFTZT.js.map} +0 -0
- /package/dist/{chunk-AJNDDXKC.js.map → chunk-K7MNWYYI.js.map} +0 -0
- /package/dist/{chunk-AHDJMEEN.js.map → chunk-N4MTNTSX.js.map} +0 -0
- /package/dist/{chunk-KLYHZRAD.js.map → chunk-PB3BTZDU.js.map} +0 -0
- /package/dist/{chunk-CWQACI4Z.js.map → chunk-RW2LPL72.js.map} +0 -0
- /package/dist/{chunk-HQYJINFV.js.map → chunk-TWGYBXYV.js.map} +0 -0
- /package/dist/{chunk-YJSKQ2ZV.js.map → chunk-VL4LUO32.js.map} +0 -0
- /package/dist/{chunk-S52LJNMU.js.map → chunk-W6ZUOWSB.js.map} +0 -0
- /package/dist/{chunk-PXF5J24T.js.map → chunk-X2YF5TJS.js.map} +0 -0
|
@@ -40,17 +40,21 @@
|
|
|
40
40
|
*/
|
|
41
41
|
|
|
42
42
|
import { useState, useEffect, useCallback, useMemo, useRef, type MutableRefObject } from 'react'
|
|
43
|
-
import { useChat, type
|
|
43
|
+
import { useChat, type StreamFnExtraOptions } from './use-chat'
|
|
44
44
|
import { useRequiredChatRuntime } from '../../../contexts/chat-runtime-context'
|
|
45
45
|
import type { ChatRef } from '../chat-ref.types'
|
|
46
46
|
import { buildChatRefKey } from '../types/chat.types'
|
|
47
47
|
import type { MessageSegment } from '../types/message.types'
|
|
48
48
|
import { useSlashCommandRegistry, type SlashCommandSummary } from './use-slash-commands'
|
|
49
|
-
import { getChatProxyAuth } from '../utils/chat-proxy-auth-storage'
|
|
50
49
|
import { chatAuthedFetch } from '../utils/chat-authed-fetch'
|
|
50
|
+
import {
|
|
51
|
+
createChatConversationStorage,
|
|
52
|
+
pruneStaleChatConversationStorage,
|
|
53
|
+
type PersistedChatConversation,
|
|
54
|
+
} from '../utils/chat-conversation-storage'
|
|
55
|
+
import type { LocalStorageAdapter } from '../../../utils/local-storage-adapter'
|
|
56
|
+
import { useChatHistoryHydration } from './use-chat-history-hydration'
|
|
51
57
|
import { parseScrollAnchor, type ScrollAnchor } from '../utils/scroll-anchor'
|
|
52
|
-
import { AUTO_CONTINUATION_DIRECTIVE_PREFIX } from '../utils/auto-continuation-directive'
|
|
53
|
-
import { flattenAssistantContent } from '../utils/flatten-assistant-content'
|
|
54
58
|
import { sanitizeTitleForChat } from '../utils/slash-dispatch-utils'
|
|
55
59
|
import { defaultTableIdForDocumentType } from '../utils/source-icons'
|
|
56
60
|
import type {
|
|
@@ -146,6 +150,10 @@ export interface ChatTurnMeta {
|
|
|
146
150
|
/** Routing decision from the server's `decideRoute`. */
|
|
147
151
|
routedComplexity: string | null
|
|
148
152
|
routedThinkingBudget: number | null
|
|
153
|
+
/** Server-computed conversation trace id (`chat_conversations.id` for this
|
|
154
|
+
* thread), echoed in the leading metadata frame. Stable across turns of
|
|
155
|
+
* one thread — the correlation handle for transcripts/analytics. */
|
|
156
|
+
conversationId: string | null
|
|
149
157
|
}
|
|
150
158
|
|
|
151
159
|
/**
|
|
@@ -199,6 +207,7 @@ function createEmptyTurnMeta(): ChatTurnMeta {
|
|
|
199
207
|
scrollAnchor: null,
|
|
200
208
|
routedComplexity: null,
|
|
201
209
|
routedThinkingBudget: null,
|
|
210
|
+
conversationId: null,
|
|
202
211
|
}
|
|
203
212
|
}
|
|
204
213
|
|
|
@@ -215,13 +224,14 @@ function escapeThinkingTags(text: string): string {
|
|
|
215
224
|
function createDocStreamFn(
|
|
216
225
|
source: DocSource,
|
|
217
226
|
endpoints: { chatStreamUrl: string; approvalToolUrl: string },
|
|
218
|
-
messagesRef: MutableRefObject<Message[]>,
|
|
219
227
|
sourcesMapRef: MutableRefObject<Map<number, ChatSource[]>>,
|
|
220
228
|
refsMapRef: MutableRefObject<Map<number, Record<string, ChatRef>>>,
|
|
221
229
|
metaMapRef: MutableRefObject<Map<number, ChatTurnMeta>>,
|
|
222
230
|
setStreamingPhase: (phase: StreamingPhase) => void,
|
|
223
231
|
bumpMetaTick: () => void,
|
|
224
232
|
sendCountRef: MutableRefObject<number>,
|
|
233
|
+
conversationIdRef: MutableRefObject<string | null>,
|
|
234
|
+
conversationStorage: LocalStorageAdapter<PersistedChatConversation>,
|
|
225
235
|
) {
|
|
226
236
|
// CRITICAL: the decoder + buffer MUST live INSIDE the returned async-
|
|
227
237
|
// generator function (per-call closure), NOT at the factory level. A
|
|
@@ -233,22 +243,6 @@ function createDocStreamFn(
|
|
|
233
243
|
signal?: AbortSignal,
|
|
234
244
|
extra?: StreamFnExtraOptions,
|
|
235
245
|
): AsyncGenerator<MessageSegment> {
|
|
236
|
-
const currentMessages = messagesRef.current || []
|
|
237
|
-
// Filter `hidden:true` messages out of the API history. The approval-
|
|
238
|
-
// action turn injects a hidden user message with `content=''`.
|
|
239
|
-
// `flattenAssistantContent` joins text-segment arrays into a single
|
|
240
|
-
// string so the server sees the receipt + auto-continuation Qs.
|
|
241
|
-
const apiMessages = [
|
|
242
|
-
...currentMessages
|
|
243
|
-
.filter((m) => (m.role === 'user' || m.role === 'assistant') && !m.hidden)
|
|
244
|
-
.map((m) => ({
|
|
245
|
-
role: m.role,
|
|
246
|
-
content:
|
|
247
|
-
typeof m.content === 'string' ? m.content : flattenAssistantContent(m.content),
|
|
248
|
-
})),
|
|
249
|
-
{ role: 'user', content: message },
|
|
250
|
-
]
|
|
251
|
-
|
|
252
246
|
// URL + body branch — approvalAction routes to the approval-tool
|
|
253
247
|
// endpoint, the standard chat path routes to the chat-stream endpoint.
|
|
254
248
|
const targetPath = extra?.approvalAction
|
|
@@ -258,26 +252,30 @@ function createDocStreamFn(
|
|
|
258
252
|
// it server-side via its own platform-detection — tamper-proof binding
|
|
259
253
|
// so a client on one platform can't POST a different platform's
|
|
260
254
|
// conversation.
|
|
255
|
+
//
|
|
256
|
+
// The server is the single source of conversation history: it re-reads
|
|
257
|
+
// `chat_messages` by conversation id on every turn. The wire therefore
|
|
258
|
+
// carries ONLY the new user message — never the prior conversation.
|
|
259
|
+
// The conversation id is SERVER-minted: the first message of a session
|
|
260
|
+
// sends none, the server mints one and echoes it in the leading metadata
|
|
261
|
+
// frame (captured below into `conversationIdRef`), and every later turn
|
|
262
|
+
// echoes it back.
|
|
263
|
+
const conversationId = conversationIdRef.current
|
|
261
264
|
const requestBody = extra?.approvalAction
|
|
262
265
|
? {
|
|
263
266
|
proposal_id: extra.approvalAction.proposalId,
|
|
264
267
|
action: extra.approvalAction.action,
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
role: m.role,
|
|
269
|
-
content:
|
|
270
|
-
typeof m.content === 'string'
|
|
271
|
-
? m.content
|
|
272
|
-
: flattenAssistantContent(m.content),
|
|
273
|
-
})),
|
|
268
|
+
// Always present here — an approval can only happen inside an
|
|
269
|
+
// established conversation (the proposal turn captured the id).
|
|
270
|
+
conversationId,
|
|
274
271
|
}
|
|
275
272
|
: {
|
|
276
|
-
messages:
|
|
273
|
+
messages: [{ role: 'user', content: message }],
|
|
277
274
|
...(extra?.commandOverride ? { commandOverride: extra.commandOverride } : {}),
|
|
278
275
|
...(extra?.pendingAttachments && extra.pendingAttachments.length > 0
|
|
279
276
|
? { pendingAttachments: extra.pendingAttachments }
|
|
280
277
|
: {}),
|
|
278
|
+
...(conversationId ? { conversationId } : {}),
|
|
281
279
|
}
|
|
282
280
|
// `chatAuthedFetch` carries the bearer-act-as headers (+ Supabase
|
|
283
281
|
// session cookies) — same wrapper `use-chat-attachments` and
|
|
@@ -489,6 +487,18 @@ function createDocStreamFn(
|
|
|
489
487
|
})
|
|
490
488
|
bumpMetaTick()
|
|
491
489
|
}
|
|
490
|
+
// Server-minted conversation id, echoed on every turn. On the
|
|
491
|
+
// session's FIRST turn this is where the client learns its id —
|
|
492
|
+
// capture it and persist it so later turns and future visits
|
|
493
|
+
// continue the same conversation.
|
|
494
|
+
if (typeof meta.conversationId === 'string' && meta.conversationId) {
|
|
495
|
+
if (conversationIdRef.current !== meta.conversationId) {
|
|
496
|
+
conversationIdRef.current = meta.conversationId
|
|
497
|
+
conversationStorage.save({ conversationId: meta.conversationId })
|
|
498
|
+
}
|
|
499
|
+
mergeTurnMeta(metaMapRef, sendIdx, { conversationId: meta.conversationId })
|
|
500
|
+
bumpMetaTick()
|
|
501
|
+
}
|
|
492
502
|
const parsedAnchor = parseScrollAnchor(meta.scrollAnchor)
|
|
493
503
|
if (parsedAnchor !== null) {
|
|
494
504
|
mergeTurnMeta(metaMapRef, sendIdx, { scrollAnchor: parsedAnchor })
|
|
@@ -587,94 +597,25 @@ function mergeTurnMeta(
|
|
|
587
597
|
}
|
|
588
598
|
|
|
589
599
|
// =============================================================================
|
|
590
|
-
//
|
|
600
|
+
// Local persistence — the server-issued conversation id ONLY
|
|
591
601
|
// =============================================================================
|
|
602
|
+
//
|
|
603
|
+
// The ONLY thing persisted locally is the conversation id the SERVER minted
|
|
604
|
+
// and echoed in the leading metadata frame of the session's first turn.
|
|
605
|
+
// The client never generates ids. Message history lives server-side in
|
|
606
|
+
// `chat_conversations` / `chat_messages` (recorded turn-by-turn by the chat
|
|
607
|
+
// route) and is rehydrated on mount via `GET <chatStreamUrl>/history`.
|
|
608
|
+
// localStorage never stores messages, sources, refs, or send counts —
|
|
609
|
+
// the server transcript is the single source of truth for history.
|
|
592
610
|
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
/** localStorage history namespace used when no `source` is configured on the
|
|
611
|
+
/** localStorage namespace used when no `source` is configured on the
|
|
596
612
|
* runtime. Embedders are platform-agnostic (see `ChatRuntime.source`), so any
|
|
597
613
|
* stable string works here — the hub passes its real platform instead. */
|
|
598
614
|
const DEFAULT_CHAT_SOURCE = 'embed'
|
|
599
615
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
const base = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}`
|
|
604
|
-
const auth = getChatProxyAuth()
|
|
605
|
-
if (auth?.email) {
|
|
606
|
-
return `${base}-u-${encodeURIComponent(auth.email.toLowerCase())}`
|
|
607
|
-
}
|
|
608
|
-
return base
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/** Sweep stale per-user chat-history keys. Drops any key whose email
|
|
612
|
-
* differs from the CURRENT proxy-auth identity. */
|
|
613
|
-
function pruneStaleChatStorage(source: DocSource): void {
|
|
614
|
-
if (typeof window === 'undefined') return
|
|
615
|
-
try {
|
|
616
|
-
const currentKey = chatStorageKey(source)
|
|
617
|
-
const prefix = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}-u-`
|
|
618
|
-
const legacy = `mingo-chat-${source}-v${CHAT_STORAGE_VERSION}`
|
|
619
|
-
const toRemove: string[] = []
|
|
620
|
-
for (let i = 0; i < window.localStorage.length; i++) {
|
|
621
|
-
const k = window.localStorage.key(i)
|
|
622
|
-
if (!k) continue
|
|
623
|
-
if (!k.startsWith(prefix)) continue
|
|
624
|
-
if (k === currentKey) continue
|
|
625
|
-
if (k === legacy) continue
|
|
626
|
-
toRemove.push(k)
|
|
627
|
-
}
|
|
628
|
-
for (const k of toRemove) {
|
|
629
|
-
window.localStorage.removeItem(k)
|
|
630
|
-
}
|
|
631
|
-
} catch {
|
|
632
|
-
// localStorage access blocked (Safari private mode etc.) — non-fatal.
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
interface PersistedChatState {
|
|
637
|
-
messages: Message[]
|
|
638
|
-
sources: Array<[number, ChatSource[]]>
|
|
639
|
-
/** Per-turn refs for inline object cards. */
|
|
640
|
-
refs?: Array<[number, Record<string, ChatRef>]>
|
|
641
|
-
sendCount: number
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
function loadPersistedChat(source: DocSource): PersistedChatState | null {
|
|
645
|
-
if (typeof window === 'undefined') return null
|
|
646
|
-
try {
|
|
647
|
-
const raw = window.localStorage.getItem(chatStorageKey(source))
|
|
648
|
-
if (!raw) return null
|
|
649
|
-
const parsed = JSON.parse(raw) as PersistedChatState
|
|
650
|
-
if (!parsed || !Array.isArray(parsed.messages)) return null
|
|
651
|
-
// Rehydrate Date objects + run forward-compat migrations.
|
|
652
|
-
for (const m of parsed.messages) {
|
|
653
|
-
if (typeof m.timestamp === 'string') m.timestamp = new Date(m.timestamp)
|
|
654
|
-
// Forward-migration for auto-continuation directive bubbles.
|
|
655
|
-
if (
|
|
656
|
-
m.role === 'user' &&
|
|
657
|
-
!m.hidden &&
|
|
658
|
-
typeof m.content === 'string' &&
|
|
659
|
-
m.content.startsWith(AUTO_CONTINUATION_DIRECTIVE_PREFIX)
|
|
660
|
-
) {
|
|
661
|
-
m.hidden = true
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
return parsed
|
|
665
|
-
} catch {
|
|
666
|
-
return null
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
function savePersistedChat(source: DocSource, state: PersistedChatState) {
|
|
671
|
-
if (typeof window === 'undefined') return
|
|
672
|
-
try {
|
|
673
|
-
window.localStorage.setItem(chatStorageKey(source), JSON.stringify(state))
|
|
674
|
-
} catch {
|
|
675
|
-
// Quota exceeded or private mode — silently drop.
|
|
676
|
-
}
|
|
677
|
-
}
|
|
616
|
+
// Persistence itself lives in `../utils/chat-conversation-storage.ts` (built
|
|
617
|
+
// on the lib-standard `createLocalStorageAdapter`); the mount-time history
|
|
618
|
+
// rebuild lives in `./use-chat-history-hydration.ts`.
|
|
678
619
|
|
|
679
620
|
// =============================================================================
|
|
680
621
|
// useSseChatAdapter — public hook
|
|
@@ -698,10 +639,10 @@ export function useSseChatAdapter(
|
|
|
698
639
|
// (hub) / embedder's provider must wrap the tree.
|
|
699
640
|
const runtime = useRequiredChatRuntime()
|
|
700
641
|
// `source` is OPTIONAL — embedders are platform-agnostic (see ChatRuntime.source).
|
|
701
|
-
// Here it's used ONLY for the localStorage
|
|
702
|
-
// labels; it is NEVER sent on the wire (the hub resolves source server-side
|
|
703
|
-
// currentPlatform()). Fall back to a stable constant so the persistence key
|
|
704
|
-
// well-formed when the embedder leaves source unset.
|
|
642
|
+
// Here it's used ONLY for the conversation-id localStorage namespace + client-side
|
|
643
|
+
// meta labels; it is NEVER sent on the wire (the hub resolves source server-side
|
|
644
|
+
// via currentPlatform()). Fall back to a stable constant so the persistence key
|
|
645
|
+
// stays well-formed when the embedder leaves source unset.
|
|
705
646
|
const source = runtime.source || DEFAULT_CHAT_SOURCE
|
|
706
647
|
// Fall back to the lib-baked hub-canonical map when the embedder
|
|
707
648
|
// didn't supply an override. Keeps Ask + Display working in any
|
|
@@ -711,23 +652,24 @@ export function useSseChatAdapter(
|
|
|
711
652
|
const tableIdForDocumentType =
|
|
712
653
|
options?.tableIdForDocumentType ?? defaultTableIdForDocumentType
|
|
713
654
|
|
|
714
|
-
// Restore
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
655
|
+
// Restore the server-issued conversation id once on mount. Null = no
|
|
656
|
+
// conversation yet — the FIRST send goes out without an id, the server
|
|
657
|
+
// mints one, and the metadata-frame capture (in the stream fn) stores it.
|
|
658
|
+
// The client NEVER generates ids. This is the ONLY local persistence —
|
|
659
|
+
// message history hydrates from the server transcript below.
|
|
660
|
+
const conversationStorage = useMemo(() => createChatConversationStorage(source), [source])
|
|
661
|
+
const conversationIdRef = useRef<string | null>(null)
|
|
662
|
+
const restoredConversationRef = useRef(false)
|
|
663
|
+
if (!restoredConversationRef.current) {
|
|
664
|
+
restoredConversationRef.current = true
|
|
665
|
+
pruneStaleChatConversationStorage(source)
|
|
666
|
+
conversationIdRef.current = conversationStorage.load()?.conversationId ?? null
|
|
720
667
|
}
|
|
721
668
|
|
|
722
|
-
const sourcesMapRef = useRef<Map<number, ChatSource[]>>(
|
|
723
|
-
|
|
724
|
-
)
|
|
725
|
-
const refsMapRef = useRef<Map<number, Record<string, ChatRef>>>(
|
|
726
|
-
new Map(persistedRef.current.refs ?? []),
|
|
727
|
-
)
|
|
669
|
+
const sourcesMapRef = useRef<Map<number, ChatSource[]>>(new Map())
|
|
670
|
+
const refsMapRef = useRef<Map<number, Record<string, ChatRef>>>(new Map())
|
|
728
671
|
const metaMapRef = useRef<Map<number, ChatTurnMeta>>(new Map())
|
|
729
|
-
const
|
|
730
|
-
const sendCountRef = useRef(persistedRef.current.sendCount)
|
|
672
|
+
const sendCountRef = useRef(0)
|
|
731
673
|
const [streamingPhase, setStreamingPhase] = useState<StreamingPhase>('idle')
|
|
732
674
|
const [metaTick, setMetaTick] = useState(0)
|
|
733
675
|
const bumpMetaTick = useCallback(() => setMetaTick((t) => t + 1), [])
|
|
@@ -740,19 +682,21 @@ export function useSseChatAdapter(
|
|
|
740
682
|
chatStreamUrl: runtime.endpoints.chatStreamUrl,
|
|
741
683
|
approvalToolUrl: runtime.endpoints.approvalToolUrl,
|
|
742
684
|
},
|
|
743
|
-
messagesRef,
|
|
744
685
|
sourcesMapRef,
|
|
745
686
|
refsMapRef,
|
|
746
687
|
metaMapRef,
|
|
747
688
|
setStreamingPhase,
|
|
748
689
|
bumpMetaTick,
|
|
749
690
|
sendCountRef,
|
|
691
|
+
conversationIdRef,
|
|
692
|
+
conversationStorage,
|
|
750
693
|
),
|
|
751
694
|
[
|
|
752
695
|
source,
|
|
753
696
|
runtime.endpoints.chatStreamUrl,
|
|
754
697
|
runtime.endpoints.approvalToolUrl,
|
|
755
698
|
bumpMetaTick,
|
|
699
|
+
conversationStorage,
|
|
756
700
|
],
|
|
757
701
|
)
|
|
758
702
|
|
|
@@ -792,20 +736,6 @@ export function useSseChatAdapter(
|
|
|
792
736
|
return map
|
|
793
737
|
}, [slashCommands])
|
|
794
738
|
|
|
795
|
-
// Persist on every messages change. Sources + sendCount live in refs,
|
|
796
|
-
// so we read their current values at write time.
|
|
797
|
-
const persist = useCallback(
|
|
798
|
-
(nextMessages: Message[]) => {
|
|
799
|
-
savePersistedChat(source, {
|
|
800
|
-
messages: nextMessages,
|
|
801
|
-
sources: Array.from(sourcesMapRef.current.entries()),
|
|
802
|
-
refs: Array.from(refsMapRef.current.entries()),
|
|
803
|
-
sendCount: sendCountRef.current,
|
|
804
|
-
})
|
|
805
|
-
},
|
|
806
|
-
[source],
|
|
807
|
-
)
|
|
808
|
-
|
|
809
739
|
const {
|
|
810
740
|
messages,
|
|
811
741
|
isTyping,
|
|
@@ -813,16 +743,32 @@ export function useSseChatAdapter(
|
|
|
813
743
|
sendMessage: chatSendMessage,
|
|
814
744
|
stopMessage: chatStopMessage,
|
|
815
745
|
clearMessages: chatClearMessages,
|
|
746
|
+
hydrateMessages,
|
|
816
747
|
hasMessages,
|
|
817
748
|
} = useChat({
|
|
818
749
|
useMock: false,
|
|
819
750
|
assistantName: 'Mingo AI',
|
|
820
751
|
streamFn,
|
|
821
|
-
initialMessages: persistedRef.current.messages,
|
|
822
|
-
onMessagesChange: persist,
|
|
823
752
|
})
|
|
824
753
|
|
|
825
|
-
|
|
754
|
+
// ─── Server hydration (history SSOT) ───
|
|
755
|
+
// Mount-time rebuild of the message list from the server transcript — the
|
|
756
|
+
// single store of conversation history. Extracted to its own hook; see
|
|
757
|
+
// `use-chat-history-hydration.ts` for the full contract + failure
|
|
758
|
+
// semantics (a miss starts the UI empty, never loses server context).
|
|
759
|
+
const historyUrl =
|
|
760
|
+
runtime.endpoints.chatHistoryUrl ??
|
|
761
|
+
`${runtime.endpoints.chatStreamUrl.replace(/\/+$/, '')}/history`
|
|
762
|
+
const { isHydratingHistory, hydratedKeyRef } = useChatHistoryHydration({
|
|
763
|
+
active,
|
|
764
|
+
source,
|
|
765
|
+
historyUrl,
|
|
766
|
+
conversationIdRef,
|
|
767
|
+
refsMapRef,
|
|
768
|
+
sendCountRef,
|
|
769
|
+
hydrateMessages,
|
|
770
|
+
bumpMetaTick,
|
|
771
|
+
})
|
|
826
772
|
|
|
827
773
|
// Index sources/refs/scrollAnchor by USER-SEND count (`sendIdx`), not
|
|
828
774
|
// by assistant-message count. Each user send produces exactly ONE
|
|
@@ -1008,17 +954,17 @@ export function useSseChatAdapter(
|
|
|
1008
954
|
refsMapRef.current.clear()
|
|
1009
955
|
metaMapRef.current.clear()
|
|
1010
956
|
sendCountRef.current = 0
|
|
957
|
+
// New chat = drop the stored conversation id. The old row stays frozen
|
|
958
|
+
// server-side; the NEXT send goes out id-less and the server mints a
|
|
959
|
+
// fresh conversation (echoed back and re-captured then).
|
|
960
|
+
conversationIdRef.current = null
|
|
961
|
+
conversationStorage.clear()
|
|
962
|
+
hydratedKeyRef.current = null
|
|
1011
963
|
setStreamingPhase('idle')
|
|
1012
964
|
// Force the latestMeta useMemo to recompute with the cleared map.
|
|
1013
965
|
bumpMetaTick()
|
|
1014
966
|
chatClearMessages()
|
|
1015
|
-
|
|
1016
|
-
if (typeof window !== 'undefined') {
|
|
1017
|
-
try {
|
|
1018
|
-
window.localStorage.removeItem(chatStorageKey(source))
|
|
1019
|
-
} catch {}
|
|
1020
|
-
}
|
|
1021
|
-
}, [chatClearMessages, source, bumpMetaTick])
|
|
967
|
+
}, [chatClearMessages, conversationStorage, hydratedKeyRef, bumpMetaTick])
|
|
1022
968
|
|
|
1023
969
|
// Reset to idle whenever both flags drop off.
|
|
1024
970
|
useEffect(() => {
|
|
@@ -1047,6 +993,8 @@ export function useSseChatAdapter(
|
|
|
1047
993
|
stopMessage,
|
|
1048
994
|
clearMessages,
|
|
1049
995
|
streamingPhase,
|
|
996
|
+
/** True while the mount-time rebuild from the server transcript runs. */
|
|
997
|
+
isHydratingHistory,
|
|
1050
998
|
/** Provider key for the lib's `<ModelDisplay>` icon. */
|
|
1051
999
|
currentProvider: latestMeta?.provider ?? null,
|
|
1052
1000
|
currentModelLabel: latestMeta?.modelLabel ?? null,
|
|
@@ -1061,12 +1009,11 @@ export function useSseChatAdapter(
|
|
|
1061
1009
|
* token counts). null until the trailing usage frame lands. */
|
|
1062
1010
|
currentUsageBreakdown: latestMeta?.breakdown ?? null,
|
|
1063
1011
|
// ─── Dialog management — stubs for v1 ────────────────────────────────
|
|
1064
|
-
// Guide mode
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1068
|
-
//
|
|
1069
|
-
// `dialogs.length === 0`.
|
|
1012
|
+
// Guide mode keeps ONE server-side conversation per stored id
|
|
1013
|
+
// (`chat_conversations`, hydrated on mount). Surfacing multiple threads
|
|
1014
|
+
// as a structured dialog list is a follow-up; for now the shape is
|
|
1015
|
+
// satisfied with empty defaults so the unified contract type-checks and
|
|
1016
|
+
// EmbeddableChat hides sidebar affordances when `dialogs.length === 0`.
|
|
1070
1017
|
dialogs: SSE_EMPTY_DIALOGS,
|
|
1071
1018
|
activeDialogId: null,
|
|
1072
1019
|
selectDialog: noopSelectDialog,
|
|
@@ -251,6 +251,12 @@ export interface UnifiedChatState {
|
|
|
251
251
|
/** Granular phase for the "Thinking..."/"Streaming..." status row above input. */
|
|
252
252
|
streamingPhase: StreamingPhase
|
|
253
253
|
|
|
254
|
+
/** True while the adapter is rebuilding the message list from the
|
|
255
|
+
* server-side transcript store (SSE/Guide mount-time hydration). Optional —
|
|
256
|
+
* transports without server hydration (NATS manages its own dialog
|
|
257
|
+
* loading) never set it. */
|
|
258
|
+
isHydratingHistory?: boolean
|
|
259
|
+
|
|
254
260
|
// ─── Actions ──────────────────────────────────────────────────────────────
|
|
255
261
|
sendMessage: (
|
|
256
262
|
text: string,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat conversation-id persistence — the SSE/Guide chat's ONLY local state.
|
|
3
|
+
*
|
|
4
|
+
* The stored value is the SERVER-minted conversation id echoed in the chat
|
|
5
|
+
* stream's leading metadata frame; the client never generates ids. Message
|
|
6
|
+
* history itself lives server-side (`chat_conversations` / `chat_messages`)
|
|
7
|
+
* and is rehydrated via the history endpoint — see
|
|
8
|
+
* `use-chat-history-hydration.ts`.
|
|
9
|
+
*
|
|
10
|
+
* Built on the lib-standard `createLocalStorageAdapter` (SSR guard,
|
|
11
|
+
* try/catch, quota-failure logging, dynamic namespacing) instead of raw
|
|
12
|
+
* `window.localStorage`. The namespace resolver runs on EVERY read/write, so
|
|
13
|
+
* a proxy-auth identity switch mid-page automatically re-partitions the key.
|
|
14
|
+
*
|
|
15
|
+
* Key shape: `mingo-chat-<source>[-u-<email>].conversation`
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
createLocalStorageAdapter,
|
|
20
|
+
type LocalStorageAdapter,
|
|
21
|
+
} from '../../../utils/local-storage-adapter'
|
|
22
|
+
import { getChatProxyAuth } from './chat-proxy-auth-storage'
|
|
23
|
+
|
|
24
|
+
export interface PersistedChatConversation {
|
|
25
|
+
conversationId: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const STORAGE_KEY = 'conversation'
|
|
29
|
+
|
|
30
|
+
const namespaceFor = (source: string): string => {
|
|
31
|
+
const base = `mingo-chat-${source}`
|
|
32
|
+
const auth = getChatProxyAuth()
|
|
33
|
+
return auth?.email ? `${base}-u-${encodeURIComponent(auth.email.toLowerCase())}` : base
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One adapter per chat `source` (= platform). Memoize per mount. */
|
|
37
|
+
export function createChatConversationStorage(
|
|
38
|
+
source: string,
|
|
39
|
+
): LocalStorageAdapter<PersistedChatConversation> {
|
|
40
|
+
return createLocalStorageAdapter<PersistedChatConversation>({
|
|
41
|
+
key: STORAGE_KEY,
|
|
42
|
+
namespace: () => namespaceFor(source),
|
|
43
|
+
validate: (parsed): parsed is PersistedChatConversation =>
|
|
44
|
+
!!parsed &&
|
|
45
|
+
typeof (parsed as PersistedChatConversation).conversationId === 'string' &&
|
|
46
|
+
(parsed as PersistedChatConversation).conversationId.length > 0,
|
|
47
|
+
logTag: '[chat-conversation-storage]',
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sweep THIS source's stale keys: the other-identity variants (base vs
|
|
53
|
+
* impersonation), and retired key shapes from before the server-minted-id
|
|
54
|
+
* design (raw `mingo-chat-<source>` and versioned `-v1`/`-v2` full-history
|
|
55
|
+
* stores). Boundary-aware: a hyphen-extended sibling source (`flamingo` vs
|
|
56
|
+
* `flamingo-teaser`) is NOT this source's namespace and must never be swept.
|
|
57
|
+
*
|
|
58
|
+
* Raw storage enumeration is unavoidable here — the adapter abstraction is
|
|
59
|
+
* per-key and Web Storage only exposes iteration on the raw object.
|
|
60
|
+
*/
|
|
61
|
+
export function pruneStaleChatConversationStorage(source: string): void {
|
|
62
|
+
if (typeof window === 'undefined') return
|
|
63
|
+
try {
|
|
64
|
+
const base = `mingo-chat-${source}`
|
|
65
|
+
const currentKey = `${namespaceFor(source)}.${STORAGE_KEY}`
|
|
66
|
+
const ownedKey = (k: string) =>
|
|
67
|
+
k === base || // retired raw key (pre-adapter shape)
|
|
68
|
+
k === `${base}.${STORAGE_KEY}` || // current no-identity shape
|
|
69
|
+
k.startsWith(`${base}-u-`) || // per-identity shapes (current + retired)
|
|
70
|
+
k.startsWith(`${base}-v`) // retired versioned full-history shapes
|
|
71
|
+
const toRemove: string[] = []
|
|
72
|
+
for (let i = 0; i < window.localStorage.length; i++) {
|
|
73
|
+
const k = window.localStorage.key(i)
|
|
74
|
+
if (!k) continue
|
|
75
|
+
if (ownedKey(k) && k !== currentKey) toRemove.push(k)
|
|
76
|
+
}
|
|
77
|
+
for (const k of toRemove) {
|
|
78
|
+
window.localStorage.removeItem(k)
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// localStorage access blocked (Safari private mode etc.) — non-fatal.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -57,6 +57,14 @@ export interface ChatRuntime {
|
|
|
57
57
|
listEngagementsUrl?: string
|
|
58
58
|
/** GET slash-command catalog. Hub: '/api/docs/commands'. */
|
|
59
59
|
commandsUrl: string
|
|
60
|
+
/** GET server-side conversation history (`?conversationId=<id>`) — the
|
|
61
|
+
* chat panel's mount-time hydration read against the server transcript
|
|
62
|
+
* store (localStorage keeps only the server-issued conversation id).
|
|
63
|
+
* OPTIONAL — defaults to `<chatStreamUrl>/history`, which is correct for
|
|
64
|
+
* same-origin hosts AND for reverse-proxy embedders (the proxied chat
|
|
65
|
+
* prefix covers it). Set explicitly only when the history route lives
|
|
66
|
+
* elsewhere. */
|
|
67
|
+
chatHistoryUrl?: string
|
|
60
68
|
/** GET RAG-search endpoint behind `<DocSearchBar>` (the in-source search
|
|
61
69
|
* bar mounted by `<DocViewer>` / `<DocsHubPage>` when `showAIChat` is on).
|
|
62
70
|
* Hub: '/api/docs/search'. OPTIONAL — falls back to the hub path so
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-2B6773QM.cjs","../src/contexts/endpoints-runtime-context.tsx","../src/contexts/chat-runtime-context.tsx"],"names":["createContext","useContext"],"mappings":"AAAA,qFAAY;AACZ;AACA;ACqBA,8BAA0C;AAenC,IAAM,wBAAA,EAA0B,kCAAA,IAA2C,CAAA;AAO3E,SAAS,mBAAA,CAAA,EAA+C;AAC7D,EAAA,OAAO,+BAAA,uBAAkC,CAAA;AAC3C;AASO,SAAS,2BAAA,CAAA,EAAgD;AAC9D,EAAA,MAAM,EAAA,EAAI,+BAAA,uBAAkC,CAAA;AAC5C,EAAA,GAAA,CAAI,CAAC,CAAA,EAAG;AACN,IAAA,MAAM,IAAI,KAAA;AAAA,MACR;AAAA,IAIF,CAAA;AAAA,EACF;AACA,EAAA,OAAO,CAAA;AACT;ADlDA;AACA;AEiBA;AA0MO,IAAM,mBAAA,EAAqBA,kCAAAA,IAAsC,CAAA;AAQjE,SAAS,cAAA,CAAA,EAAqC;AACnD,EAAA,OAAOC,+BAAAA,kBAA6B,CAAA;AACtC;AAUO,SAAS,sBAAA,CAAA,EAAsC;AACpD,EAAA,MAAM,EAAA,EAAIA,+BAAAA,kBAA6B,CAAA;AACvC,EAAA,GAAA,CAAI,CAAC,CAAA,EAAG;AACN,IAAA,MAAM,IAAI,KAAA;AAAA,MACR;AAAA,IAMF,CAAA;AAAA,EACF;AACA,EAAA,OAAO,CAAA;AACT;AF7OA;AACA;AACE;AACA;AACA;AACA;AACA;AACA;AACF,mUAAC","file":"/home/runner/work/openframe-oss-lib/openframe-oss-lib/openframe-frontend-core/dist/chunk-2B6773QM.cjs","sourcesContent":[null,"'use client'\n\n/**\n * Endpoints runtime — sibling of ChatRuntime. Carries the API path\n * literals consumed by oss-lib components/hooks/utils so a host\n * application can override them (e.g. when running behind a reverse\n * proxy as `user1.openframe.ai` → `/api/mingo-guide/*`).\n *\n * The hub mounts `<HubRuntimeProvider>` at root with the\n * canonical hub paths; an embedded app mounts its own provider with\n * remapped paths. The pattern mirrors ChatRuntimeContext exactly:\n *\n * - `useEndpointsRuntime()` returns null when no provider is mounted.\n * For optional consumers that should gracefully no-op without one.\n * - `useRequiredEndpointsRuntime()` throws on missing provider — for\n * hooks/components that cannot function without endpoints.\n *\n * IMPORTANT for embedders: memoize the value passed to\n * `<EndpointsRuntimeContext.Provider value={...}>` (e.g. React.useMemo).\n * Reference changes invalidate downstream effect dependency arrays and\n * trigger unnecessary re-fetches.\n */\n\nimport { createContext, useContext } from 'react'\n\nexport interface EndpointsRuntime {\n /** GET active announcement (used by `<AnnouncementBar>` mount fetch + refocus revalidation). */\n announcementsUrl: string\n accessCode: {\n /** POST validate access code. */\n validateUrl: string\n /** POST consume / redeem access code after registration. */\n consumeUrl: string\n }\n /** POST contact-form submission. */\n contactUrl: string\n}\n\nexport const EndpointsRuntimeContext = createContext<EndpointsRuntime | null>(null)\n\n/**\n * Optional read — returns null when no provider is mounted. Use for\n * surfaces that should silently skip the fetch (e.g. announcement\n * polling on a page rendered outside the provider tree).\n */\nexport function useEndpointsRuntime(): EndpointsRuntime | null {\n return useContext(EndpointsRuntimeContext)\n}\n\n/**\n * Strict variant — throws on missing provider. Use for consumers that\n * cannot function without an endpoint (form submission, code\n * validation). In tests/Storybook, wrap with the hub's\n * `<HubRuntimeProvider>` or a stub\n * `<EndpointsRuntimeContext.Provider value={mockedEndpoints}>`.\n */\nexport function useRequiredEndpointsRuntime(): EndpointsRuntime {\n const v = useContext(EndpointsRuntimeContext)\n if (!v) {\n throw new Error(\n '[endpoints-runtime] hook called outside an <EndpointsRuntimeContext.Provider>. ' +\n 'Hub: mount <HubRuntimeProvider> in your providers tree. ' +\n 'Embedded app: mount your own provider with proxied URLs at the tree root. ' +\n 'Tests/Storybook: wrap render() in <EndpointsRuntimeContext.Provider value={mocked}>.',\n )\n }\n return v\n}\n","'use client'\n\n/**\n * Chat runtime context — single seam for embedding the chat panel in a\n * different host (e.g. user1.openframe.ai reverse-proxying API calls\n * under /api/mingo-guide/* to hub.openframe.ai/api/*).\n *\n * Three concerns, one context:\n * 1. API endpoints: chatStreamUrl / approvalToolUrl / commandsUrl /\n * buildListUrl + attachment endpoints + chat-identity. The chat\n * reads them from runtime; hub vs embedded app supply different\n * strings via different providers.\n * 2. Navigation mode + callbacks: 'host' or 'embed' mode. Host wires\n * its own router/docNav via the optional `navigate` callback\n * (plain function, NOT a hook); embed forces new-tab via\n * `defaultContentOrigin` + lib's `resolveExternalNavigation`.\n * 3. Identity context: only `source` (required for localStorage\n * namespacing). The display identity (greeting first-name etc.)\n * comes from the server via `useChatIdentity()` — never injected\n * client-side, so it always matches the server-resolved auth.\n *\n * Sibling of EndpointsRuntimeContext (announcement bar, contact form,\n * access codes). Each runtime stays an independent React context so\n * embedders can opt into either feature without forcing the other.\n *\n * IMPORTANT for embedders: memoize the value passed to\n * `<ChatRuntimeContext.Provider value={...}>` (e.g. via React.useMemo).\n * Every change to its reference identity invalidates downstream\n * `useMemo` consumers (the chat input's slash-commands binding,\n * useNavLink's embed-resolution memo, useDocChat's streamFn factory).\n * The hub's `<HubRuntimeProvider>` already memoizes correctly with\n * stable deps. Embedded apps that build the value inline on each render\n * will pay an avoidable re-render cost across the entire chat tree.\n */\n\nimport { createContext, useContext, type ReactNode } from 'react'\n\nimport type { ComposeContentUrl } from '../utils/content-href'\n\n/**\n * Runtime config consumed by the chat panel.\n */\nexport interface ChatRuntime {\n endpoints: {\n /** POST streaming chat. Hub: '/api/docs/chat'. */\n chatStreamUrl: string\n /** POST agent approve/reject. Hub: '/api/chat/agent/confirm-tool'. */\n approvalToolUrl: string\n /** Customer-ticket agent endpoints (Help Center). OPTIONAL — when unset,\n * the ticket hooks fall back to the bare hub paths\n * (`/api/chat/agent/{find-ticket,ticket-action,list-engagements}`).\n * Embedders behind a reverse proxy set these to their proxied paths\n * (e.g. `/content/api/chat/agent/...`) so tickets route through the SAME\n * endpoint config + proxy as every other endpoint. */\n findTicketUrl?: string\n ticketActionUrl?: string\n listEngagementsUrl?: string\n /** GET slash-command catalog. Hub: '/api/docs/commands'. */\n commandsUrl: string\n /** GET RAG-search endpoint behind `<DocSearchBar>` (the in-source search\n * bar mounted by `<DocViewer>` / `<DocsHubPage>` when `showAIChat` is on).\n * Hub: '/api/docs/search'. OPTIONAL — falls back to the hub path so\n * same-origin Next.js hosts don't need to set it. Cross-origin embedders\n * set their proxied path so the search bar routes through the same\n * reverse proxy as everything else. Same pattern as `findTicketUrl`. */\n docsSearchUrl?: string\n /** POST internal-link resolver. The in-source markdown renderer (lib or\n * custom) calls `<DocViewer>`'s `handlers.onResolveLink(href, currentPath)`\n * for relative hrefs like `./getting-started/intro.md` — that callback\n * posts to this URL with `{ link, currentPath, source }` and expects a\n * `ResolveLinkResult` back. Hub: '/api/docs/resolve-link'. OPTIONAL — same\n * fall-back chain as `docsSearchUrl`: prop override → runtime → default. */\n docsResolveLinkUrl?: string\n /** GET per-platform empty-state config (admin-edited in\n * `/admin/chat-config`): `{ greeting, enabledRagTableIds, suggestedQueries }`.\n * Hub: '/api/docs/empty-state'. OPTIONAL — the in-app (host-mode) chat\n * injects these values as SSR props instead, so it leaves this unset.\n * Cross-origin EMBEDDERS (no server hop) set it to their proxied path\n * (e.g. '/content/api/docs/empty-state') so `<EmbeddableChat>` can fetch\n * the greeting / quick-action chips / RAG-source filter at runtime. When\n * unset, the chat falls back to the explicit `emptyStateGreeting` /\n * `suggestedQueries` / `enabledRagTableIds` props (or in-code defaults). */\n emptyStateUrl?: string\n /** Build the per-agent display-config URL for an OpenFrame AI agent\n * (Fae/Mingo). OPTIONAL. The returned endpoint MUST be byte-compatible\n * with the empty-state wire shape ({ greeting, suggestedQueries,\n * enabledRagTableIds }). When set AND `EmbeddableChat` receives an\n * `activeAgentSlug` in embed mode, the chat fetches THIS url instead of\n * `emptyStateUrl` to render the selected agent's greeting + suggested\n * prompts (the \"agent mode\" URL override). Hub: `(slug) =>\n * '/api/ai-agents/' + encodeURIComponent(slug)`. */\n aiAgentConfigUrl?: (slug: string) => string\n /** Build entity-card list URL for a content type + ids. Hub delegates\n * to the rag-table-config registry; embedded app provides its own\n * per-type URL builder against the reverse proxy. Returns null when\n * the type has no list endpoint (caller skips rendering). */\n buildListUrl: (type: string, ids: string[]) => string | null\n /** Chat-attachment endpoints — added for the v2 attachment feature.\n *\n * Three concerns:\n * - `attachmentUploadUrl` — POSTed by the chat-attachment hook\n * to mint a Supabase signed-upload-URL + HMAC view token.\n * - `attachmentViewUrlPrefix` — embedded in markdown URLs the\n * chat hosts in user message bubbles (`![]()` / `[Attached]`).\n * Stored in chat history; chosen at SEND time. In host mode the\n * relative `/api/storage/view/chat-attachments/` is sufficient\n * (same-origin); embedders supply an absolute hub URL so the\n * browser can fetch cross-origin.\n * - `identityUrl` — GET endpoint the `useChatIdentity` hook\n * hits to learn the `{authTier, source, attachmentsEnabled}`\n * capability bag for the current session. Used beyond chat\n * (tickets / contact form / any embedded surface that needs\n * to identify the proxied customer), so the name has no\n * \"chat\" prefix even though the consuming hook still does. */\n attachmentUploadUrl: string\n attachmentViewUrlPrefix: string\n identityUrl: string\n /** Optional URL prefix for the image proxy (`<prefix>?url=<external>`).\n * When unset, lib's `getProxiedImageUrl` returns the original URL\n * unchanged. Hub default: '/api/image-proxy'. Embedders that don't\n * host an image-proxy route leave this undefined → images load\n * directly cross-origin (CORS-permitting). */\n imageProxyUrlPrefix?: string\n /** Optional list of hostnames that should bypass the image proxy\n * (rendered direct). Hub uses ['openmsp.ai']; embedders typically\n * leave it unset. Matches the `skipDomains` parameter of\n * `getProxiedImageUrl`. */\n imageProxySkipDomains?: string[]\n /** Optional base URL for the branded og-placeholder image route — the\n * DEFAULT cover-image fallback for entity cards with no image. The lib\n * appends `?title=…` (+ `w`/`h` for square slots) itself, so this is\n * the base, NOT a full URL: relative (`/api/og-placeholder`) for same-\n * origin hosts, or the proxied path (`/content/api/og-placeholder`) for\n * cross-origin embedders. May carry baked-in query params (preserved when\n * the lib layers `title`/dimensions on top) — but per-platform brand\n * colors are NO LONGER baked here; the `/api/og-placeholder` route\n * resolves them server-side from the platform. Most hosts leave this unset\n * and let the lib derive the base from `imageProxyUrlPrefix`.\n *\n * OPTIONAL — when unset the lib derives the base from the sibling\n * `imageProxyUrlPrefix` (same API base, route name swapped), then falls\n * back to the relative `/api/og-placeholder`. So an embedder that already\n * proxies images needs NO og-placeholder wiring. See\n * `resolveOgPlaceholderBase` / `buildOgPlaceholderUrl` in `../utils`. */\n ogPlaceholderUrl?: string\n /** Supabase storage origin (e.g. `https://xyz.supabase.co`) — used\n * by `useVideoWarmup` to scope the `<link rel=\"preload\" as=\"video\">`\n * hint to MP4s the deployment actually hosts. Hub wires it via\n * `getSupabaseStorageOrigin()`; embedders without a Supabase\n * storage origin leave it unset (preload is then skipped; Mux/\n * YouTube preconnect still fires). */\n supabaseStorageOrigin?: string\n }\n navigation: {\n /** ONE knob, two behaviors:\n * - 'host' = use the host page's existing click-routing untouched.\n * The chat panel calls `navigate?.()` for in-app routing.\n * - 'embed' = guest inside another app: short-circuit at the top\n * of click handlers to force new-tab + absolutize via\n * resolveExternalNavigation. */\n mode: 'host' | 'embed'\n /** Embed-only fallback origin for relative URLs whose target platform\n * can't be inferred. Used by resolveExternalNavigation when\n * `targetPlatform` is null — without this, a relative `/foo` href would\n * window.open against the embedder's origin, which is WRONG.\n * Set to your content host (e.g. 'https://hub.openframe.ai').\n * Required by the embedded app whenever mode='embed'. */\n defaultContentOrigin?: string\n /** Override for opening external URLs. MUST BE SYNCHRONOUS —\n * Safari/Firefox block popups opened outside a direct user gesture.\n * Default: window.open(href, '_blank', 'noopener,noreferrer'). */\n openExternal?: (href: string) => void\n /** Optional in-app navigation callback (host-mode only).\n * Returns `true` if the host handled the click in-app\n * (router.push + docNav.navigate); returns `false`, `undefined`,\n * or `void` → lib falls back to window.location.assign(href).\n * Hub wires this via HubRuntimeProvider's HubNavigationWiring;\n * embedders not in Next.js leave it undefined. */\n navigate?: (input: { href: string; path?: string | null; targetPlatform?: string | null }) => boolean | void\n /** Optional new-tab decision callback. Returns true → lib opens in\n * new tab; false → same tab via `navigate`. Hub wires the existing\n * `decideNewTab` logic from use-nav-link.tsx (re-imports the pure\n * helper from lib). Embedders may omit; lib defaults to:\n * same-origin/same-platform → same tab, else new tab. */\n decideNewTab?: (args: { href: string; targetPlatform?: string | null }) => boolean\n }\n /** Optional content-URL composer. Returns the platform-aware href +\n * target-platform tuple for a content entity. Hub wires this to its\n * `buildContentURL(type, slug, extractPrimaryPlatform(platforms))`\n * pipeline so the lib catalog/detail views can derive cross-\n * platform hrefs without knowing the hub's platform topology\n * (openmsp.ai / openframe.app / flamingo.run / tmcg).\n *\n * THE single content-href authority for every embeddable surface — page\n * views (onboarding catalog/detail, releases) AND chat cards / chips /\n * search results all resolve content links through this one seam, so a\n * given type lands in the SAME place regardless of where it's rendered.\n * Embedders wire `makeComposeContentUrl({ hostedTypes, contentOrigin })`;\n * omit it and lib views fall back to a same-origin relative path\n * (`buildDefaultHref`).\n *\n * Takes a single `ComposeContentUrlInput`: `type` + `identifier` (page\n * views pass the slug; chat rows pass the id + `externalUrl`, whose path\n * yields the slug for in-app routing) + optional `platforms` /\n * `externalUrl` / `targetPlatform`. */\n composeContentUrl?: ComposeContentUrl\n /** Per-`documentType` doc-viewer targets — the UNIFIED, DYNAMIC replacement for\n * the single `chipBasePlatform` prop. Maps a doc-table documentType\n * (`'markdown'`, `'data_room_doc'`, …) → `{ platform, basePath }` for the PUBLIC\n * doc viewer that hosts it. Doc chips with no `externalUrl` resolve PER ROW to\n * `getBaseUrl(platform)/<basePath>/<path>`, so a chat mixing several doc sources\n * sends EACH to its own home (markdown→flamingo/knowledge-base,\n * data_room_doc→company-hub/data-room) instead of one static fallback. The hub\n * may keep using `chipBasePlatform` (one doc source per platform); embedders that\n * surface multiple doc sources wire this. Threaded into `resolveSourceRowCTA`. */\n docPlatformTargets?: Record<string, { platform: string; basePath: string }>\n /** Chat source / platform identifier — OPTIONAL. The hub sets it from\n * `currentPlatform()`; EMBEDDERS leave it unset and stay platform-agnostic.\n *\n * It is NOT required for chat to work. The wire resolves source server-side\n * (`/docs/chat|search|commands` reject any client `source`); the\n * same-tab-vs-new-tab link decision falls back to an origin comparison when\n * it's absent (`decideNewTab` → `isCrossOriginUrl`); and the localStorage\n * history namespace falls back to a stable constant. Set it only where the\n * client legitimately needs to know its platform a priori — i.e. the hub,\n * where several platforms share related origins so \"same platform\" can't be\n * inferred from a URL alone. */\n source?: string\n // NOTE: No `user` field. The chat's display identity (greeting\n // first-name, etc.) comes from the SERVER-resolved auth via\n // `useChatIdentity()` — the same identity the server uses to\n // authorize requests. Letting embedders pass a client-side `user`\n // would let it desync from the actual auth tier, causing greetings\n // like \"Hey Bob\" while the server treats the session as\n // alice@example.com. Single source of truth: the server.\n}\n\nexport const ChatRuntimeContext = createContext<ChatRuntime | null>(null)\n\n/**\n * Returns the active runtime, or null when no provider is mounted.\n * NULL is a first-class value — it signals \"no chat runtime configured.\"\n * Optional consumers fall back to no-op behavior; strict consumers\n * use `useRequiredChatRuntime` (below).\n */\nexport function useChatRuntime(): ChatRuntime | null {\n return useContext(ChatRuntimeContext)\n}\n\n/**\n * Strict variant used INSIDE the chat panel. Throws if no provider.\n * The hub guarantees one exists by mounting `<HubRuntimeProvider>` at\n * root; the embedded app mounts its own `<ChatRuntimeContext.Provider>`\n * at the tree root. In Jest / Storybook tests that render chat\n * internals directly, wrap with `<HubRuntimeProvider>` (hub defaults)\n * or supply `<ChatRuntimeContext.Provider value={mockedRuntime}>`.\n */\nexport function useRequiredChatRuntime(): ChatRuntime {\n const v = useContext(ChatRuntimeContext)\n if (!v) {\n throw new Error(\n '[chat-runtime] hook called outside a <ChatRuntimeContext.Provider>. ' +\n 'The hub mounts <HubRuntimeProvider> at root — this only fires when ' +\n 'chat internals are rendered above the provider tree. ' +\n 'Fix: ensure the rendering subtree descends from the runtime provider. ' +\n 'In tests/Storybook: wrap with <HubRuntimeProvider> or supply ' +\n 'a <ChatRuntimeContext.Provider value={mockedRuntime}>.',\n )\n }\n return v\n}\n"]}
|