@open-mercato/ui 0.6.1-develop.3167.1.7aedd7b2ba → 0.6.1-develop.3179.1.9ee0441adc
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/src/ai/useAiChat.ts
CHANGED
|
@@ -119,6 +119,9 @@ function makeConversationId(): string {
|
|
|
119
119
|
|
|
120
120
|
const SESSION_STORAGE_PREFIX = 'om-ai-chat:'
|
|
121
121
|
const SESSION_STORAGE_VERSION = 1
|
|
122
|
+
const SESSION_UPDATED_EVENT = 'om-ai-chat-session-updated'
|
|
123
|
+
const SESSION_STREAM_STATE_EVENT = 'om-ai-chat-session-stream-state'
|
|
124
|
+
const activeSessionStreams = new Set<string>()
|
|
122
125
|
|
|
123
126
|
interface PersistedAiChatSession {
|
|
124
127
|
v: number
|
|
@@ -138,6 +141,28 @@ function getSessionStorageKey(agent: string, conversationId?: string | null): st
|
|
|
138
141
|
return `${SESSION_STORAGE_PREFIX}${agent}`
|
|
139
142
|
}
|
|
140
143
|
|
|
144
|
+
function writeSessionStreamState(storageKey: string, active: boolean): void {
|
|
145
|
+
if (active) {
|
|
146
|
+
activeSessionStreams.add(storageKey)
|
|
147
|
+
} else {
|
|
148
|
+
activeSessionStreams.delete(storageKey)
|
|
149
|
+
}
|
|
150
|
+
if (typeof window === 'undefined') return
|
|
151
|
+
try {
|
|
152
|
+
window.dispatchEvent(
|
|
153
|
+
new CustomEvent(SESSION_STREAM_STATE_EVENT, {
|
|
154
|
+
detail: { key: storageKey, active },
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
} catch {
|
|
158
|
+
// ignore event dispatch failures in non-browser test environments
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isSessionStreamActive(storageKey: string): boolean {
|
|
163
|
+
return activeSessionStreams.has(storageKey)
|
|
164
|
+
}
|
|
165
|
+
|
|
141
166
|
function readPersistedSession(
|
|
142
167
|
agent: string,
|
|
143
168
|
conversationId?: string | null,
|
|
@@ -169,6 +194,7 @@ function writePersistedSession(
|
|
|
169
194
|
agent: string,
|
|
170
195
|
session: PersistedAiChatSession,
|
|
171
196
|
conversationId?: string | null,
|
|
197
|
+
options?: { notify?: boolean },
|
|
172
198
|
): void {
|
|
173
199
|
if (typeof window === 'undefined') return
|
|
174
200
|
try {
|
|
@@ -189,10 +215,15 @@ function writePersistedSession(
|
|
|
189
215
|
})
|
|
190
216
|
return { ...message, files: safeFiles }
|
|
191
217
|
})
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
218
|
+
const storageKey = getSessionStorageKey(agent, conversationId)
|
|
219
|
+
window.localStorage.setItem(storageKey, JSON.stringify({ ...session, messages }))
|
|
220
|
+
if (options?.notify) {
|
|
221
|
+
window.dispatchEvent(
|
|
222
|
+
new CustomEvent(SESSION_UPDATED_EVENT, {
|
|
223
|
+
detail: { key: storageKey, agent, conversationId: session.conversationId },
|
|
224
|
+
}),
|
|
225
|
+
)
|
|
226
|
+
}
|
|
196
227
|
} catch {
|
|
197
228
|
// Quota exceeded / privacy mode — silently drop persistence.
|
|
198
229
|
}
|
|
@@ -550,6 +581,11 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
550
581
|
typeof conversationIdInput === 'string' && conversationIdInput.length > 0
|
|
551
582
|
? conversationIdInput
|
|
552
583
|
: mintedConversationIdRef.current
|
|
584
|
+
const persistKey =
|
|
585
|
+
typeof conversationIdInput === 'string' && conversationIdInput.length > 0
|
|
586
|
+
? conversationIdInput
|
|
587
|
+
: null
|
|
588
|
+
const sessionStorageKey = getSessionStorageKey(agent, persistKey)
|
|
553
589
|
|
|
554
590
|
const [messages, setMessages] = React.useState<AiChatMessage[]>(() => {
|
|
555
591
|
if (persisted && persisted.messages.length > 0) {
|
|
@@ -565,13 +601,40 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
565
601
|
// Persist messages + conversationId on every change. Skip during in-flight
|
|
566
602
|
// streaming so we do not write the same growing string on every chunk —
|
|
567
603
|
// the next idle tick captures the final assistant content.
|
|
568
|
-
const [status, setStatusInternal] = React.useState<'idle' | 'submitting' | 'streaming'>(
|
|
604
|
+
const [status, setStatusInternal] = React.useState<'idle' | 'submitting' | 'streaming'>(() =>
|
|
605
|
+
isSessionStreamActive(sessionStorageKey) ? 'streaming' : 'idle',
|
|
606
|
+
)
|
|
607
|
+
// Refs mirror the latest persist state so the unmount cleanup can flush
|
|
608
|
+
// an in-flight assistant message to localStorage even though the streaming
|
|
609
|
+
// skip above (and React's stale-closure semantics) would otherwise lose it.
|
|
610
|
+
// Without this, closing the dock or switching agents while the assistant
|
|
611
|
+
// is "Thinking" abandons the partial reply (issue #1816).
|
|
612
|
+
const latestMessagesRef = React.useRef<AiChatMessage[]>(messages)
|
|
613
|
+
const persistKeyRef = React.useRef<string | null>(null)
|
|
614
|
+
const agentRef = React.useRef<string>(agent)
|
|
615
|
+
const effectiveConversationIdRef = React.useRef<string>(effectiveConversationId)
|
|
616
|
+
const sessionStorageKeyRef = React.useRef<string>(sessionStorageKey)
|
|
617
|
+
const mountedRef = React.useRef(true)
|
|
569
618
|
React.useEffect(() => {
|
|
570
|
-
|
|
571
|
-
|
|
619
|
+
latestMessagesRef.current = messages
|
|
620
|
+
}, [messages])
|
|
621
|
+
React.useEffect(() => {
|
|
622
|
+
persistKeyRef.current =
|
|
572
623
|
typeof conversationIdInput === 'string' && conversationIdInput.length > 0
|
|
573
624
|
? conversationIdInput
|
|
574
625
|
: null
|
|
626
|
+
}, [conversationIdInput])
|
|
627
|
+
React.useEffect(() => {
|
|
628
|
+
agentRef.current = agent
|
|
629
|
+
}, [agent])
|
|
630
|
+
React.useEffect(() => {
|
|
631
|
+
effectiveConversationIdRef.current = effectiveConversationId
|
|
632
|
+
}, [effectiveConversationId])
|
|
633
|
+
React.useEffect(() => {
|
|
634
|
+
sessionStorageKeyRef.current = sessionStorageKey
|
|
635
|
+
}, [sessionStorageKey])
|
|
636
|
+
React.useEffect(() => {
|
|
637
|
+
if (status !== 'idle') return
|
|
575
638
|
if (messages.length === 0) {
|
|
576
639
|
clearPersistedSession(agent, persistKey)
|
|
577
640
|
return
|
|
@@ -585,8 +648,45 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
585
648
|
},
|
|
586
649
|
persistKey,
|
|
587
650
|
)
|
|
588
|
-
}, [agent,
|
|
589
|
-
const
|
|
651
|
+
}, [agent, effectiveConversationId, messages, persistKey, status])
|
|
652
|
+
const persistSnapshot = React.useCallback((snapshot: AiChatMessage[], notify = false) => {
|
|
653
|
+
if (snapshot.length === 0) return
|
|
654
|
+
writePersistedSession(
|
|
655
|
+
agentRef.current,
|
|
656
|
+
{
|
|
657
|
+
v: SESSION_STORAGE_VERSION,
|
|
658
|
+
conversationId: effectiveConversationIdRef.current,
|
|
659
|
+
messages: snapshot,
|
|
660
|
+
},
|
|
661
|
+
persistKeyRef.current,
|
|
662
|
+
{ notify },
|
|
663
|
+
)
|
|
664
|
+
}, [])
|
|
665
|
+
|
|
666
|
+
const updateMessages = React.useCallback(
|
|
667
|
+
(
|
|
668
|
+
updater:
|
|
669
|
+
| AiChatMessage[]
|
|
670
|
+
| ((current: AiChatMessage[]) => AiChatMessage[]),
|
|
671
|
+
options?: { persistWhenUnmounted?: boolean },
|
|
672
|
+
) => {
|
|
673
|
+
const current = latestMessagesRef.current
|
|
674
|
+
const next = typeof updater === 'function' ? updater(current) : updater
|
|
675
|
+
latestMessagesRef.current = next
|
|
676
|
+
if (mountedRef.current) {
|
|
677
|
+
setMessages(next)
|
|
678
|
+
} else if (options?.persistWhenUnmounted) {
|
|
679
|
+
persistSnapshot(next, true)
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
[persistSnapshot],
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
const setStatus = React.useCallback((next: 'idle' | 'submitting' | 'streaming') => {
|
|
686
|
+
if (mountedRef.current) {
|
|
687
|
+
setStatusInternal(next)
|
|
688
|
+
}
|
|
689
|
+
}, [])
|
|
590
690
|
const [error, setError] = React.useState<AiChatErrorEnvelope | null>(null)
|
|
591
691
|
const [lastRequestDebug, setLastRequestDebug] = React.useState<
|
|
592
692
|
{ url: string; body: unknown } | null
|
|
@@ -602,12 +702,14 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
602
702
|
}, [onError])
|
|
603
703
|
|
|
604
704
|
const emitError = React.useCallback((envelope: AiChatErrorEnvelope) => {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
705
|
+
if (mountedRef.current) {
|
|
706
|
+
setError(envelope)
|
|
707
|
+
try {
|
|
708
|
+
onErrorRef.current?.(envelope)
|
|
709
|
+
} catch {
|
|
710
|
+
// UI layer must never throw because a caller-supplied error handler
|
|
711
|
+
// misbehaved.
|
|
712
|
+
}
|
|
611
713
|
}
|
|
612
714
|
}, [])
|
|
613
715
|
|
|
@@ -616,18 +718,19 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
616
718
|
abortRef.current.abort()
|
|
617
719
|
abortRef.current = null
|
|
618
720
|
}
|
|
721
|
+
writeSessionStreamState(sessionStorageKeyRef.current, false)
|
|
619
722
|
setStatus('idle')
|
|
620
|
-
}, [])
|
|
723
|
+
}, [setStatus])
|
|
621
724
|
|
|
622
725
|
const reset = React.useCallback(() => {
|
|
623
726
|
cancel()
|
|
624
|
-
|
|
727
|
+
updateMessages([])
|
|
625
728
|
setError(null)
|
|
626
729
|
setLastRequestDebug(null)
|
|
627
730
|
setLastResponseDebug(null)
|
|
628
|
-
clearPersistedSession(agent)
|
|
731
|
+
clearPersistedSession(agent, persistKey)
|
|
629
732
|
mintedConversationIdRef.current = makeConversationId()
|
|
630
|
-
}, [agent, cancel])
|
|
733
|
+
}, [agent, cancel, persistKey, updateMessages])
|
|
631
734
|
|
|
632
735
|
const sendMessage = React.useCallback(
|
|
633
736
|
async (textInput: string, files?: AiChatMessageFile[]) => {
|
|
@@ -652,9 +755,10 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
652
755
|
const assistantId = assistantMessage.id
|
|
653
756
|
// Snapshot prior messages for request payload so the dispatcher sees the
|
|
654
757
|
// full turn history including the just-added user message.
|
|
655
|
-
const outgoingHistory = [...
|
|
656
|
-
|
|
758
|
+
const outgoingHistory = [...latestMessagesRef.current, userMessage]
|
|
759
|
+
updateMessages([...outgoingHistory, assistantMessage], { persistWhenUnmounted: true })
|
|
657
760
|
setStatus('submitting')
|
|
761
|
+
writeSessionStreamState(sessionStorageKey, true)
|
|
658
762
|
|
|
659
763
|
const controller = new AbortController()
|
|
660
764
|
abortRef.current = controller
|
|
@@ -670,7 +774,9 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
670
774
|
debug,
|
|
671
775
|
conversationId: effectiveConversationId,
|
|
672
776
|
}
|
|
673
|
-
|
|
777
|
+
if (mountedRef.current) {
|
|
778
|
+
setLastRequestDebug({ url, body })
|
|
779
|
+
}
|
|
674
780
|
|
|
675
781
|
let response: Response
|
|
676
782
|
try {
|
|
@@ -685,6 +791,7 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
685
791
|
})
|
|
686
792
|
} catch (requestError) {
|
|
687
793
|
if ((requestError as { name?: string })?.name === 'AbortError') {
|
|
794
|
+
writeSessionStreamState(sessionStorageKey, false)
|
|
688
795
|
setStatus('idle')
|
|
689
796
|
abortRef.current = null
|
|
690
797
|
return
|
|
@@ -694,6 +801,7 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
694
801
|
? requestError.message
|
|
695
802
|
: 'Network request failed.'
|
|
696
803
|
emitError({ message })
|
|
804
|
+
writeSessionStreamState(sessionStorageKey, false)
|
|
697
805
|
setStatus('idle')
|
|
698
806
|
abortRef.current = null
|
|
699
807
|
return
|
|
@@ -701,17 +809,25 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
701
809
|
|
|
702
810
|
if (!response.ok) {
|
|
703
811
|
const envelope = await readErrorEnvelope(response)
|
|
704
|
-
|
|
812
|
+
if (mountedRef.current) {
|
|
813
|
+
setLastResponseDebug({ status: response.status, text: envelope.message })
|
|
814
|
+
}
|
|
705
815
|
emitError(envelope)
|
|
816
|
+
writeSessionStreamState(sessionStorageKey, false)
|
|
706
817
|
setStatus('idle')
|
|
707
|
-
|
|
818
|
+
updateMessages((current) => current.filter((entry) => entry.id !== assistantId), {
|
|
819
|
+
persistWhenUnmounted: true,
|
|
820
|
+
})
|
|
708
821
|
abortRef.current = null
|
|
709
822
|
return
|
|
710
823
|
}
|
|
711
824
|
|
|
712
825
|
const bodyStream = response.body
|
|
713
826
|
if (!bodyStream) {
|
|
714
|
-
|
|
827
|
+
if (mountedRef.current) {
|
|
828
|
+
setLastResponseDebug({ status: response.status, text: '' })
|
|
829
|
+
}
|
|
830
|
+
writeSessionStreamState(sessionStorageKey, false)
|
|
715
831
|
setStatus('idle')
|
|
716
832
|
abortRef.current = null
|
|
717
833
|
return
|
|
@@ -771,12 +887,14 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
771
887
|
builder = { ...builder, text: streamedRaw }
|
|
772
888
|
}
|
|
773
889
|
const snapshotBuilder = builder
|
|
774
|
-
|
|
775
|
-
current
|
|
776
|
-
entry
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
890
|
+
updateMessages(
|
|
891
|
+
(current) =>
|
|
892
|
+
current.map((entry) =>
|
|
893
|
+
entry.id === assistantId
|
|
894
|
+
? mergeAssistantMessage(entry, snapshotBuilder)
|
|
895
|
+
: entry,
|
|
896
|
+
),
|
|
897
|
+
{ persistWhenUnmounted: true },
|
|
780
898
|
)
|
|
781
899
|
}
|
|
782
900
|
const tail = decoder.decode()
|
|
@@ -793,14 +911,18 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
793
911
|
}
|
|
794
912
|
builder = { ...builder, reasoningStreaming: false }
|
|
795
913
|
const finalSnapshot = builder
|
|
796
|
-
|
|
797
|
-
current
|
|
798
|
-
entry
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
914
|
+
updateMessages(
|
|
915
|
+
(current) =>
|
|
916
|
+
current.map((entry) =>
|
|
917
|
+
entry.id === assistantId
|
|
918
|
+
? mergeAssistantMessage(entry, finalSnapshot)
|
|
919
|
+
: entry,
|
|
920
|
+
),
|
|
921
|
+
{ persistWhenUnmounted: true },
|
|
802
922
|
)
|
|
803
|
-
|
|
923
|
+
if (mountedRef.current) {
|
|
924
|
+
setLastResponseDebug({ status: response.status, text: streamedRaw })
|
|
925
|
+
}
|
|
804
926
|
const isEmpty =
|
|
805
927
|
!builder.text.trim() && builder.toolCalls.length === 0 && !builder.reasoning
|
|
806
928
|
if (isEmpty) {
|
|
@@ -809,7 +931,9 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
809
931
|
message:
|
|
810
932
|
'The AI agent returned an empty response. This usually means the LLM provider rejected the request (invalid API key, rate limit, or model error). Check your server logs for details.',
|
|
811
933
|
})
|
|
812
|
-
|
|
934
|
+
updateMessages((current) => current.filter((entry) => entry.id !== assistantId), {
|
|
935
|
+
persistWhenUnmounted: true,
|
|
936
|
+
})
|
|
813
937
|
}
|
|
814
938
|
} catch (streamError) {
|
|
815
939
|
if ((streamError as { name?: string })?.name === 'AbortError') {
|
|
@@ -829,27 +953,79 @@ export function useAiChat(input: UseAiChatInput): UseAiChatResult {
|
|
|
829
953
|
emitError({ code: 'stream_error', message })
|
|
830
954
|
// Remove the empty assistant placeholder so the error alert is
|
|
831
955
|
// the only visible feedback.
|
|
832
|
-
|
|
956
|
+
updateMessages((current) => current.filter((entry) => entry.id !== assistantId), {
|
|
957
|
+
persistWhenUnmounted: true,
|
|
958
|
+
})
|
|
833
959
|
}
|
|
834
960
|
} finally {
|
|
835
961
|
reader.releaseLock()
|
|
836
962
|
if (abortRef.current === controller) {
|
|
837
963
|
abortRef.current = null
|
|
838
964
|
}
|
|
965
|
+
writeSessionStreamState(sessionStorageKey, false)
|
|
839
966
|
setStatus('idle')
|
|
840
967
|
}
|
|
841
968
|
},
|
|
842
|
-
[
|
|
969
|
+
[
|
|
970
|
+
agent,
|
|
971
|
+
apiPath,
|
|
972
|
+
attachmentIds,
|
|
973
|
+
debug,
|
|
974
|
+
effectiveConversationId,
|
|
975
|
+
emitError,
|
|
976
|
+
pageContext,
|
|
977
|
+
sessionStorageKey,
|
|
978
|
+
setStatus,
|
|
979
|
+
updateMessages,
|
|
980
|
+
],
|
|
843
981
|
)
|
|
844
982
|
|
|
845
983
|
React.useEffect(() => {
|
|
984
|
+
if (typeof window === 'undefined') return
|
|
985
|
+
const handleSessionUpdate = (event: Event) => {
|
|
986
|
+
const detail = (event as CustomEvent<{ key?: string }>).detail
|
|
987
|
+
if (!detail || detail.key !== sessionStorageKey) return
|
|
988
|
+
const next = readPersistedSession(agent, persistKey)
|
|
989
|
+
if (!next) return
|
|
990
|
+
if (next.conversationId !== effectiveConversationId) return
|
|
991
|
+
updateMessages(next.messages)
|
|
992
|
+
}
|
|
993
|
+
window.addEventListener(SESSION_UPDATED_EVENT, handleSessionUpdate)
|
|
846
994
|
return () => {
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
995
|
+
window.removeEventListener(SESSION_UPDATED_EVENT, handleSessionUpdate)
|
|
996
|
+
}
|
|
997
|
+
}, [agent, effectiveConversationId, persistKey, sessionStorageKey, updateMessages])
|
|
998
|
+
|
|
999
|
+
React.useEffect(() => {
|
|
1000
|
+
if (isSessionStreamActive(sessionStorageKey)) {
|
|
1001
|
+
setStatusInternal('streaming')
|
|
1002
|
+
}
|
|
1003
|
+
if (typeof window === 'undefined') return
|
|
1004
|
+
const handleStreamState = (event: Event) => {
|
|
1005
|
+
const detail = (event as CustomEvent<{ key?: string; active?: boolean }>).detail
|
|
1006
|
+
if (!detail || detail.key !== sessionStorageKey) return
|
|
1007
|
+
setStatusInternal(detail.active ? 'streaming' : 'idle')
|
|
1008
|
+
}
|
|
1009
|
+
window.addEventListener(SESSION_STREAM_STATE_EVENT, handleStreamState)
|
|
1010
|
+
return () => {
|
|
1011
|
+
window.removeEventListener(SESSION_STREAM_STATE_EVENT, handleStreamState)
|
|
1012
|
+
}
|
|
1013
|
+
}, [sessionStorageKey])
|
|
1014
|
+
|
|
1015
|
+
React.useEffect(() => {
|
|
1016
|
+
mountedRef.current = true
|
|
1017
|
+
return () => {
|
|
1018
|
+
// Flush the latest snapshot — including any in-flight assistant
|
|
1019
|
+
// content — and let the request keep running in the background.
|
|
1020
|
+
// Background stream updates continue writing to this same storage slot
|
|
1021
|
+
// and notify any reopened chat for the conversation (issue #1816).
|
|
1022
|
+
const finalMessages = latestMessagesRef.current
|
|
1023
|
+
if (finalMessages.length > 0) {
|
|
1024
|
+
persistSnapshot(finalMessages, true)
|
|
850
1025
|
}
|
|
1026
|
+
mountedRef.current = false
|
|
851
1027
|
}
|
|
852
|
-
}, [])
|
|
1028
|
+
}, [persistSnapshot])
|
|
853
1029
|
|
|
854
1030
|
return {
|
|
855
1031
|
messages,
|