@flamingo-stack/openframe-frontend-core 0.0.508 → 0.0.509
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/chat-protocol/decode.d.ts +58 -0
- package/dist/chat-protocol/decode.d.ts.map +1 -0
- package/dist/chat-protocol/encode.d.ts +44 -0
- package/dist/chat-protocol/encode.d.ts.map +1 -0
- package/dist/chat-protocol/env-flag.d.ts +35 -0
- package/dist/chat-protocol/env-flag.d.ts.map +1 -0
- package/dist/chat-protocol/events.d.ts +216 -0
- package/dist/chat-protocol/events.d.ts.map +1 -0
- package/dist/chat-protocol/frames.d.ts +213 -0
- package/dist/chat-protocol/frames.d.ts.map +1 -0
- package/dist/chat-protocol/index.cjs +506 -0
- package/dist/chat-protocol/index.cjs.map +1 -0
- package/dist/chat-protocol/index.d.ts +18 -0
- package/dist/chat-protocol/index.d.ts.map +1 -0
- package/dist/chat-protocol/index.js +490 -0
- package/dist/chat-protocol/index.js.map +1 -0
- package/dist/chat-protocol/ip-normalize.d.ts +44 -0
- package/dist/chat-protocol/ip-normalize.d.ts.map +1 -0
- package/dist/chat-protocol/nats-decoder.d.ts +26 -0
- package/dist/chat-protocol/nats-decoder.d.ts.map +1 -0
- package/dist/{chunk-3SQ5KXHQ.cjs → chunk-2T4GTV25.cjs} +9 -9
- package/dist/{chunk-3SQ5KXHQ.cjs.map → chunk-2T4GTV25.cjs.map} +1 -1
- package/dist/{chunk-CWAOV2FK.cjs → chunk-3CGZPAGR.cjs} +3 -3
- package/dist/{chunk-CWAOV2FK.cjs.map → chunk-3CGZPAGR.cjs.map} +1 -1
- package/dist/{chunk-TLMJHMXJ.js → chunk-3QSR22IC.js} +12 -12
- package/dist/chunk-3QSR22IC.js.map +1 -0
- package/dist/{chunk-TWYNR4TZ.cjs → chunk-4G5TLHJD.cjs} +7141 -4799
- package/dist/chunk-4G5TLHJD.cjs.map +1 -0
- package/dist/{chunk-ADPMHWOE.js → chunk-72XAID7Y.js} +4 -4
- package/dist/{chunk-PAGKRNWK.js → chunk-7WZHBQ4J.js} +2 -2
- package/dist/{chunk-OCEKO5CW.js → chunk-AEELJC4N.js} +12106 -9764
- package/dist/chunk-AEELJC4N.js.map +1 -0
- package/dist/{chunk-Z42BGM6Q.cjs → chunk-BT2WWDFP.cjs} +5 -5
- package/dist/{chunk-Z42BGM6Q.cjs.map → chunk-BT2WWDFP.cjs.map} +1 -1
- package/dist/{chunk-7LEL3RIX.cjs → chunk-C7OY3PQP.cjs} +11 -11
- package/dist/{chunk-7LEL3RIX.cjs.map → chunk-C7OY3PQP.cjs.map} +1 -1
- package/dist/{chunk-RHGGEKPQ.cjs → chunk-CIOLOQKQ.cjs} +4 -4
- package/dist/{chunk-RHGGEKPQ.cjs.map → chunk-CIOLOQKQ.cjs.map} +1 -1
- package/dist/{chunk-TSZHM74B.js → chunk-DC2TKS7C.js} +2 -2
- package/dist/{chunk-M2GOR3XQ.cjs → chunk-DYPYR7FR.cjs} +87 -87
- package/dist/{chunk-M2GOR3XQ.cjs.map → chunk-DYPYR7FR.cjs.map} +1 -1
- package/dist/{chunk-I64ABCDX.js → chunk-I2B6X77L.js} +2 -2
- package/dist/{chunk-NIZDKTGL.cjs → chunk-K5MSCMDK.cjs} +37 -37
- package/dist/{chunk-NIZDKTGL.cjs.map → chunk-K5MSCMDK.cjs.map} +1 -1
- package/dist/{chunk-DTYRYB2N.js → chunk-K6QTZ7CZ.js} +2 -2
- package/dist/{chunk-4LTMXDUS.js → chunk-KBN7PMFT.js} +2 -2
- package/dist/{chunk-TBMV7I5N.js → chunk-KXZO2WZX.js} +2 -2
- package/dist/{chunk-QTYZMP6D.cjs → chunk-LBAKLT6T.cjs} +62 -62
- package/dist/chunk-LBAKLT6T.cjs.map +1 -0
- package/dist/{chunk-KEBYLU3U.js → chunk-LFUPI7VX.js} +2 -2
- package/dist/{chunk-LDTR4IVZ.cjs → chunk-M37PBG4N.cjs} +31 -31
- package/dist/{chunk-LDTR4IVZ.cjs.map → chunk-M37PBG4N.cjs.map} +1 -1
- package/dist/{chunk-WA7RR64F.js → chunk-PO5O654H.js} +6 -5
- package/dist/chunk-PO5O654H.js.map +1 -0
- package/dist/{chunk-WK4N5VBX.cjs → chunk-QJJISLPA.cjs} +28 -27
- package/dist/chunk-QJJISLPA.cjs.map +1 -0
- package/dist/{chunk-2F44PLTV.cjs → chunk-T7MCSA55.cjs} +26 -26
- package/dist/{chunk-2F44PLTV.cjs.map → chunk-T7MCSA55.cjs.map} +1 -1
- package/dist/{chunk-WJQHJD7J.js → chunk-TWSKTJHW.js} +2 -2
- package/dist/{chunk-ZSQHZYCO.cjs → chunk-UQDMJ6DH.cjs} +13 -13
- package/dist/{chunk-ZSQHZYCO.cjs.map → chunk-UQDMJ6DH.cjs.map} +1 -1
- package/dist/{chunk-TZRUCD56.js → chunk-V3ZAAFE3.js} +5 -5
- package/dist/{chunk-LRGHJPET.js → chunk-XGIFOHKE.js} +2 -2
- package/dist/{chunk-POOMO3PA.cjs → chunk-YQEPRYT5.cjs} +7 -7
- package/dist/{chunk-POOMO3PA.cjs.map → chunk-YQEPRYT5.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/chat-message-enhanced.d.ts.map +1 -1
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/chat/hooks/index.d.ts +0 -1
- package/dist/components/chat/hooks/index.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-chunk-catchup.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +15 -48
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts +10 -57
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +10 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +21 -13
- package/dist/components/chat/stream/chat-dialog-store.d.ts +184 -0
- package/dist/components/chat/stream/chat-dialog-store.d.ts.map +1 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts +380 -0
- package/dist/components/chat/stream/chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/stream/delta-batcher.d.ts +51 -0
- package/dist/components/chat/stream/delta-batcher.d.ts.map +1 -0
- package/dist/components/chat/stream/index.d.ts +17 -0
- package/dist/components/chat/stream/index.d.ts.map +1 -0
- package/dist/components/chat/stream/message-mutations.d.ts +77 -0
- package/dist/components/chat/stream/message-mutations.d.ts.map +1 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts +22 -0
- package/dist/components/chat/stream/use-chat-stream-reducer.d.ts.map +1 -0
- package/dist/components/chat/types/api.types.d.ts +1 -81
- package/dist/components/chat/types/api.types.d.ts.map +1 -1
- package/dist/components/chat/types/processing.types.d.ts +2 -93
- package/dist/components/chat/types/processing.types.d.ts.map +1 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts +15 -0
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts +34 -6
- package/dist/components/chat/utils/extract-incomplete-message-state.d.ts.map +1 -1
- package/dist/components/chat/utils/history-merge.d.ts.map +1 -1
- package/dist/components/chat/utils/index.d.ts +2 -3
- package/dist/components/chat/utils/index.d.ts.map +1 -1
- package/dist/components/chat/utils/process-historical-messages.d.ts +59 -11
- package/dist/components/chat/utils/process-historical-messages.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +174 -132
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +65 -23
- package/dist/components/index.js.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +34 -34
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +44 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -2
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +55 -13
- package/dist/components/ui/markdown/base-components.d.ts +51 -0
- package/dist/components/ui/markdown/base-components.d.ts.map +1 -0
- package/dist/components/ui/markdown/engine.d.ts +99 -0
- package/dist/components/ui/markdown/engine.d.ts.map +1 -0
- package/dist/components/ui/markdown/heading-ids.d.ts +65 -0
- package/dist/components/ui/markdown/heading-ids.d.ts.map +1 -0
- package/dist/components/ui/markdown/index.d.ts +18 -0
- package/dist/components/ui/markdown/index.d.ts.map +1 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts +79 -0
- package/dist/components/ui/markdown/mermaid-diagram.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts +14 -0
- package/dist/components/ui/markdown/rich/embed-overrides.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts +40 -0
- package/dist/components/ui/markdown/rich/rich-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts +8 -0
- package/dist/components/ui/markdown/rich/shortcodes.d.ts.map +1 -0
- package/dist/components/ui/markdown/sanitize.d.ts +153 -0
- package/dist/components/ui/markdown/sanitize.d.ts.map +1 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts +23 -0
- package/dist/components/ui/markdown/simple-markdown-renderer.d.ts.map +1 -0
- package/dist/components/ui/markdown/streaming.d.ts +78 -0
- package/dist/components/ui/markdown/streaming.d.ts.map +1 -0
- package/dist/components/ui/markdown/text-size.d.ts +34 -0
- package/dist/components/ui/markdown/text-size.d.ts.map +1 -0
- package/dist/index.cjs +60 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +71 -13
- package/dist/utils/index.cjs +169 -40
- 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 +162 -41
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/markdown-fences.d.ts +42 -0
- package/dist/utils/markdown-fences.d.ts.map +1 -0
- package/dist/utils/markdown-heading-id.d.ts +85 -0
- package/dist/utils/markdown-heading-id.d.ts.map +1 -0
- package/dist/utils/markdown-section-extractor.d.ts.map +1 -1
- package/package.json +7 -1
- package/src/chat-protocol/__tests__/__snapshots__/nats-decoder-golden.test.ts.snap +319 -0
- package/src/chat-protocol/__tests__/chat-protocol.test.ts +516 -0
- package/src/chat-protocol/__tests__/env-flag.test.ts +52 -0
- package/src/chat-protocol/__tests__/ip-normalize.test.ts +136 -0
- package/src/chat-protocol/__tests__/nats-decoder-golden.test.ts +237 -0
- package/src/chat-protocol/decode.ts +278 -0
- package/src/chat-protocol/encode.ts +71 -0
- package/src/chat-protocol/env-flag.ts +39 -0
- package/src/chat-protocol/events.ts +254 -0
- package/src/chat-protocol/frames.ts +245 -0
- package/src/chat-protocol/index.ts +21 -0
- package/src/chat-protocol/ip-normalize.ts +146 -0
- package/src/chat-protocol/nats-decoder.ts +264 -0
- package/src/components/chat/.chat-message-list.md +10 -6
- package/src/components/chat/__tests__/chat-message-list.test.tsx +450 -9
- package/src/components/chat/__tests__/chat-message-streaming-memo.test.tsx +207 -0
- package/src/components/chat/__tests__/chat-pending-turn.test.tsx +250 -0
- package/src/components/chat/chat-message-enhanced.tsx +122 -20
- package/src/components/chat/chat-message-list.tsx +515 -133
- package/src/components/chat/embeddable-chat.tsx +6 -0
- package/src/components/chat/hooks/.index.md +30 -33
- package/src/components/chat/hooks/.use-nats-chat-adapter.md +36 -55
- package/src/components/chat/hooks/__tests__/__snapshots__/conversation-id-persistence-golden.test.ts.snap +37 -0
- package/src/components/chat/hooks/__tests__/__snapshots__/sse-stream-golden.test.ts.snap +0 -0
- package/src/components/chat/hooks/__tests__/chunk-catchup-dialog-staleness.test.ts +92 -0
- package/src/components/chat/hooks/__tests__/conversation-id-persistence-golden.test.ts +438 -0
- package/src/components/chat/hooks/__tests__/sse-stream-golden.test.ts +318 -0
- package/src/components/chat/hooks/index.ts +0 -1
- package/src/components/chat/hooks/use-chat.ts +8 -0
- package/src/components/chat/hooks/use-chunk-catchup.ts +26 -1
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +254 -825
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +380 -684
- package/src/components/chat/index.ts +5 -0
- package/src/components/chat/stream/__tests__/__snapshots__/chat-stream-reducer-golden.test.ts.snap +944 -0
- package/src/components/chat/stream/__tests__/chat-dialog-store.test.ts +806 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer-golden.test.ts +467 -0
- package/src/components/chat/stream/__tests__/chat-stream-reducer.test.ts +1330 -0
- package/src/components/chat/stream/__tests__/delta-batcher.test.ts +255 -0
- package/src/components/chat/stream/__tests__/use-chat-stream-reducer.test.ts +105 -0
- package/src/components/chat/stream/chat-dialog-store.ts +536 -0
- package/src/components/chat/stream/chat-stream-reducer.ts +1928 -0
- package/src/components/chat/stream/delta-batcher.ts +168 -0
- package/src/components/chat/stream/index.ts +55 -0
- package/src/components/chat/stream/message-mutations.ts +340 -0
- package/src/components/chat/stream/use-chat-stream-reducer.ts +126 -0
- package/src/components/chat/thinking-display.tsx +1 -1
- package/src/components/chat/types/.api.types.md +39 -49
- package/src/components/chat/types/.processing.types.md +29 -58
- package/src/components/chat/types/.unified-chat-state.types.md +2 -2
- package/src/components/chat/types/api.types.ts +6 -73
- package/src/components/chat/types/processing.types.ts +11 -53
- package/src/components/chat/types/unified-chat-state.types.ts +16 -0
- package/src/components/chat/utils/.extract-incomplete-message-state.md +21 -3
- package/src/components/chat/utils/.history-merge.md +44 -3
- package/src/components/chat/utils/.index.md +31 -40
- package/src/components/chat/utils/__tests__/__snapshots__/process-historical-messages-golden.test.ts.snap +420 -0
- package/src/components/chat/utils/__tests__/__snapshots__/segment-accumulator-golden.test.ts.snap +605 -0
- package/src/components/chat/utils/__tests__/extract-incomplete-message-state.test.ts +106 -0
- package/src/components/chat/utils/__tests__/history-merge.test.ts +160 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-approvals.test.ts +61 -0
- package/src/components/chat/utils/__tests__/process-historical-messages-golden.test.ts +394 -0
- package/src/components/chat/utils/__tests__/segment-accumulator-golden.test.ts +270 -0
- package/src/components/chat/utils/extract-incomplete-message-state.ts +93 -6
- package/src/components/chat/utils/history-merge.ts +85 -5
- package/src/components/chat/utils/index.ts +5 -8
- package/src/components/chat/utils/process-historical-messages.ts +361 -385
- package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +22 -4
- package/src/components/shared/legal-document/legal-document-page.tsx +1 -1
- package/src/components/shared/product-release/release-detail-page.tsx +19 -4
- package/src/components/ui/__tests__/__snapshots__/markdown-parity.test.tsx.snap +2420 -0
- package/src/components/ui/__tests__/markdown-parity.test.tsx +2540 -0
- package/src/components/ui/index.ts +1 -2
- package/src/components/ui/markdown/__tests__/mermaid-security.test.ts +176 -0
- package/src/components/ui/markdown/__tests__/mermaid-stale-render.test.tsx +151 -0
- package/src/components/ui/markdown/__tests__/sanitize-invariant.test.ts +354 -0
- package/src/components/ui/markdown/__tests__/sanitize-render.test.tsx +118 -0
- package/src/components/ui/markdown/__tests__/streaming.test.tsx +436 -0
- package/src/components/ui/markdown/base-components.tsx +360 -0
- package/src/components/ui/markdown/engine.tsx +315 -0
- package/src/components/ui/markdown/heading-ids.ts +239 -0
- package/src/components/ui/markdown/index.ts +49 -0
- package/src/components/ui/markdown/mermaid-diagram.tsx +323 -0
- package/src/components/ui/markdown/rich/embed-overrides.tsx +199 -0
- package/src/components/ui/markdown/rich/rich-markdown-renderer.tsx +184 -0
- package/src/components/ui/markdown/rich/shortcodes.ts +170 -0
- package/src/components/ui/markdown/sanitize.ts +3584 -0
- package/src/components/ui/markdown/simple-markdown-renderer.tsx +28 -0
- package/src/components/ui/markdown/streaming.ts +390 -0
- package/src/components/ui/markdown/text-size.ts +106 -0
- package/src/components/ui/release-changelog-section.tsx +1 -1
- package/src/components/ui/ticket-info-section.tsx +1 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/markdown-fences.ts +127 -0
- package/src/utils/markdown-heading-id.ts +348 -0
- package/src/utils/markdown-section-extractor.ts +41 -56
- package/dist/chunk-OCEKO5CW.js.map +0 -1
- package/dist/chunk-QTYZMP6D.cjs.map +0 -1
- package/dist/chunk-TLMJHMXJ.js.map +0 -1
- package/dist/chunk-TWYNR4TZ.cjs.map +0 -1
- package/dist/chunk-WA7RR64F.js.map +0 -1
- package/dist/chunk-WK4N5VBX.cjs.map +0 -1
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts +0 -6
- package/dist/components/chat/hooks/use-realtime-chunk-processor.d.ts.map +0 -1
- package/dist/components/chat/utils/chunk-parser.d.ts +0 -25
- package/dist/components/chat/utils/chunk-parser.d.ts.map +0 -1
- package/dist/components/ui/rich-markdown-renderer.d.ts +0 -34
- package/dist/components/ui/rich-markdown-renderer.d.ts.map +0 -1
- package/dist/components/ui/simple-markdown-renderer.d.ts +0 -73
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +0 -1
- package/src/components/chat/hooks/.use-realtime-chunk-processor.md +0 -77
- package/src/components/chat/hooks/use-realtime-chunk-processor.ts +0 -489
- package/src/components/chat/utils/.chunk-parser.md +0 -62
- package/src/components/chat/utils/chunk-parser.ts +0 -262
- package/src/components/ui/.simple-markdown-renderer.md +0 -52
- package/src/components/ui/rich-markdown-renderer.tsx +0 -1223
- package/src/components/ui/simple-markdown-renderer.tsx +0 -964
- /package/dist/{chunk-ADPMHWOE.js.map → chunk-72XAID7Y.js.map} +0 -0
- /package/dist/{chunk-PAGKRNWK.js.map → chunk-7WZHBQ4J.js.map} +0 -0
- /package/dist/{chunk-TSZHM74B.js.map → chunk-DC2TKS7C.js.map} +0 -0
- /package/dist/{chunk-I64ABCDX.js.map → chunk-I2B6X77L.js.map} +0 -0
- /package/dist/{chunk-DTYRYB2N.js.map → chunk-K6QTZ7CZ.js.map} +0 -0
- /package/dist/{chunk-4LTMXDUS.js.map → chunk-KBN7PMFT.js.map} +0 -0
- /package/dist/{chunk-TBMV7I5N.js.map → chunk-KXZO2WZX.js.map} +0 -0
- /package/dist/{chunk-KEBYLU3U.js.map → chunk-LFUPI7VX.js.map} +0 -0
- /package/dist/{chunk-WJQHJD7J.js.map → chunk-TWSKTJHW.js.map} +0 -0
- /package/dist/{chunk-TZRUCD56.js.map → chunk-V3ZAAFE3.js.map} +0 -0
- /package/dist/{chunk-LRGHJPET.js.map → chunk-XGIFOHKE.js.map} +0 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createDeltaBatcher — framework-free, append-only batching of the
|
|
3
|
+
* `text-delta` / `thinking-delta` / `guide-delta` events.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS SHARED (and not a hook-private detail): every host that drives a
|
|
6
|
+
* `ChatStreamReducer` from a raw transport needs the SAME batching, and a
|
|
7
|
+
* copy-paste of it drifts. The React wrapper (`useChatStreamReducer`) and any
|
|
8
|
+
* non-React mirror in a product app both consume this one implementation.
|
|
9
|
+
*
|
|
10
|
+
* Behaviour:
|
|
11
|
+
* - `push(event, key)` queues delta events and returns true; anything else
|
|
12
|
+
* returns false so the caller can flush + apply it synchronously (ordering:
|
|
13
|
+
* completion state must always land on fully-applied deltas).
|
|
14
|
+
* - CONSECUTIVE same-type deltas are COALESCED into one event (the reducer's
|
|
15
|
+
* append semantics distribute over concatenation), so a 60-delta/sec turn
|
|
16
|
+
* costs ONE `applyOne` per frame instead of 60. `text-delta` additionally
|
|
17
|
+
* requires a matching `leading` flag before coalescing.
|
|
18
|
+
* - A flush is scheduled on `requestAnimationFrame` when available, with a
|
|
19
|
+
* timer fallback that is ALWAYS armed (rAF pauses in background tabs, and a
|
|
20
|
+
* hidden chat panel must still keep its thread current).
|
|
21
|
+
* - `key` is an opaque routing token handed back to `applyOne`. When it
|
|
22
|
+
* changes, the pending batch is flushed against the PREVIOUS key first, so
|
|
23
|
+
* deltas never land on the wrong dialog/side.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
ChatStreamEvent,
|
|
28
|
+
GuideDeltaEvent,
|
|
29
|
+
TextDeltaEvent,
|
|
30
|
+
ThinkingDeltaEvent,
|
|
31
|
+
} from '../../../chat-protocol/events'
|
|
32
|
+
|
|
33
|
+
export const DELTA_FLUSH_FALLBACK_MS = 50
|
|
34
|
+
|
|
35
|
+
export type DeltaEvent = TextDeltaEvent | ThinkingDeltaEvent | GuideDeltaEvent
|
|
36
|
+
|
|
37
|
+
export function isDeltaEvent(event: ChatStreamEvent): event is DeltaEvent {
|
|
38
|
+
return (
|
|
39
|
+
event.type === 'text-delta' ||
|
|
40
|
+
event.type === 'thinking-delta' ||
|
|
41
|
+
event.type === 'guide-delta'
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CreateDeltaBatcherOptions<K> {
|
|
46
|
+
/** Apply one (possibly coalesced) delta against `key`'s reducer. */
|
|
47
|
+
applyOne: (event: DeltaEvent, key: K | undefined) => void
|
|
48
|
+
/** Called after a flush that applied at least one delta. */
|
|
49
|
+
onFlushed?: (appliedCount: number) => void
|
|
50
|
+
/** Timer fallback in ms (default `DELTA_FLUSH_FALLBACK_MS`). */
|
|
51
|
+
fallbackMs?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DeltaBatcher<K = unknown> {
|
|
55
|
+
/**
|
|
56
|
+
* Queue `event` when it is a delta (returns true). Non-delta events are NOT
|
|
57
|
+
* queued and return false — the caller flushes and applies them itself.
|
|
58
|
+
*/
|
|
59
|
+
push(event: ChatStreamEvent, key?: K): boolean
|
|
60
|
+
/** Synchronously apply the pending batch and cancel any scheduled flush. */
|
|
61
|
+
flush(): void
|
|
62
|
+
/** Flush and release timers — call on teardown so tail deltas aren't lost. */
|
|
63
|
+
dispose(): void
|
|
64
|
+
/** Queued (post-coalescing) delta count. Test/diagnostic surface. */
|
|
65
|
+
readonly pendingCount: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createDeltaBatcher<K = unknown>({
|
|
69
|
+
applyOne,
|
|
70
|
+
onFlushed,
|
|
71
|
+
fallbackMs = DELTA_FLUSH_FALLBACK_MS,
|
|
72
|
+
}: CreateDeltaBatcherOptions<K>): DeltaBatcher<K> {
|
|
73
|
+
let pending: DeltaEvent[] = []
|
|
74
|
+
let pendingKey: K | undefined
|
|
75
|
+
let hasKey = false
|
|
76
|
+
let raf: number | null = null
|
|
77
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
78
|
+
|
|
79
|
+
function cancelScheduled(): void {
|
|
80
|
+
if (raf !== null) {
|
|
81
|
+
if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(raf)
|
|
82
|
+
raf = null
|
|
83
|
+
}
|
|
84
|
+
if (timer !== null) {
|
|
85
|
+
clearTimeout(timer)
|
|
86
|
+
timer = null
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function flush(): void {
|
|
91
|
+
cancelScheduled()
|
|
92
|
+
if (pending.length === 0) return
|
|
93
|
+
const batch = pending
|
|
94
|
+
pending = []
|
|
95
|
+
const key = pendingKey
|
|
96
|
+
for (const delta of batch) applyOne(delta, key)
|
|
97
|
+
onFlushed?.(batch.length)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function schedule(): void {
|
|
101
|
+
if (raf !== null || timer !== null) return
|
|
102
|
+
if (typeof requestAnimationFrame === 'function') {
|
|
103
|
+
raf = requestAnimationFrame(() => {
|
|
104
|
+
raf = null
|
|
105
|
+
flush()
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
// Timer fallback ALWAYS armed: rAF pauses in background tabs.
|
|
109
|
+
timer = setTimeout(() => {
|
|
110
|
+
timer = null
|
|
111
|
+
flush()
|
|
112
|
+
}, fallbackMs)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
push(event, key) {
|
|
117
|
+
// A pending batch belonging to a previous key must land on ITS reducer
|
|
118
|
+
// before we start queueing for the new one.
|
|
119
|
+
if (!hasKey || !Object.is(pendingKey, key)) {
|
|
120
|
+
flush()
|
|
121
|
+
pendingKey = key
|
|
122
|
+
hasKey = true
|
|
123
|
+
}
|
|
124
|
+
if (!isDeltaEvent(event)) return false
|
|
125
|
+
const tail = pending[pending.length - 1]
|
|
126
|
+
// NEVER coalesce a delta whose seq does not ADVANCE past the tail's.
|
|
127
|
+
// Coalescing stamps the merged entry with the LAST pushed seq, so a
|
|
128
|
+
// redelivered / out-of-order delta arriving inside one frame would both
|
|
129
|
+
// concatenate its text (the reducer never sees it as a duplicate) and
|
|
130
|
+
// REWIND the batch's seq below an already-applied one. Pushing it as its
|
|
131
|
+
// own entry hands it to the reducer intact, where the `seq <=
|
|
132
|
+
// lastAppliedSeq` gate drops it — the batcher must not depend on hosts
|
|
133
|
+
// gating at dispatch.
|
|
134
|
+
// A seq-LESS tail is not "always advancing": merging a seq-BEARING delta
|
|
135
|
+
// onto it stamps the merged entry with that seq, which the reducer's gate
|
|
136
|
+
// then treats as the batch's position — a redelivered delta could rewind
|
|
137
|
+
// it. Coalesce onto a seq-less tail only when the incoming delta is
|
|
138
|
+
// likewise seq-less (a uniformly seq-less stream, e.g. SSE).
|
|
139
|
+
const seqAdvances =
|
|
140
|
+
tail?.seq == null ? event.seq == null : event.seq != null && event.seq > tail.seq
|
|
141
|
+
if (
|
|
142
|
+
tail &&
|
|
143
|
+
seqAdvances &&
|
|
144
|
+
tail.type === event.type &&
|
|
145
|
+
(tail.type !== 'text-delta' ||
|
|
146
|
+
(tail as TextDeltaEvent).leading === (event as TextDeltaEvent).leading)
|
|
147
|
+
) {
|
|
148
|
+
pending[pending.length - 1] = {
|
|
149
|
+
...tail,
|
|
150
|
+
text: tail.text + event.text,
|
|
151
|
+
...(event.seq != null ? { seq: event.seq } : {}),
|
|
152
|
+
} as DeltaEvent
|
|
153
|
+
} else {
|
|
154
|
+
pending.push({ ...event })
|
|
155
|
+
}
|
|
156
|
+
schedule()
|
|
157
|
+
return true
|
|
158
|
+
},
|
|
159
|
+
flush,
|
|
160
|
+
dispose() {
|
|
161
|
+
flush()
|
|
162
|
+
cancelScheduled()
|
|
163
|
+
},
|
|
164
|
+
get pendingCount() {
|
|
165
|
+
return pending.length
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat stream module — ONE master reader for every chat event source
|
|
3
|
+
* (SSE decoder, NATS chunk decoder, historical replay). See
|
|
4
|
+
* `chat-stream-reducer.ts` for the architecture note.
|
|
5
|
+
*
|
|
6
|
+
* NOTE: the pure message-mutation helpers (`appendToTrailingAssistant`,
|
|
7
|
+
* `applyToolExecutionToMessages`, …) are intentionally NOT star-exported
|
|
8
|
+
* here — their legacy public home is `use-nats-chat-adapter`, which
|
|
9
|
+
* re-exports them from `./message-mutations`; exporting the same bindings
|
|
10
|
+
* from two star-exported barrels would make them ambiguous and drop them
|
|
11
|
+
* from the chat barrel entirely.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
createChatStreamReducer,
|
|
16
|
+
createEmptyTurnMeta,
|
|
17
|
+
type BeginSseSendOptions,
|
|
18
|
+
type ChatReducerEffect,
|
|
19
|
+
type ChatReducerState,
|
|
20
|
+
type ChatStreamReducer,
|
|
21
|
+
type ChatStreamReducerOptions,
|
|
22
|
+
type ChatTurnMetaState,
|
|
23
|
+
type EscalatedApprovalData,
|
|
24
|
+
type InitializeExtras,
|
|
25
|
+
type PendingEcho,
|
|
26
|
+
} from './chat-stream-reducer'
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
createChatDialogStore,
|
|
30
|
+
DEFAULT_DIALOG_SIDE,
|
|
31
|
+
DEFAULT_MAX_REDUCERS,
|
|
32
|
+
MAX_PENDING_RETAIN_KEYS,
|
|
33
|
+
type ChatDialogSide,
|
|
34
|
+
type ChatDialogStore,
|
|
35
|
+
type CreateChatDialogStoreOptions,
|
|
36
|
+
type EvictedReducerState,
|
|
37
|
+
} from './chat-dialog-store'
|
|
38
|
+
|
|
39
|
+
// Framework-free delta batching — shared with NON-React hosts that drive a
|
|
40
|
+
// reducer straight from a transport (they must NOT re-implement it).
|
|
41
|
+
// `isDeltaEvent` + `DELTA_FLUSH_FALLBACK_MS` are deliberately NOT re-exported:
|
|
42
|
+
// they are internals of the batcher (its own tests import them from the
|
|
43
|
+
// module), and no consumer needs to classify or re-time deltas by hand.
|
|
44
|
+
export {
|
|
45
|
+
createDeltaBatcher,
|
|
46
|
+
type CreateDeltaBatcherOptions,
|
|
47
|
+
type DeltaBatcher,
|
|
48
|
+
type DeltaEvent,
|
|
49
|
+
} from './delta-batcher'
|
|
50
|
+
|
|
51
|
+
export {
|
|
52
|
+
useChatStreamReducer,
|
|
53
|
+
type UseChatStreamReducerOptions,
|
|
54
|
+
type UseChatStreamReducerReturn,
|
|
55
|
+
} from './use-chat-stream-reducer'
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure message-mutation kernel for the master chat-stream reducer.
|
|
3
|
+
*
|
|
4
|
+
* These helpers were absorbed from `use-nats-chat-adapter.ts` in Phase 3 of
|
|
5
|
+
* the chat unification — they are the ONE implementation of "apply a stream
|
|
6
|
+
* fragment to a message thread". The reducer (`chat-stream-reducer.ts`) and
|
|
7
|
+
* the cross-side projections (`chat-dialog-store.ts`) both build on them.
|
|
8
|
+
*
|
|
9
|
+
* REFERENTIAL-STABILITY CONTRACT: every helper clones ONLY the touched
|
|
10
|
+
* message object (and the touched segment slot inside it). A value-level
|
|
11
|
+
* no-op — e.g. a replayed duplicate EXECUTED_TOOL whose merge would write
|
|
12
|
+
* identical values — returns the PRIOR references untouched, so React
|
|
13
|
+
* memoization over untouched messages survives redeliveries.
|
|
14
|
+
*
|
|
15
|
+
* Framework-free: no React, no timers.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
ApprovalBatchExecutionState,
|
|
20
|
+
ChatApprovalStatus,
|
|
21
|
+
MessageSegment,
|
|
22
|
+
ToolExecutionSegment,
|
|
23
|
+
ToolExecutionData,
|
|
24
|
+
} from '../types'
|
|
25
|
+
import type { UnifiedChatMessage } from '../types/unified-chat-state.types'
|
|
26
|
+
|
|
27
|
+
export function nextId(role: 'user' | 'assistant'): string {
|
|
28
|
+
// Date.now() + counter sliver keeps ids monotonic even when two
|
|
29
|
+
// messages are produced inside the same ms tick (user + assistant
|
|
30
|
+
// placeholder fire back-to-back from a single sendMessage call).
|
|
31
|
+
return `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Realtime user/direct/system chunks can be replays of rows already on
|
|
36
|
+
* screen. Content-window dedup is the LAST layer (seq-less transports only)
|
|
37
|
+
* — see the layer list in `chat-stream-reducer.ts`.
|
|
38
|
+
*/
|
|
39
|
+
export const CONTENT_DEDUP_WINDOW = 4
|
|
40
|
+
export const SYSTEM_DEDUP_WINDOW = 10
|
|
41
|
+
|
|
42
|
+
export function hasRecentMessage(
|
|
43
|
+
prev: UnifiedChatMessage[],
|
|
44
|
+
predicate: (message: UnifiedChatMessage) => boolean,
|
|
45
|
+
window: number,
|
|
46
|
+
): boolean {
|
|
47
|
+
const start = Math.max(0, prev.length - window)
|
|
48
|
+
for (let i = prev.length - 1; i >= start; i--) {
|
|
49
|
+
if (predicate(prev[i])) return true
|
|
50
|
+
}
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Replace (or append) the trailing assistant message with the latest
|
|
56
|
+
* accumulated segments.
|
|
57
|
+
*/
|
|
58
|
+
export function updateTrailingAssistant(
|
|
59
|
+
prev: UnifiedChatMessage[],
|
|
60
|
+
segments: MessageSegment[],
|
|
61
|
+
): UnifiedChatMessage[] {
|
|
62
|
+
const last = prev[prev.length - 1]
|
|
63
|
+
if (!last || last.role !== 'assistant') {
|
|
64
|
+
// No placeholder exists — append a fresh assistant message.
|
|
65
|
+
return [
|
|
66
|
+
...prev,
|
|
67
|
+
{
|
|
68
|
+
id: nextId('assistant'),
|
|
69
|
+
role: 'assistant',
|
|
70
|
+
content: '',
|
|
71
|
+
segments,
|
|
72
|
+
},
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
return [...prev.slice(0, -1), { ...last, segments }]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Append-mode counterpart of `updateTrailingAssistant` for post-MESSAGE_END
|
|
80
|
+
* continuation fragments (`SegmentsUpdateMetadata.append`). Coalesces
|
|
81
|
+
* trailing fragments of the same type, mirroring the accumulator; approval
|
|
82
|
+
* deltas upsert by request id so a replayed emit stays idempotent.
|
|
83
|
+
*/
|
|
84
|
+
export function appendToTrailingAssistant(
|
|
85
|
+
prev: UnifiedChatMessage[],
|
|
86
|
+
segments: MessageSegment[],
|
|
87
|
+
): UnifiedChatMessage[] {
|
|
88
|
+
if (segments.length === 0) return prev
|
|
89
|
+
const last = prev[prev.length - 1]
|
|
90
|
+
if (!last || last.role !== 'assistant') {
|
|
91
|
+
return [
|
|
92
|
+
...prev,
|
|
93
|
+
{ id: nextId('assistant'), role: 'assistant', content: '', segments },
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
const merged = [...(last.segments ?? [])]
|
|
97
|
+
for (const seg of segments) {
|
|
98
|
+
const tail = merged[merged.length - 1]
|
|
99
|
+
if (seg.type === 'text' && tail?.type === 'text') {
|
|
100
|
+
merged[merged.length - 1] = { type: 'text', text: tail.text + seg.text }
|
|
101
|
+
} else if (seg.type === 'thinking' && tail?.type === 'thinking') {
|
|
102
|
+
merged[merged.length - 1] = { type: 'thinking', text: tail.text + seg.text }
|
|
103
|
+
} else if (seg.type === 'guide' && tail?.type === 'guide') {
|
|
104
|
+
merged[merged.length - 1] = { type: 'guide', text: tail.text + seg.text }
|
|
105
|
+
} else if (seg.type === 'approval_batch') {
|
|
106
|
+
// Approval deltas must be IDEMPOTENT: the escalated-result emit can be
|
|
107
|
+
// seen twice (live + catch-up replay over hydrated history), so upsert
|
|
108
|
+
// by request id instead of raw-appending a duplicate card.
|
|
109
|
+
const idx = merged.findIndex(
|
|
110
|
+
(m) => m.type === 'approval_batch' && m.data.approvalRequestId === seg.data.approvalRequestId,
|
|
111
|
+
)
|
|
112
|
+
if (idx !== -1) merged[idx] = seg
|
|
113
|
+
else merged.push(seg)
|
|
114
|
+
} else if (seg.type === 'approval_request') {
|
|
115
|
+
// Legacy cards share one requestId across an unfolded batch — key on
|
|
116
|
+
// (requestId, command) so each tool's card upserts its own twin.
|
|
117
|
+
const idx = merged.findIndex(
|
|
118
|
+
(m) =>
|
|
119
|
+
m.type === 'approval_request' &&
|
|
120
|
+
m.data.requestId === seg.data.requestId &&
|
|
121
|
+
m.data.command === seg.data.command,
|
|
122
|
+
)
|
|
123
|
+
if (idx !== -1) merged[idx] = seg
|
|
124
|
+
else merged.push(seg)
|
|
125
|
+
} else {
|
|
126
|
+
// Other non-text segments are pushed RAW on purpose: EXECUTING↔EXECUTED
|
|
127
|
+
// pairing and batch merging are `applyToolExecutionToMessages`'s job
|
|
128
|
+
// (post-END tool chunks never reach this helper — the reducer routes
|
|
129
|
+
// them cross-message). Running the accumulator here would double-apply
|
|
130
|
+
// those rules.
|
|
131
|
+
merged.push(seg)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...prev.slice(0, -1), { ...last, segments: merged }]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Upsert a standalone context-compaction segment into the trailing assistant
|
|
139
|
+
* bubble. Compaction emissions arrive as the accumulator's CUMULATIVE array —
|
|
140
|
+
* only the compaction segment itself may be applied, or interleaved
|
|
141
|
+
* continuation text would duplicate. A `completed` segment replaces the last
|
|
142
|
+
* `started` one in place.
|
|
143
|
+
*/
|
|
144
|
+
export function upsertTrailingCompaction(
|
|
145
|
+
prev: UnifiedChatMessage[],
|
|
146
|
+
segments: MessageSegment[],
|
|
147
|
+
): UnifiedChatMessage[] {
|
|
148
|
+
const compaction = [...segments].reverse().find((s) => s.type === 'context_compaction')
|
|
149
|
+
if (!compaction) return prev
|
|
150
|
+
const last = prev[prev.length - 1]
|
|
151
|
+
if (!last || last.role !== 'assistant') {
|
|
152
|
+
return [
|
|
153
|
+
...prev,
|
|
154
|
+
{ id: nextId('assistant'), role: 'assistant', content: '', segments: [compaction] },
|
|
155
|
+
]
|
|
156
|
+
}
|
|
157
|
+
const existing = last.segments ?? []
|
|
158
|
+
// LAST 'started' segment (not first): with repeated compactions in one
|
|
159
|
+
// bubble the earlier ones are already completed-in-place, so the newest
|
|
160
|
+
// 'started' is the only one this completion can belong to.
|
|
161
|
+
const startedIdx = existing
|
|
162
|
+
.map((s) => s.type === 'context_compaction' && s.status === 'started')
|
|
163
|
+
.lastIndexOf(true)
|
|
164
|
+
const merged =
|
|
165
|
+
startedIdx !== -1
|
|
166
|
+
? existing.map((s, i) => (i === startedIdx ? compaction : s))
|
|
167
|
+
: [...existing, compaction]
|
|
168
|
+
return [...prev.slice(0, -1), { ...last, segments: merged }]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Scoped value-compare for the fields a tool merge writes (status/type,
|
|
172
|
+
* result, success, execId, restored title, parameters). Small objects —
|
|
173
|
+
* JSON compare for `parameters` keeps a replayed duplicate a no-op even
|
|
174
|
+
* when the transport rebuilt the object. */
|
|
175
|
+
function sameToolData(a: ToolExecutionData, b: ToolExecutionData): boolean {
|
|
176
|
+
return (
|
|
177
|
+
a.type === b.type &&
|
|
178
|
+
a.integratedToolType === b.integratedToolType &&
|
|
179
|
+
a.toolFunction === b.toolFunction &&
|
|
180
|
+
a.toolTitle === b.toolTitle &&
|
|
181
|
+
a.result === b.result &&
|
|
182
|
+
a.success === b.success &&
|
|
183
|
+
a.toolExecutionRequestId === b.toolExecutionRequestId &&
|
|
184
|
+
(a.parameters === b.parameters ||
|
|
185
|
+
JSON.stringify(a.parameters ?? null) === JSON.stringify(b.parameters ?? null))
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function sameExecutionState(
|
|
190
|
+
a: ApprovalBatchExecutionState | undefined,
|
|
191
|
+
b: ApprovalBatchExecutionState,
|
|
192
|
+
): boolean {
|
|
193
|
+
return !!a && a.status === b.status && a.result === b.result && a.success === b.success
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Cross-message tool-execution updater for post-MESSAGE_END tool chunks
|
|
198
|
+
* (approved commands executing after the approval bubble, async batch
|
|
199
|
+
* results). Scans messages from the end:
|
|
200
|
+
* 1) an `approval_batch` whose `toolCalls` contains the execution id →
|
|
201
|
+
* merge into its `executions` map;
|
|
202
|
+
* 2) a matching `tool_execution` segment (same id, or EXECUTING with the
|
|
203
|
+
* same tool for legacy id-less backends) → update in place;
|
|
204
|
+
* 3) no match → append the segment to the trailing assistant bubble.
|
|
205
|
+
*
|
|
206
|
+
* A value-level no-op (replayed duplicate) returns `prev` untouched.
|
|
207
|
+
*/
|
|
208
|
+
export function applyToolExecutionToMessages(
|
|
209
|
+
prev: UnifiedChatMessage[],
|
|
210
|
+
segment: ToolExecutionSegment,
|
|
211
|
+
): UnifiedChatMessage[] {
|
|
212
|
+
const merged = mergeToolExecutionIfPresent(prev, segment)
|
|
213
|
+
if (merged !== null) return merged
|
|
214
|
+
return appendToTrailingAssistant(prev, [segment])
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Merge-only variant of `applyToolExecutionToMessages` — the pure projection
|
|
219
|
+
* used for the cross-SIDE fan-out in `chat-dialog-store.ts`. Updates a
|
|
220
|
+
* matching batch slot / tool segment when present; returns `null` when
|
|
221
|
+
* nothing matches (the caller decides whether to append — projections never
|
|
222
|
+
* do, so a tool that belongs to one side only never grows a card on the
|
|
223
|
+
* other).
|
|
224
|
+
*/
|
|
225
|
+
export function mergeToolExecutionIfPresent(
|
|
226
|
+
prev: UnifiedChatMessage[],
|
|
227
|
+
segment: ToolExecutionSegment,
|
|
228
|
+
): UnifiedChatMessage[] | null {
|
|
229
|
+
const toolData = segment.data
|
|
230
|
+
const execId = toolData.toolExecutionRequestId
|
|
231
|
+
|
|
232
|
+
for (let i = prev.length - 1; i >= 0; i--) {
|
|
233
|
+
const message = prev[i]
|
|
234
|
+
if (message.role !== 'assistant' || !message.segments) continue
|
|
235
|
+
|
|
236
|
+
for (let j = message.segments.length - 1; j >= 0; j--) {
|
|
237
|
+
const seg = message.segments[j]
|
|
238
|
+
|
|
239
|
+
if (
|
|
240
|
+
execId &&
|
|
241
|
+
seg.type === 'approval_batch' &&
|
|
242
|
+
seg.data.toolCalls.some((c) => c.toolExecutionRequestId === execId)
|
|
243
|
+
) {
|
|
244
|
+
const prevExec: ApprovalBatchExecutionState | undefined = seg.data.executions?.[execId]
|
|
245
|
+
// Never downgrade a done batch slot back to executing (JetStream
|
|
246
|
+
// redelivery of the EXECUTING chunk after EXECUTED landed). Returning
|
|
247
|
+
// prev = matched-with-no-change, so the caller doesn't append either.
|
|
248
|
+
if (toolData.type === 'EXECUTING_TOOL' && prevExec?.status === 'done') {
|
|
249
|
+
return prev
|
|
250
|
+
}
|
|
251
|
+
const nextExec: ApprovalBatchExecutionState =
|
|
252
|
+
toolData.type === 'EXECUTED_TOOL'
|
|
253
|
+
? { status: 'done', result: toolData.result, success: toolData.success }
|
|
254
|
+
: { status: 'executing', result: prevExec?.result, success: prevExec?.success }
|
|
255
|
+
// Value-level no-op (replayed duplicate) → prior references.
|
|
256
|
+
if (sameExecutionState(prevExec, nextExec)) return prev
|
|
257
|
+
const nextSegments = [...message.segments]
|
|
258
|
+
nextSegments[j] = {
|
|
259
|
+
...seg,
|
|
260
|
+
data: { ...seg.data, executions: { ...(seg.data.executions ?? {}), [execId]: nextExec } },
|
|
261
|
+
}
|
|
262
|
+
const next = [...prev]
|
|
263
|
+
next[i] = { ...message, segments: nextSegments }
|
|
264
|
+
return next
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (seg.type === 'tool_execution') {
|
|
268
|
+
// KNOWN LIMIT (id-less chunks only — current backends always send
|
|
269
|
+
// toolExecutionRequestId): the fuzzy fallback pairs only with a
|
|
270
|
+
// segment still EXECUTING, so a replayed id-less EXECUTING/EXECUTED
|
|
271
|
+
// arriving after the pair completed matches nothing and falls to the
|
|
272
|
+
// caller's append (duplicate card). Widening the predicate to
|
|
273
|
+
// EXECUTED twins would instead swallow a LEGITIMATE second run of the
|
|
274
|
+
// same tool — without ids the two are indistinguishable, and losing a
|
|
275
|
+
// real run is worse than a duplicate card on a legacy transport.
|
|
276
|
+
const matches = execId
|
|
277
|
+
? seg.data.toolExecutionRequestId === execId
|
|
278
|
+
: seg.data.type === 'EXECUTING_TOOL' &&
|
|
279
|
+
seg.data.integratedToolType === toolData.integratedToolType &&
|
|
280
|
+
seg.data.toolFunction === toolData.toolFunction
|
|
281
|
+
if (!matches) continue
|
|
282
|
+
// Never downgrade a completed segment back to EXECUTING (replayed
|
|
283
|
+
// EXECUTING chunk after its EXECUTED already landed).
|
|
284
|
+
if (toolData.type === 'EXECUTING_TOOL' && seg.data.type === 'EXECUTED_TOOL') {
|
|
285
|
+
return prev
|
|
286
|
+
}
|
|
287
|
+
const mergedData: ToolExecutionData = {
|
|
288
|
+
...toolData,
|
|
289
|
+
toolTitle: toolData.toolTitle ?? seg.data.toolTitle,
|
|
290
|
+
parameters: toolData.parameters || seg.data.parameters,
|
|
291
|
+
}
|
|
292
|
+
// Value-level no-op (replayed duplicate) → prior references.
|
|
293
|
+
if (sameToolData(seg.data, mergedData)) return prev
|
|
294
|
+
const nextSegments = [...message.segments]
|
|
295
|
+
nextSegments[j] = { type: 'tool_execution', data: mergedData }
|
|
296
|
+
const next = [...prev]
|
|
297
|
+
next[i] = { ...message, segments: nextSegments }
|
|
298
|
+
return next
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Cross-message approval status flip (absorbed from the NATS adapter's
|
|
308
|
+
* `onApprovalResolved` handler). Idempotent + reference-preserving: only the
|
|
309
|
+
* messages/segments whose status (or resolvedByName) actually changes are
|
|
310
|
+
* cloned; a no-op returns `prev`.
|
|
311
|
+
*/
|
|
312
|
+
export function projectApprovalResolutionToMessages(
|
|
313
|
+
prev: UnifiedChatMessage[],
|
|
314
|
+
requestId: string,
|
|
315
|
+
status: ChatApprovalStatus,
|
|
316
|
+
resolvedByName?: string | null,
|
|
317
|
+
): UnifiedChatMessage[] {
|
|
318
|
+
let changed = false
|
|
319
|
+
const next = prev.map((m) => {
|
|
320
|
+
if (m.role !== 'assistant' || !m.segments) return m
|
|
321
|
+
let msgChanged = false
|
|
322
|
+
const segs = m.segments.map((s) => {
|
|
323
|
+
if (s.type === 'approval_request' && s.data.requestId === requestId && s.status !== status) {
|
|
324
|
+
msgChanged = true
|
|
325
|
+
return { ...s, status }
|
|
326
|
+
}
|
|
327
|
+
if (s.type === 'approval_batch' && s.data.approvalRequestId === requestId) {
|
|
328
|
+
const nextResolvedBy = resolvedByName ?? s.resolvedByName
|
|
329
|
+
if (s.status === status && nextResolvedBy === s.resolvedByName) return s
|
|
330
|
+
msgChanged = true
|
|
331
|
+
return { ...s, status, resolvedByName: nextResolvedBy }
|
|
332
|
+
}
|
|
333
|
+
return s
|
|
334
|
+
})
|
|
335
|
+
if (!msgChanged) return m
|
|
336
|
+
changed = true
|
|
337
|
+
return { ...m, segments: segs }
|
|
338
|
+
})
|
|
339
|
+
return changed ? next : prev
|
|
340
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useChatStreamReducer — thin React wrapper over a `ChatDialogStore` side.
|
|
5
|
+
*
|
|
6
|
+
* - `useSyncExternalStore` over the store (snapshot identity is stable
|
|
7
|
+
* between mutations, so untouched renders bail out);
|
|
8
|
+
* - APPEND-ONLY DELTA BATCHING: delegated wholesale to the framework-free
|
|
9
|
+
* `createDeltaBatcher` (same module) so a non-React host can share the
|
|
10
|
+
* exact same coalescing/flush policy instead of re-implementing it.
|
|
11
|
+
* - Any NON-delta event force-flushes the pending batch first, so ordering
|
|
12
|
+
* is preserved and stream completion (`turn-end`, errors, approval
|
|
13
|
+
* frames) always lands on fully-applied delta state. Adapters whose
|
|
14
|
+
* transport has no terminal event (SSE) call `flushDeltas()` explicitly
|
|
15
|
+
* before their end-of-turn command.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
|
|
19
|
+
import type { ChatStreamEvent } from '../../../chat-protocol/events'
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_DIALOG_SIDE,
|
|
22
|
+
type ChatDialogSide,
|
|
23
|
+
type ChatDialogStore,
|
|
24
|
+
} from './chat-dialog-store'
|
|
25
|
+
import { createDeltaBatcher, type DeltaBatcher } from './delta-batcher'
|
|
26
|
+
import type {
|
|
27
|
+
ChatReducerState,
|
|
28
|
+
ChatStreamReducer,
|
|
29
|
+
ChatStreamReducerOptions,
|
|
30
|
+
} from './chat-stream-reducer'
|
|
31
|
+
|
|
32
|
+
interface DialogKey {
|
|
33
|
+
dialogId: string
|
|
34
|
+
side: ChatDialogSide
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UseChatStreamReducerOptions {
|
|
38
|
+
store: ChatDialogStore
|
|
39
|
+
dialogId: string
|
|
40
|
+
side?: ChatDialogSide
|
|
41
|
+
/** Consulted once, when this (dialogId, side) reducer is first created. */
|
|
42
|
+
createReducerOptions?: () => ChatStreamReducerOptions
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface UseChatStreamReducerReturn {
|
|
46
|
+
state: ChatReducerState
|
|
47
|
+
/** Apply a decoded event (deltas are batched; everything else immediate). */
|
|
48
|
+
applyEvent: (event: ChatStreamEvent) => void
|
|
49
|
+
/** Synchronously apply any pending delta batch. */
|
|
50
|
+
flushDeltas: () => void
|
|
51
|
+
/** Run reducer commands (adapter-driven, non-wire mutations). */
|
|
52
|
+
mutate: <T>(fn: (reducer: ChatStreamReducer) => T) => T
|
|
53
|
+
reducer: ChatStreamReducer
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useChatStreamReducer({
|
|
57
|
+
store,
|
|
58
|
+
dialogId,
|
|
59
|
+
side = DEFAULT_DIALOG_SIDE,
|
|
60
|
+
createReducerOptions,
|
|
61
|
+
}: UseChatStreamReducerOptions): UseChatStreamReducerReturn {
|
|
62
|
+
const optionsRef = useRef(createReducerOptions)
|
|
63
|
+
optionsRef.current = createReducerOptions
|
|
64
|
+
|
|
65
|
+
// Ensure the reducer exists before the first snapshot read.
|
|
66
|
+
const reducer = store.getReducer(dialogId, side, () => optionsRef.current?.() ?? {})
|
|
67
|
+
|
|
68
|
+
const getSnapshot = useCallback(() => store.getSnapshot(dialogId, side), [store, dialogId, side])
|
|
69
|
+
// Distinct server getter: `getSnapshot` is a pure read of a CLIENT-lived
|
|
70
|
+
// map, so reusing it for SSR would render a per-request-meaningless value
|
|
71
|
+
// (and, before it was made pure, silently accumulate reducers per request).
|
|
72
|
+
const getServerSnapshot = useCallback(() => store.getServerSnapshot(), [store])
|
|
73
|
+
const subscribe = useCallback((listener: () => void) => store.subscribe(listener), [store])
|
|
74
|
+
const state = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
75
|
+
|
|
76
|
+
// Pin this key for as long as the hook is mounted, so the store's LRU can
|
|
77
|
+
// never evict a reducer a live panel is rendering.
|
|
78
|
+
useEffect(() => store.retain(dialogId, side), [store, dialogId, side])
|
|
79
|
+
|
|
80
|
+
// ── Delta batch ──────────────────────────────────────────────────────────
|
|
81
|
+
// Identity-stable key object: the batcher compares keys with `Object.is`,
|
|
82
|
+
// so it must only change when the (dialogId, side) VALUES change.
|
|
83
|
+
const keyRef = useRef<DialogKey>({ dialogId, side })
|
|
84
|
+
if (keyRef.current.dialogId !== dialogId || keyRef.current.side !== side) {
|
|
85
|
+
keyRef.current = { dialogId, side }
|
|
86
|
+
}
|
|
87
|
+
const storeRef = useRef(store)
|
|
88
|
+
storeRef.current = store
|
|
89
|
+
|
|
90
|
+
const batcherRef = useRef<DeltaBatcher<DialogKey> | null>(null)
|
|
91
|
+
if (batcherRef.current === null) {
|
|
92
|
+
batcherRef.current = createDeltaBatcher<DialogKey>({
|
|
93
|
+
applyOne: (event, key) => {
|
|
94
|
+
if (key) storeRef.current.apply(key.dialogId, key.side, event)
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
const batcher = batcherRef.current
|
|
99
|
+
|
|
100
|
+
const flushDeltas = useCallback(() => batcher.flush(), [batcher])
|
|
101
|
+
|
|
102
|
+
const applyEvent = useCallback(
|
|
103
|
+
(event: ChatStreamEvent) => {
|
|
104
|
+
// `push` flushes on key change and returns false for non-delta events;
|
|
105
|
+
// those flush the batch first (ordering) and apply synchronously, so
|
|
106
|
+
// completion state always lands on fully-applied deltas.
|
|
107
|
+
if (batcher.push(event, keyRef.current)) return
|
|
108
|
+
batcher.flush()
|
|
109
|
+
store.apply(dialogId, side, event)
|
|
110
|
+
},
|
|
111
|
+
[batcher, store, dialogId, side],
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
// Flush on unmount so a torn-down panel doesn't drop its tail deltas.
|
|
115
|
+
useEffect(() => () => batcher.dispose(), [batcher])
|
|
116
|
+
|
|
117
|
+
const mutate = useCallback(
|
|
118
|
+
<T,>(fn: (reducer: ChatStreamReducer) => T): T => store.mutate(dialogId, side, fn),
|
|
119
|
+
[store, dialogId, side],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return useMemo(
|
|
123
|
+
() => ({ state, applyEvent, flushDeltas, mutate, reducer }),
|
|
124
|
+
[state, applyEvent, flushDeltas, mutate, reducer],
|
|
125
|
+
)
|
|
126
|
+
}
|