@data-club/ai-hub 0.0.1
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/auth/passwordPolicy.d.ts +55 -0
- package/dist/auth/passwordPolicy.d.ts.map +1 -0
- package/dist/auth/passwordPolicy.js +63 -0
- package/dist/auth/passwordPolicy.js.map +1 -0
- package/dist/auth/texts.d.ts +815 -0
- package/dist/auth/texts.d.ts.map +1 -0
- package/dist/auth/texts.js +429 -0
- package/dist/auth/texts.js.map +1 -0
- package/dist/client/AIHubClient.d.ts +1058 -0
- package/dist/client/AIHubClient.d.ts.map +1 -0
- package/dist/client/AIHubClient.js +950 -0
- package/dist/client/AIHubClient.js.map +1 -0
- package/dist/client/index.d.ts +11 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +9 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/sseParser.d.ts +41 -0
- package/dist/client/sseParser.d.ts.map +1 -0
- package/dist/client/sseParser.js +153 -0
- package/dist/client/sseParser.js.map +1 -0
- package/dist/client/tokenStorage.d.ts +56 -0
- package/dist/client/tokenStorage.d.ts.map +1 -0
- package/dist/client/tokenStorage.js +116 -0
- package/dist/client/tokenStorage.js.map +1 -0
- package/dist/components/AgentsPicker/index.d.ts +70 -0
- package/dist/components/AgentsPicker/index.d.ts.map +1 -0
- package/dist/components/AgentsPicker/index.js +63 -0
- package/dist/components/AgentsPicker/index.js.map +1 -0
- package/dist/components/AuthGate/index.d.ts +79 -0
- package/dist/components/AuthGate/index.d.ts.map +1 -0
- package/dist/components/AuthGate/index.js +61 -0
- package/dist/components/AuthGate/index.js.map +1 -0
- package/dist/components/CacheBadge/index.d.ts +88 -0
- package/dist/components/CacheBadge/index.d.ts.map +1 -0
- package/dist/components/CacheBadge/index.js +127 -0
- package/dist/components/CacheBadge/index.js.map +1 -0
- package/dist/components/ChangePassword/index.d.ts +109 -0
- package/dist/components/ChangePassword/index.d.ts.map +1 -0
- package/dist/components/ChangePassword/index.js +142 -0
- package/dist/components/ChangePassword/index.js.map +1 -0
- package/dist/components/ChatShell/ShellHeader.d.ts +44 -0
- package/dist/components/ChatShell/ShellHeader.d.ts.map +1 -0
- package/dist/components/ChatShell/ShellHeader.js +14 -0
- package/dist/components/ChatShell/ShellHeader.js.map +1 -0
- package/dist/components/ChatShell/UserMenu.d.ts +48 -0
- package/dist/components/ChatShell/UserMenu.d.ts.map +1 -0
- package/dist/components/ChatShell/UserMenu.js +86 -0
- package/dist/components/ChatShell/UserMenu.js.map +1 -0
- package/dist/components/ChatShell/index.d.ts +98 -0
- package/dist/components/ChatShell/index.d.ts.map +1 -0
- package/dist/components/ChatShell/index.js +124 -0
- package/dist/components/ChatShell/index.js.map +1 -0
- package/dist/components/DataClubAIHub/index.d.ts +132 -0
- package/dist/components/DataClubAIHub/index.d.ts.map +1 -0
- package/dist/components/DataClubAIHub/index.js +124 -0
- package/dist/components/DataClubAIHub/index.js.map +1 -0
- package/dist/components/DataClubAIHubAdmin/AgentEditorModal.d.ts +89 -0
- package/dist/components/DataClubAIHubAdmin/AgentEditorModal.d.ts.map +1 -0
- package/dist/components/DataClubAIHubAdmin/AgentEditorModal.js +199 -0
- package/dist/components/DataClubAIHubAdmin/AgentEditorModal.js.map +1 -0
- package/dist/components/DataClubAIHubAdmin/AgentsPage.d.ts +33 -0
- package/dist/components/DataClubAIHubAdmin/AgentsPage.d.ts.map +1 -0
- package/dist/components/DataClubAIHubAdmin/AgentsPage.js +137 -0
- package/dist/components/DataClubAIHubAdmin/AgentsPage.js.map +1 -0
- package/dist/components/DataClubAIHubAdmin/CreateUserModal.d.ts +50 -0
- package/dist/components/DataClubAIHubAdmin/CreateUserModal.d.ts.map +1 -0
- package/dist/components/DataClubAIHubAdmin/CreateUserModal.js +93 -0
- package/dist/components/DataClubAIHubAdmin/CreateUserModal.js.map +1 -0
- package/dist/components/DataClubAIHubAdmin/PasswordRevealModal.d.ts +49 -0
- package/dist/components/DataClubAIHubAdmin/PasswordRevealModal.d.ts.map +1 -0
- package/dist/components/DataClubAIHubAdmin/PasswordRevealModal.js +94 -0
- package/dist/components/DataClubAIHubAdmin/PasswordRevealModal.js.map +1 -0
- package/dist/components/DataClubAIHubAdmin/ResetPasswordModal.d.ts +37 -0
- package/dist/components/DataClubAIHubAdmin/ResetPasswordModal.d.ts.map +1 -0
- package/dist/components/DataClubAIHubAdmin/ResetPasswordModal.js +44 -0
- package/dist/components/DataClubAIHubAdmin/ResetPasswordModal.js.map +1 -0
- package/dist/components/DataClubAIHubAdmin/RotatingMessageEditorModal.d.ts +56 -0
- package/dist/components/DataClubAIHubAdmin/RotatingMessageEditorModal.d.ts.map +1 -0
- package/dist/components/DataClubAIHubAdmin/RotatingMessageEditorModal.js +158 -0
- package/dist/components/DataClubAIHubAdmin/RotatingMessageEditorModal.js.map +1 -0
- package/dist/components/DataClubAIHubAdmin/RotatingMessagesPage.d.ts +29 -0
- package/dist/components/DataClubAIHubAdmin/RotatingMessagesPage.d.ts.map +1 -0
- package/dist/components/DataClubAIHubAdmin/RotatingMessagesPage.js +183 -0
- package/dist/components/DataClubAIHubAdmin/RotatingMessagesPage.js.map +1 -0
- package/dist/components/DataClubAIHubAdmin/SessionsOverviewPage.d.ts +38 -0
- package/dist/components/DataClubAIHubAdmin/SessionsOverviewPage.d.ts.map +1 -0
- package/dist/components/DataClubAIHubAdmin/SessionsOverviewPage.js +294 -0
- package/dist/components/DataClubAIHubAdmin/SessionsOverviewPage.js.map +1 -0
- package/dist/components/DataClubAIHubAdmin/SettingsPage.d.ts +36 -0
- package/dist/components/DataClubAIHubAdmin/SettingsPage.d.ts.map +1 -0
- package/dist/components/DataClubAIHubAdmin/SettingsPage.js +373 -0
- package/dist/components/DataClubAIHubAdmin/SettingsPage.js.map +1 -0
- package/dist/components/DataClubAIHubAdmin/UsersPage.d.ts +47 -0
- package/dist/components/DataClubAIHubAdmin/UsersPage.d.ts.map +1 -0
- package/dist/components/DataClubAIHubAdmin/UsersPage.js +233 -0
- package/dist/components/DataClubAIHubAdmin/UsersPage.js.map +1 -0
- package/dist/components/DataClubAIHubAdmin/index.d.ts +87 -0
- package/dist/components/DataClubAIHubAdmin/index.d.ts.map +1 -0
- package/dist/components/DataClubAIHubAdmin/index.js +137 -0
- package/dist/components/DataClubAIHubAdmin/index.js.map +1 -0
- package/dist/components/DataClubAIHubLogin/index.d.ts +113 -0
- package/dist/components/DataClubAIHubLogin/index.d.ts.map +1 -0
- package/dist/components/DataClubAIHubLogin/index.js +123 -0
- package/dist/components/DataClubAIHubLogin/index.js.map +1 -0
- package/dist/components/EffortControl/index.d.ts +57 -0
- package/dist/components/EffortControl/index.d.ts.map +1 -0
- package/dist/components/EffortControl/index.js +84 -0
- package/dist/components/EffortControl/index.js.map +1 -0
- package/dist/components/Layout/index.d.ts +63 -0
- package/dist/components/Layout/index.d.ts.map +1 -0
- package/dist/components/Layout/index.js +45 -0
- package/dist/components/Layout/index.js.map +1 -0
- package/dist/components/MainColumn/index.d.ts +69 -0
- package/dist/components/MainColumn/index.d.ts.map +1 -0
- package/dist/components/MainColumn/index.js +47 -0
- package/dist/components/MainColumn/index.js.map +1 -0
- package/dist/components/MarkdownRenderer/index.d.ts +66 -0
- package/dist/components/MarkdownRenderer/index.d.ts.map +1 -0
- package/dist/components/MarkdownRenderer/index.js +153 -0
- package/dist/components/MarkdownRenderer/index.js.map +1 -0
- package/dist/components/MessageInput/index.d.ts +85 -0
- package/dist/components/MessageInput/index.d.ts.map +1 -0
- package/dist/components/MessageInput/index.js +139 -0
- package/dist/components/MessageInput/index.js.map +1 -0
- package/dist/components/MessageList/index.d.ts +156 -0
- package/dist/components/MessageList/index.d.ts.map +1 -0
- package/dist/components/MessageList/index.js +352 -0
- package/dist/components/MessageList/index.js.map +1 -0
- package/dist/components/ReasoningSelector/index.d.ts +77 -0
- package/dist/components/ReasoningSelector/index.d.ts.map +1 -0
- package/dist/components/ReasoningSelector/index.js +156 -0
- package/dist/components/ReasoningSelector/index.js.map +1 -0
- package/dist/components/ReasoningView/index.d.ts +94 -0
- package/dist/components/ReasoningView/index.d.ts.map +1 -0
- package/dist/components/ReasoningView/index.js +83 -0
- package/dist/components/ReasoningView/index.js.map +1 -0
- package/dist/components/ScrollToPresentButton/index.d.ts +54 -0
- package/dist/components/ScrollToPresentButton/index.d.ts.map +1 -0
- package/dist/components/ScrollToPresentButton/index.js +44 -0
- package/dist/components/ScrollToPresentButton/index.js.map +1 -0
- package/dist/components/SessionsPanel/SessionTitleModal.d.ts +79 -0
- package/dist/components/SessionsPanel/SessionTitleModal.d.ts.map +1 -0
- package/dist/components/SessionsPanel/SessionTitleModal.js +102 -0
- package/dist/components/SessionsPanel/SessionTitleModal.js.map +1 -0
- package/dist/components/SessionsPanel/index.d.ts +100 -0
- package/dist/components/SessionsPanel/index.d.ts.map +1 -0
- package/dist/components/SessionsPanel/index.js +176 -0
- package/dist/components/SessionsPanel/index.js.map +1 -0
- package/dist/components/SystemNotice/index.d.ts +66 -0
- package/dist/components/SystemNotice/index.d.ts.map +1 -0
- package/dist/components/SystemNotice/index.js +81 -0
- package/dist/components/SystemNotice/index.js.map +1 -0
- package/dist/components/ThemeSwitcher/index.d.ts +66 -0
- package/dist/components/ThemeSwitcher/index.d.ts.map +1 -0
- package/dist/components/ThemeSwitcher/index.js +73 -0
- package/dist/components/ThemeSwitcher/index.js.map +1 -0
- package/dist/components/ToolCallView/formatContent.d.ts +36 -0
- package/dist/components/ToolCallView/formatContent.d.ts.map +1 -0
- package/dist/components/ToolCallView/formatContent.js +53 -0
- package/dist/components/ToolCallView/formatContent.js.map +1 -0
- package/dist/components/ToolCallView/index.d.ts +120 -0
- package/dist/components/ToolCallView/index.d.ts.map +1 -0
- package/dist/components/ToolCallView/index.js +104 -0
- package/dist/components/ToolCallView/index.js.map +1 -0
- package/dist/components/TpmBanner/index.d.ts +53 -0
- package/dist/components/TpmBanner/index.d.ts.map +1 -0
- package/dist/components/TpmBanner/index.js +116 -0
- package/dist/components/TpmBanner/index.js.map +1 -0
- package/dist/components/TurnStatusIndicator/index.d.ts +77 -0
- package/dist/components/TurnStatusIndicator/index.d.ts.map +1 -0
- package/dist/components/TurnStatusIndicator/index.js +122 -0
- package/dist/components/TurnStatusIndicator/index.js.map +1 -0
- package/dist/components/internal/PasswordInput.d.ts +62 -0
- package/dist/components/internal/PasswordInput.d.ts.map +1 -0
- package/dist/components/internal/PasswordInput.js +42 -0
- package/dist/components/internal/PasswordInput.js.map +1 -0
- package/dist/context/AIHubContext.d.ts +220 -0
- package/dist/context/AIHubContext.d.ts.map +1 -0
- package/dist/context/AIHubContext.js +19 -0
- package/dist/context/AIHubContext.js.map +1 -0
- package/dist/context/AIHubProvider.d.ts +75 -0
- package/dist/context/AIHubProvider.d.ts.map +1 -0
- package/dist/context/AIHubProvider.js +954 -0
- package/dist/context/AIHubProvider.js.map +1 -0
- package/dist/context/index.d.ts +9 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +9 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/theme.d.ts +43 -0
- package/dist/context/theme.d.ts.map +1 -0
- package/dist/context/theme.js +70 -0
- package/dist/context/theme.js.map +1 -0
- package/dist/context/useAIHub.d.ts +104 -0
- package/dist/context/useAIHub.d.ts.map +1 -0
- package/dist/context/useAIHub.js +63 -0
- package/dist/context/useAIHub.js.map +1 -0
- package/dist/context/useAIHubAuth.d.ts +44 -0
- package/dist/context/useAIHubAuth.d.ts.map +1 -0
- package/dist/context/useAIHubAuth.js +35 -0
- package/dist/context/useAIHubAuth.js.map +1 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +57 -0
- package/dist/index.js.map +1 -0
- package/dist/primitives/index.d.ts +99 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +97 -0
- package/dist/primitives/index.js.map +1 -0
- package/dist/state/affordances.d.ts +182 -0
- package/dist/state/affordances.d.ts.map +1 -0
- package/dist/state/affordances.js +189 -0
- package/dist/state/affordances.js.map +1 -0
- package/dist/state/stateReducer.d.ts +72 -0
- package/dist/state/stateReducer.d.ts.map +1 -0
- package/dist/state/stateReducer.js +122 -0
- package/dist/state/stateReducer.js.map +1 -0
- package/dist/stories/EffortControl.stories.d.ts +14 -0
- package/dist/stories/EffortControl.stories.d.ts.map +1 -0
- package/dist/stories/EffortControl.stories.js +43 -0
- package/dist/stories/EffortControl.stories.js.map +1 -0
- package/dist/stories/MarkdownRenderer.stories.d.ts +25 -0
- package/dist/stories/MarkdownRenderer.stories.d.ts.map +1 -0
- package/dist/stories/MarkdownRenderer.stories.js +178 -0
- package/dist/stories/MarkdownRenderer.stories.js.map +1 -0
- package/dist/stories/MessageBubble.stories.d.ts +21 -0
- package/dist/stories/MessageBubble.stories.d.ts.map +1 -0
- package/dist/stories/MessageBubble.stories.js +59 -0
- package/dist/stories/MessageBubble.stories.js.map +1 -0
- package/dist/stories/ReasoningView.stories.d.ts +18 -0
- package/dist/stories/ReasoningView.stories.d.ts.map +1 -0
- package/dist/stories/ReasoningView.stories.js +62 -0
- package/dist/stories/ReasoningView.stories.js.map +1 -0
- package/dist/stories/ScrollToPresentButton.stories.d.ts +18 -0
- package/dist/stories/ScrollToPresentButton.stories.d.ts.map +1 -0
- package/dist/stories/ScrollToPresentButton.stories.js +34 -0
- package/dist/stories/ScrollToPresentButton.stories.js.map +1 -0
- package/dist/stories/SystemNotice.stories.d.ts +17 -0
- package/dist/stories/SystemNotice.stories.d.ts.map +1 -0
- package/dist/stories/SystemNotice.stories.js +42 -0
- package/dist/stories/SystemNotice.stories.js.map +1 -0
- package/dist/stories/ThemeSwitcher.stories.d.ts +15 -0
- package/dist/stories/ThemeSwitcher.stories.d.ts.map +1 -0
- package/dist/stories/ThemeSwitcher.stories.js +35 -0
- package/dist/stories/ThemeSwitcher.stories.js.map +1 -0
- package/dist/stories/ToolCallView.stories.d.ts +20 -0
- package/dist/stories/ToolCallView.stories.d.ts.map +1 -0
- package/dist/stories/ToolCallView.stories.js +65 -0
- package/dist/stories/ToolCallView.stories.js.map +1 -0
- package/dist/stories/TpmBanner.stories.d.ts +24 -0
- package/dist/stories/TpmBanner.stories.d.ts.map +1 -0
- package/dist/stories/TpmBanner.stories.js +63 -0
- package/dist/stories/TpmBanner.stories.js.map +1 -0
- package/dist/stories/TurnStatusIndicator.stories.d.ts +25 -0
- package/dist/stories/TurnStatusIndicator.stories.d.ts.map +1 -0
- package/dist/stories/TurnStatusIndicator.stories.js +56 -0
- package/dist/stories/TurnStatusIndicator.stories.js.map +1 -0
- package/dist/stories/_mockProvider.d.ts +44 -0
- package/dist/stories/_mockProvider.d.ts.map +1 -0
- package/dist/stories/_mockProvider.js +84 -0
- package/dist/stories/_mockProvider.js.map +1 -0
- package/dist/types.d.ts +787 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +19 -0
- package/dist/types.js.map +1 -0
- package/dist/webcomponent/defineWebComponent.d.ts +106 -0
- package/dist/webcomponent/defineWebComponent.d.ts.map +1 -0
- package/dist/webcomponent/defineWebComponent.js +126 -0
- package/dist/webcomponent/defineWebComponent.js.map +1 -0
- package/dist/webcomponent/index.d.ts +7 -0
- package/dist/webcomponent/index.d.ts.map +1 -0
- package/dist/webcomponent/index.js +7 -0
- package/dist/webcomponent/index.js.map +1 -0
- package/package.json +63 -0
- package/src/__lint-canary__/step1-lint-canary.ts.disabled +28 -0
- package/src/auth/passwordPolicy.ts +75 -0
- package/src/auth/texts.ts +1294 -0
- package/src/client/AIHubClient.ts +1718 -0
- package/src/client/index.ts +30 -0
- package/src/client/sseParser.ts +168 -0
- package/src/client/tokenStorage.ts +131 -0
- package/src/components/AgentsPicker/index.tsx +147 -0
- package/src/components/AuthGate/index.tsx +132 -0
- package/src/components/CacheBadge/index.tsx +245 -0
- package/src/components/ChangePassword/index.tsx +375 -0
- package/src/components/ChatShell/ShellHeader.tsx +80 -0
- package/src/components/ChatShell/UserMenu.tsx +196 -0
- package/src/components/ChatShell/index.tsx +307 -0
- package/src/components/DataClubAIHub/index.tsx +300 -0
- package/src/components/DataClubAIHubAdmin/AgentEditorModal.tsx +505 -0
- package/src/components/DataClubAIHubAdmin/AgentsPage.tsx +342 -0
- package/src/components/DataClubAIHubAdmin/CreateUserModal.tsx +214 -0
- package/src/components/DataClubAIHubAdmin/PasswordRevealModal.tsx +204 -0
- package/src/components/DataClubAIHubAdmin/ResetPasswordModal.tsx +122 -0
- package/src/components/DataClubAIHubAdmin/RotatingMessageEditorModal.tsx +408 -0
- package/src/components/DataClubAIHubAdmin/RotatingMessagesPage.tsx +373 -0
- package/src/components/DataClubAIHubAdmin/SessionsOverviewPage.tsx +639 -0
- package/src/components/DataClubAIHubAdmin/SettingsPage.tsx +979 -0
- package/src/components/DataClubAIHubAdmin/UsersPage.tsx +495 -0
- package/src/components/DataClubAIHubAdmin/index.tsx +277 -0
- package/src/components/DataClubAIHubLogin/index.tsx +282 -0
- package/src/components/EffortControl/index.tsx +138 -0
- package/src/components/Layout/index.tsx +92 -0
- package/src/components/MainColumn/index.tsx +131 -0
- package/src/components/MarkdownRenderer/index.tsx +256 -0
- package/src/components/MessageInput/index.tsx +283 -0
- package/src/components/MessageList/index.tsx +753 -0
- package/src/components/ReasoningSelector/index.tsx +268 -0
- package/src/components/ReasoningView/index.tsx +183 -0
- package/src/components/ScrollToPresentButton/index.tsx +85 -0
- package/src/components/SessionsPanel/SessionTitleModal.tsx +256 -0
- package/src/components/SessionsPanel/index.tsx +406 -0
- package/src/components/SystemNotice/index.tsx +151 -0
- package/src/components/ThemeSwitcher/index.tsx +136 -0
- package/src/components/ToolCallView/formatContent.ts +50 -0
- package/src/components/ToolCallView/index.tsx +274 -0
- package/src/components/TpmBanner/index.tsx +172 -0
- package/src/components/TurnStatusIndicator/index.tsx +203 -0
- package/src/components/internal/PasswordInput.tsx +145 -0
- package/src/context/AIHubContext.ts +249 -0
- package/src/context/AIHubProvider.tsx +1084 -0
- package/src/context/index.ts +9 -0
- package/src/context/theme.ts +76 -0
- package/src/context/useAIHub.ts +151 -0
- package/src/context/useAIHubAuth.ts +65 -0
- package/src/index.ts +275 -0
- package/src/primitives/index.ts +101 -0
- package/src/state/affordances.ts +321 -0
- package/src/state/stateReducer.ts +131 -0
- package/src/stories/EffortControl.stories.tsx +63 -0
- package/src/stories/MarkdownRenderer.stories.tsx +201 -0
- package/src/stories/MessageBubble.stories.tsx +101 -0
- package/src/stories/ReasoningView.stories.tsx +81 -0
- package/src/stories/ScrollToPresentButton.stories.tsx +52 -0
- package/src/stories/SystemNotice.stories.tsx +59 -0
- package/src/stories/ThemeSwitcher.stories.tsx +55 -0
- package/src/stories/ToolCallView.stories.tsx +95 -0
- package/src/stories/TpmBanner.stories.tsx +93 -0
- package/src/stories/TurnStatusIndicator.stories.tsx +86 -0
- package/src/stories/_mockProvider.tsx +116 -0
- package/src/styles/CLASSES.md +356 -0
- package/src/styles/default.css +2937 -0
- package/src/types.ts +867 -0
- package/src/webcomponent/defineWebComponent.tsx +193 -0
- package/src/webcomponent/index.ts +11 -0
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<AIHubProvider>` — root of every ai-hub React tree.
|
|
3
|
+
*
|
|
4
|
+
* Owns:
|
|
5
|
+
*
|
|
6
|
+
* - The shared `AIHubClient` instance (consumers call SDK methods through
|
|
7
|
+
* `useAIHub().client`).
|
|
8
|
+
* - The JWT lifecycle: read from `tokenStorage` on mount, attempt `/auth/me`
|
|
9
|
+
* to hydrate `user` + `deployment`; clear on logout; clear and route
|
|
10
|
+
* back to login on any 401 from the server (§6.7 — no silent refresh).
|
|
11
|
+
* - The resolved theme: client-side precedence per §7.6, FOUC-avoided via
|
|
12
|
+
* a cached value in `localStorage` applied on first paint.
|
|
13
|
+
* - Per-message reasoning effort state.
|
|
14
|
+
* - **Step 9 — chat state:** agents catalog, active agent, sessions list,
|
|
15
|
+
* active session, message history, in-flight turn state (driven by the
|
|
16
|
+
* §10 reducer over SSE events from `client.streamChat`), `sendMessage`,
|
|
17
|
+
* `stop`. The state-aware affordance gates (TPM banner, theme switcher,
|
|
18
|
+
* tool-call card, reasoning view) layer on top in Step 10.
|
|
19
|
+
*
|
|
20
|
+
* @see SPECIFICATIONS.md §7.2 — provider props
|
|
21
|
+
* @see SPECIFICATIONS.md §7.5 — state on the client
|
|
22
|
+
* @see SPECIFICATIONS.md §7.6 — theme resolution
|
|
23
|
+
* @see SPECIFICATIONS.md §6.7 — auth model
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
useCallback,
|
|
28
|
+
useEffect,
|
|
29
|
+
useMemo,
|
|
30
|
+
useRef,
|
|
31
|
+
useState,
|
|
32
|
+
type ReactNode,
|
|
33
|
+
} from "react";
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
AIHubClient,
|
|
37
|
+
AIHubRequestError,
|
|
38
|
+
type AgentSummary,
|
|
39
|
+
type PatchSessionOptions,
|
|
40
|
+
} from "../client/AIHubClient.js";
|
|
41
|
+
import type { AIHubEffort } from "../client/AIHubClient.js";
|
|
42
|
+
import type { TokenStorageMode, TokenStorage } from "../client/tokenStorage.js";
|
|
43
|
+
import {
|
|
44
|
+
initialAIHubState,
|
|
45
|
+
reduceAIHubState,
|
|
46
|
+
} from "../state/stateReducer.js";
|
|
47
|
+
import type {
|
|
48
|
+
AIHubAuthContext,
|
|
49
|
+
AIHubDeployment,
|
|
50
|
+
AIHubError,
|
|
51
|
+
AIHubFeatureToggles,
|
|
52
|
+
AIHubMessage,
|
|
53
|
+
AIHubRole,
|
|
54
|
+
AIHubState,
|
|
55
|
+
AIHubToolCallRecord,
|
|
56
|
+
AIHubUser,
|
|
57
|
+
Session,
|
|
58
|
+
} from "../types.js";
|
|
59
|
+
|
|
60
|
+
import { aiHubContext, type AIHubContextValue } from "./AIHubContext.js";
|
|
61
|
+
import {
|
|
62
|
+
readCachedTheme,
|
|
63
|
+
resolveTheme,
|
|
64
|
+
writeCachedTheme,
|
|
65
|
+
type ResolvedTheme,
|
|
66
|
+
} from "./theme.js";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* When the post-stream canonicalize refetches messages from the server,
|
|
70
|
+
* we merge the optimistic-streaming `reasoning` + `tool_calls` we
|
|
71
|
+
* captured during the turn into the canonical assistant message that
|
|
72
|
+
* carries the same id. This handles two cases:
|
|
73
|
+
*
|
|
74
|
+
* 1. **Race window.** Letta's `tool_return_message` commit can lag the
|
|
75
|
+
* SSE `tool_call_result` event by a few ms; if our refetch hits in
|
|
76
|
+
* that window the projector sees the tool_call but no return, so
|
|
77
|
+
* the canonical row's `tool_calls` is missing or stale.
|
|
78
|
+
* 2. **Older server build.** Operators upgrading mid-session may still
|
|
79
|
+
* be running a server without the §10.x aggregating projector. The
|
|
80
|
+
* canonical row carries no `reasoning` / `tool_calls` at all.
|
|
81
|
+
*
|
|
82
|
+
* Either way, the optimistic data we already showed during streaming
|
|
83
|
+
* is correct; merging it onto the canonical row keeps it visible.
|
|
84
|
+
*
|
|
85
|
+
* @param canonical - the freshly-refetched list from
|
|
86
|
+
* `client.getSessionMessages`.
|
|
87
|
+
* @param optimistic - the message list we held in state before the
|
|
88
|
+
* refetch (post-streaming accumulation).
|
|
89
|
+
* @returns a new list, same identity / shape as `canonical`, with the
|
|
90
|
+
* missing affordance fields filled in from `optimistic` when present.
|
|
91
|
+
*/
|
|
92
|
+
function mergeOptimisticAffordances(
|
|
93
|
+
canonical: ReadonlyArray<AIHubMessage>,
|
|
94
|
+
optimistic: ReadonlyArray<AIHubMessage>,
|
|
95
|
+
): AIHubMessage[] {
|
|
96
|
+
const optimisticById = new Map<string, AIHubMessage>();
|
|
97
|
+
for (const m of optimistic) {
|
|
98
|
+
if (m.role === "assistant" && (m.reasoning || m.tool_calls?.length || m.usage)) {
|
|
99
|
+
optimisticById.set(m.id, m);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return canonical.map((c) => {
|
|
103
|
+
if (c.role !== "assistant") return c;
|
|
104
|
+
const o = optimisticById.get(c.id);
|
|
105
|
+
if (!o) return c;
|
|
106
|
+
const next: AIHubMessage = { ...c };
|
|
107
|
+
if (!next.reasoning && o.reasoning) {
|
|
108
|
+
next.reasoning = o.reasoning;
|
|
109
|
+
}
|
|
110
|
+
if ((!next.tool_calls || next.tool_calls.length === 0) && o.tool_calls?.length) {
|
|
111
|
+
next.tool_calls = o.tool_calls;
|
|
112
|
+
}
|
|
113
|
+
// Usage rides through too — if the canonical refetch happens
|
|
114
|
+
// before the server stamped `message_usage` (small race window),
|
|
115
|
+
// the live `usage` event we already handled keeps the badge
|
|
116
|
+
// rendered until the next refresh confirms it.
|
|
117
|
+
if (!next.usage && o.usage) {
|
|
118
|
+
next.usage = o.usage;
|
|
119
|
+
}
|
|
120
|
+
return next;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Public props on `<AIHubProvider>`.
|
|
126
|
+
*
|
|
127
|
+
* `defaultTheme` is overridden by the deployment's `theme_default` once
|
|
128
|
+
* `/auth/me` resolves (server config wins — same rule as the §8.4 toggles).
|
|
129
|
+
*/
|
|
130
|
+
export interface AIHubProviderProps {
|
|
131
|
+
/**
|
|
132
|
+
* Base URL the ai-hub-server routes are mounted at. Typically
|
|
133
|
+
* `/api/ai-hub` in a per-customer wrapper.
|
|
134
|
+
*/
|
|
135
|
+
baseUrl: string;
|
|
136
|
+
/** Custom `fetch`; defaults to `globalThis.fetch`. */
|
|
137
|
+
fetch?: typeof fetch;
|
|
138
|
+
/** Token storage mode or custom adapter. Defaults to `"localStorage"`. */
|
|
139
|
+
tokenStorage?: TokenStorageMode | TokenStorage;
|
|
140
|
+
/**
|
|
141
|
+
* Pre-built `AIHubClient`. When set, `baseUrl` / `fetch` / `tokenStorage`
|
|
142
|
+
* are ignored. Useful for tests that need to stub the SDK without going
|
|
143
|
+
* through the provider's lifecycle.
|
|
144
|
+
*/
|
|
145
|
+
client?: AIHubClient;
|
|
146
|
+
/**
|
|
147
|
+
* Notification hook for top-level errors. Fired for transport and
|
|
148
|
+
* surfaced stream errors; not fired for normal stream `done` /
|
|
149
|
+
* `cancelled`. The provider does not render its own error UI.
|
|
150
|
+
*/
|
|
151
|
+
onError?: (err: AIHubError) => void;
|
|
152
|
+
/**
|
|
153
|
+
* Fallback theme used when no `/auth/me` has resolved yet and the cache
|
|
154
|
+
* is empty. Server config (`theme_default`) wins after hydration.
|
|
155
|
+
*/
|
|
156
|
+
defaultTheme?: ResolvedTheme;
|
|
157
|
+
/** The tree under the provider. */
|
|
158
|
+
children: ReactNode;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The provider component. Single instance per ai-hub mount — multiple
|
|
163
|
+
* mounts on the same page each manage their own auth state (this is the
|
|
164
|
+
* shape `defineWebComponent` ships).
|
|
165
|
+
*
|
|
166
|
+
* @param props - the provider props.
|
|
167
|
+
*/
|
|
168
|
+
export function AIHubProvider(props: AIHubProviderProps): ReactNode {
|
|
169
|
+
const {
|
|
170
|
+
baseUrl,
|
|
171
|
+
children,
|
|
172
|
+
client: clientProp,
|
|
173
|
+
defaultTheme = "light",
|
|
174
|
+
fetch: fetchProp,
|
|
175
|
+
onError,
|
|
176
|
+
tokenStorage = "localStorage",
|
|
177
|
+
} = props;
|
|
178
|
+
|
|
179
|
+
// ── client ───────────────────────────────────────────────────────────
|
|
180
|
+
const unauthorizedRef = useRef<(() => void) | null>(null);
|
|
181
|
+
const client = useMemo(() => {
|
|
182
|
+
if (clientProp) return clientProp;
|
|
183
|
+
return new AIHubClient({
|
|
184
|
+
baseUrl,
|
|
185
|
+
...(fetchProp ? { fetch: fetchProp } : {}),
|
|
186
|
+
tokenStorage,
|
|
187
|
+
onUnauthorized: () => unauthorizedRef.current?.(),
|
|
188
|
+
});
|
|
189
|
+
}, [baseUrl, clientProp, fetchProp, tokenStorage]);
|
|
190
|
+
|
|
191
|
+
// ── auth state ──────────────────────────────────────────────────────
|
|
192
|
+
const [user, setUser] = useState<AIHubUser | null>(null);
|
|
193
|
+
const [deployment, setDeployment] = useState<AIHubDeployment | null>(null);
|
|
194
|
+
// Server feature toggles fetched from public GET /feature-toggles on mount
|
|
195
|
+
// (§8.4). `null` while the request is in flight; thereafter `{}` (empty
|
|
196
|
+
// blob — no overrides) or the typed blob. The provider does not block
|
|
197
|
+
// rendering on this — the §8.4 precedence ("server toggle > React prop >
|
|
198
|
+
// built-in default") falls through to the prop default until the fetch
|
|
199
|
+
// resolves, which keeps the FOUC story honest.
|
|
200
|
+
const [featureToggles, setFeatureToggles] =
|
|
201
|
+
useState<AIHubFeatureToggles | null>(null);
|
|
202
|
+
const [mustChangePassword, setMustChangePassword] = useState(false);
|
|
203
|
+
const [hydrating, setHydrating] = useState(true);
|
|
204
|
+
|
|
205
|
+
// ── effort (§7.4) + theme (§7.6) ────────────────────────────────────
|
|
206
|
+
const [effort, setEffort] = useState<AIHubEffort>("medium");
|
|
207
|
+
const [theme, setThemeState] = useState<ResolvedTheme>(() => {
|
|
208
|
+
return readCachedTheme() ?? defaultTheme;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── chat state (Step 9) ─────────────────────────────────────────────
|
|
212
|
+
const [agents, setAgents] = useState<AgentSummary[]>([]);
|
|
213
|
+
const [agentsLoading, setAgentsLoading] = useState(false);
|
|
214
|
+
const [activeAgentId, setActiveAgentIdState] = useState<string | null>(null);
|
|
215
|
+
const [sessions, setSessions] = useState<Session[]>([]);
|
|
216
|
+
const [sessionsLoading, setSessionsLoading] = useState(false);
|
|
217
|
+
const [includeArchivedSessions, setIncludeArchivedSessions] = useState(false);
|
|
218
|
+
const [activeSessionId, setActiveSessionIdState] = useState<string | null>(
|
|
219
|
+
null,
|
|
220
|
+
);
|
|
221
|
+
const [messages, setMessages] = useState<AIHubMessage[]>([]);
|
|
222
|
+
const [messagesLoading, setMessagesLoading] = useState(false);
|
|
223
|
+
const [streamState, setStreamState] = useState<AIHubState>(() =>
|
|
224
|
+
initialAIHubState(),
|
|
225
|
+
);
|
|
226
|
+
const [streamingAssistantText, setStreamingAssistantText] = useState<
|
|
227
|
+
string | null
|
|
228
|
+
>(null);
|
|
229
|
+
// Reasoning text accumulating during the current turn, when the user
|
|
230
|
+
// sent a non-`none` effort and the agent emitted `reasoning_delta`
|
|
231
|
+
// events. Cleared on `stream_end` (the canonicalized assistant
|
|
232
|
+
// message will carry the final reasoning).
|
|
233
|
+
const [streamingReasoning, setStreamingReasoning] = useState<string | null>(
|
|
234
|
+
null,
|
|
235
|
+
);
|
|
236
|
+
// Tool calls accumulating during the current turn. Map keyed by
|
|
237
|
+
// `tool_call_id`; one entry per call. Cleared on `stream_end`.
|
|
238
|
+
const [streamingToolCalls, setStreamingToolCalls] = useState<
|
|
239
|
+
ReadonlyArray<AIHubToolCallRecord>
|
|
240
|
+
>([]);
|
|
241
|
+
const [lastTurnError, setLastTurnError] = useState<string | null>(null);
|
|
242
|
+
// True from `stop()` invocation until the matching `stream_end` lands.
|
|
243
|
+
// The state machine's `state.status` stays `running` for the brief
|
|
244
|
+
// server-side cancel window — `isStoppingTurn` is the client-only flag
|
|
245
|
+
// that drives the "Stopping…" button label and keeps the input
|
|
246
|
+
// disabled until the genuine terminal event arrives.
|
|
247
|
+
const [isStoppingTurn, setIsStoppingTurn] = useState(false);
|
|
248
|
+
// Text to push into `<MessageInput>` on the next render. Populated by
|
|
249
|
+
// the post-stream-end branch when a cancel rolled back the user's
|
|
250
|
+
// message (single-step cancel: Letta deletes the entire turn from
|
|
251
|
+
// history — the user's typed text would be lost without this).
|
|
252
|
+
const [pendingDraft, setPendingDraft] = useState<string | null>(null);
|
|
253
|
+
|
|
254
|
+
// The turn currently being streamed: AbortController + cached turn id
|
|
255
|
+
// (read off `stream_start`) so `stop()` can fire the explicit cancel
|
|
256
|
+
// endpoint *and* abort the SSE iterator at the same time. We carry both
|
|
257
|
+
// in a ref so updates don't trigger re-renders mid-stream.
|
|
258
|
+
const streamRef = useRef<{
|
|
259
|
+
controller: AbortController;
|
|
260
|
+
turnId: string | null;
|
|
261
|
+
} | null>(null);
|
|
262
|
+
|
|
263
|
+
// Mirror of `activeSessionId` for use inside long-running async
|
|
264
|
+
// callbacks (the streamChat loop closes over `sessionId` from the
|
|
265
|
+
// moment `sendMessage` was called; we use this ref to detect whether
|
|
266
|
+
// the user has switched sessions in the meantime before applying the
|
|
267
|
+
// post-stream canonicalize).
|
|
268
|
+
const activeSessionIdRef = useRef<string | null>(null);
|
|
269
|
+
|
|
270
|
+
// Mirror of `messages` used by `sendMessage` to snapshot the message
|
|
271
|
+
// count BEFORE the optimistic user-bubble append. After stream_end
|
|
272
|
+
// the post-stream branch compares the canonical message count to this
|
|
273
|
+
// baseline: if `canonical.length <= baseline` the cancelled turn was
|
|
274
|
+
// rolled back entirely (single-step cancel — Letta deletes the user
|
|
275
|
+
// message too), and the user's typed text is pushed into `pendingDraft`
|
|
276
|
+
// so `<MessageInput>` can prefill it. State refs can lag a render
|
|
277
|
+
// behind the value but `sendMessage` reads it synchronously at the top.
|
|
278
|
+
const messagesRef = useRef<AIHubMessage[]>([]);
|
|
279
|
+
messagesRef.current = messages;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Resets every chat-state slot. Called on logout and on
|
|
283
|
+
* unauthenticated → authenticated transitions so a previous user's
|
|
284
|
+
* artifacts don't leak into the next session.
|
|
285
|
+
*/
|
|
286
|
+
const resetChatState = useCallback((): void => {
|
|
287
|
+
streamRef.current?.controller.abort();
|
|
288
|
+
streamRef.current = null;
|
|
289
|
+
setAgents([]);
|
|
290
|
+
setActiveAgentIdState(null);
|
|
291
|
+
setSessions([]);
|
|
292
|
+
setIncludeArchivedSessions(false);
|
|
293
|
+
setActiveSessionIdState(null);
|
|
294
|
+
setMessages([]);
|
|
295
|
+
setStreamState(initialAIHubState());
|
|
296
|
+
setStreamingAssistantText(null);
|
|
297
|
+
setLastTurnError(null);
|
|
298
|
+
}, []);
|
|
299
|
+
|
|
300
|
+
unauthorizedRef.current = () => {
|
|
301
|
+
setUser(null);
|
|
302
|
+
setDeployment(null);
|
|
303
|
+
setMustChangePassword(false);
|
|
304
|
+
resetChatState();
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Keep the ref in sync so the post-stream canonicalize sees the
|
|
308
|
+
// current value (it closes over a stale `activeSessionId` otherwise).
|
|
309
|
+
activeSessionIdRef.current = activeSessionId;
|
|
310
|
+
|
|
311
|
+
// ── hydrate on mount ────────────────────────────────────────────────
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
let cancelled = false;
|
|
314
|
+
const token = client.getToken();
|
|
315
|
+
if (!token) {
|
|
316
|
+
setHydrating(false);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
void (async () => {
|
|
320
|
+
try {
|
|
321
|
+
const context: AIHubAuthContext = await client.me();
|
|
322
|
+
if (cancelled) return;
|
|
323
|
+
setUser(context.user);
|
|
324
|
+
setDeployment(context.deployment);
|
|
325
|
+
setMustChangePassword(false);
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (cancelled) return;
|
|
328
|
+
if (err instanceof AIHubRequestError && err.code !== "unauthorized") {
|
|
329
|
+
onError?.({
|
|
330
|
+
code: err.code,
|
|
331
|
+
message: err.message,
|
|
332
|
+
...(err.status !== undefined ? { status: err.status } : {}),
|
|
333
|
+
...(err.request_id !== undefined ? { request_id: err.request_id } : {}),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
} finally {
|
|
337
|
+
if (!cancelled) setHydrating(false);
|
|
338
|
+
}
|
|
339
|
+
})();
|
|
340
|
+
return () => {
|
|
341
|
+
cancelled = true;
|
|
342
|
+
};
|
|
343
|
+
}, [client, onError]);
|
|
344
|
+
|
|
345
|
+
// ── load deployment-wide feature toggles on mount ───────────────────
|
|
346
|
+
// The endpoint is public (no auth) so we fetch as soon as the provider
|
|
347
|
+
// mounts — even before login lands — so the chat shell can apply the
|
|
348
|
+
// §8.4 precedence rules on first paint. Errors are logged via
|
|
349
|
+
// `onError` but never block rendering (we fall through to React-prop
|
|
350
|
+
// defaults).
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
let cancelled = false;
|
|
353
|
+
void (async () => {
|
|
354
|
+
try {
|
|
355
|
+
const toggles = await client.getFeatureToggles();
|
|
356
|
+
if (cancelled) return;
|
|
357
|
+
setFeatureToggles(toggles);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
if (cancelled) return;
|
|
360
|
+
if (err instanceof AIHubRequestError) {
|
|
361
|
+
onError?.({
|
|
362
|
+
code: err.code,
|
|
363
|
+
message: err.message,
|
|
364
|
+
...(err.status !== undefined ? { status: err.status } : {}),
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
// Fall through with an empty blob so every prop default applies.
|
|
368
|
+
setFeatureToggles({});
|
|
369
|
+
}
|
|
370
|
+
})();
|
|
371
|
+
return () => {
|
|
372
|
+
cancelled = true;
|
|
373
|
+
};
|
|
374
|
+
}, [client, onError]);
|
|
375
|
+
|
|
376
|
+
// ── reconcile resolved theme whenever inputs change ────────────────
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (!deployment) return;
|
|
379
|
+
const resolved = resolveTheme(user, deployment, defaultTheme);
|
|
380
|
+
setThemeState(resolved);
|
|
381
|
+
writeCachedTheme(resolved);
|
|
382
|
+
}, [user, deployment, defaultTheme]);
|
|
383
|
+
|
|
384
|
+
// ── load /agents whenever an authenticated user becomes available ───
|
|
385
|
+
const refreshAgents = useCallback(async (): Promise<void> => {
|
|
386
|
+
setAgentsLoading(true);
|
|
387
|
+
try {
|
|
388
|
+
const list = await client.listAgents();
|
|
389
|
+
setAgents(list);
|
|
390
|
+
} catch (err) {
|
|
391
|
+
if (err instanceof AIHubRequestError) {
|
|
392
|
+
onError?.({
|
|
393
|
+
code: err.code,
|
|
394
|
+
message: err.message,
|
|
395
|
+
...(err.status !== undefined ? { status: err.status } : {}),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
setAgents([]);
|
|
399
|
+
} finally {
|
|
400
|
+
setAgentsLoading(false);
|
|
401
|
+
}
|
|
402
|
+
}, [client, onError]);
|
|
403
|
+
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
if (!user || mustChangePassword) return;
|
|
406
|
+
void refreshAgents();
|
|
407
|
+
}, [user, mustChangePassword, refreshAgents]);
|
|
408
|
+
|
|
409
|
+
// ── load /sessions whenever an agent is opened ──────────────────────
|
|
410
|
+
const refreshSessions = useCallback(async (): Promise<void> => {
|
|
411
|
+
if (!activeAgentId) {
|
|
412
|
+
setSessions([]);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
setSessionsLoading(true);
|
|
416
|
+
try {
|
|
417
|
+
const list = await client.listSessions({
|
|
418
|
+
agentId: activeAgentId,
|
|
419
|
+
includeArchived: includeArchivedSessions,
|
|
420
|
+
});
|
|
421
|
+
setSessions(list);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
if (err instanceof AIHubRequestError) {
|
|
424
|
+
onError?.({
|
|
425
|
+
code: err.code,
|
|
426
|
+
message: err.message,
|
|
427
|
+
...(err.status !== undefined ? { status: err.status } : {}),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
setSessions([]);
|
|
431
|
+
} finally {
|
|
432
|
+
setSessionsLoading(false);
|
|
433
|
+
}
|
|
434
|
+
}, [activeAgentId, client, includeArchivedSessions, onError]);
|
|
435
|
+
|
|
436
|
+
useEffect(() => {
|
|
437
|
+
if (!activeAgentId) {
|
|
438
|
+
setSessions([]);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
void refreshSessions();
|
|
442
|
+
}, [activeAgentId, includeArchivedSessions, refreshSessions]);
|
|
443
|
+
|
|
444
|
+
// ── load /sessions/{id}/messages whenever a session is opened ──────
|
|
445
|
+
//
|
|
446
|
+
// Note: `setActiveSession` / `createSession` already clear `messages`
|
|
447
|
+
// and set `messagesLoading=true` synchronously in the same batch as
|
|
448
|
+
// the id flip, so the previous session's bubbles never flash during
|
|
449
|
+
// this fetch. We don't repeat those resets here — only the fetch +
|
|
450
|
+
// the loading-flag flip on completion are this effect's job.
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
if (!activeSessionId) {
|
|
453
|
+
setMessages([]);
|
|
454
|
+
setMessagesLoading(false);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
let cancelled = false;
|
|
458
|
+
setMessagesLoading(true);
|
|
459
|
+
void (async () => {
|
|
460
|
+
try {
|
|
461
|
+
const list = await client.getSessionMessages(activeSessionId);
|
|
462
|
+
if (cancelled) return;
|
|
463
|
+
setMessages(list);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
if (cancelled) return;
|
|
466
|
+
if (err instanceof AIHubRequestError) {
|
|
467
|
+
onError?.({
|
|
468
|
+
code: err.code,
|
|
469
|
+
message: err.message,
|
|
470
|
+
...(err.status !== undefined ? { status: err.status } : {}),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
setMessages([]);
|
|
474
|
+
} finally {
|
|
475
|
+
if (!cancelled) setMessagesLoading(false);
|
|
476
|
+
}
|
|
477
|
+
})();
|
|
478
|
+
return () => {
|
|
479
|
+
cancelled = true;
|
|
480
|
+
};
|
|
481
|
+
}, [activeSessionId, client, onError]);
|
|
482
|
+
|
|
483
|
+
// ── action callbacks (auth + theme) ────────────────────────────────
|
|
484
|
+
const login: AIHubContextValue["login"] = async (username, password) => {
|
|
485
|
+
const result = await client.login(username, password);
|
|
486
|
+
setMustChangePassword(result.mustChangePassword);
|
|
487
|
+
setUser(result.user);
|
|
488
|
+
setDeployment(result.deployment ?? null);
|
|
489
|
+
return result;
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
const logout: AIHubContextValue["logout"] = async () => {
|
|
493
|
+
try {
|
|
494
|
+
await client.logout();
|
|
495
|
+
} finally {
|
|
496
|
+
setUser(null);
|
|
497
|
+
setDeployment(null);
|
|
498
|
+
setMustChangePassword(false);
|
|
499
|
+
resetChatState();
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const changePassword: AIHubContextValue["changePassword"] = async (
|
|
504
|
+
currentPassword,
|
|
505
|
+
newPassword,
|
|
506
|
+
) => {
|
|
507
|
+
await client.changePassword(currentPassword, newPassword);
|
|
508
|
+
const context = await client.me();
|
|
509
|
+
setUser(context.user);
|
|
510
|
+
setDeployment(context.deployment);
|
|
511
|
+
setMustChangePassword(false);
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
const setTheme: AIHubContextValue["setTheme"] = async (next) => {
|
|
515
|
+
const previous = theme;
|
|
516
|
+
setThemeState(next);
|
|
517
|
+
writeCachedTheme(next);
|
|
518
|
+
try {
|
|
519
|
+
const context = await client.updatePreferences({ theme: next });
|
|
520
|
+
setUser(context.user);
|
|
521
|
+
setDeployment(context.deployment);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
setThemeState(previous);
|
|
524
|
+
writeCachedTheme(previous);
|
|
525
|
+
throw err;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
// ── chat actions ───────────────────────────────────────────────────
|
|
530
|
+
const setActiveAgent: AIHubContextValue["setActiveAgent"] = (id) => {
|
|
531
|
+
streamRef.current?.controller.abort();
|
|
532
|
+
streamRef.current = null;
|
|
533
|
+
setActiveAgentIdState(id);
|
|
534
|
+
setActiveSessionIdState(null);
|
|
535
|
+
setMessages([]);
|
|
536
|
+
setStreamingAssistantText(null);
|
|
537
|
+
setLastTurnError(null);
|
|
538
|
+
setIsStoppingTurn(false);
|
|
539
|
+
setPendingDraft(null);
|
|
540
|
+
setStreamState(initialAIHubState());
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
const setActiveSession: AIHubContextValue["setActiveSession"] = (id) => {
|
|
544
|
+
if (id === activeSessionId) return;
|
|
545
|
+
streamRef.current?.controller.abort();
|
|
546
|
+
streamRef.current = null;
|
|
547
|
+
setActiveSessionIdState(id);
|
|
548
|
+
// Clear messages synchronously in the same setState batch as the id
|
|
549
|
+
// change — otherwise the previous session's bubbles flash for the
|
|
550
|
+
// duration of the next session's `/sessions/:id/messages` fetch.
|
|
551
|
+
setMessages([]);
|
|
552
|
+
setMessagesLoading(id !== null);
|
|
553
|
+
setStreamingAssistantText(null);
|
|
554
|
+
setLastTurnError(null);
|
|
555
|
+
setIsStoppingTurn(false);
|
|
556
|
+
setPendingDraft(null);
|
|
557
|
+
setStreamState(initialAIHubState());
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const createSession: AIHubContextValue["createSession"] = async (title) => {
|
|
561
|
+
if (!activeAgentId) {
|
|
562
|
+
throw new AIHubRequestError(
|
|
563
|
+
"validation",
|
|
564
|
+
"Open an agent before creating a session.",
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
const session = await client.createSession({
|
|
568
|
+
agentId: activeAgentId,
|
|
569
|
+
title,
|
|
570
|
+
});
|
|
571
|
+
// Prepend so the new row appears at the top of the list (server orders
|
|
572
|
+
// by updated_at DESC).
|
|
573
|
+
setSessions((prev) => [session, ...prev.filter((s) => s.id !== session.id)]);
|
|
574
|
+
// Same synchronous-clear story as `setActiveSession` — otherwise the
|
|
575
|
+
// previous session's bubbles flash during the brand-new session's
|
|
576
|
+
// (empty) history fetch.
|
|
577
|
+
streamRef.current?.controller.abort();
|
|
578
|
+
streamRef.current = null;
|
|
579
|
+
setActiveSessionIdState(session.id);
|
|
580
|
+
setMessages([]);
|
|
581
|
+
setMessagesLoading(true);
|
|
582
|
+
setStreamingAssistantText(null);
|
|
583
|
+
setLastTurnError(null);
|
|
584
|
+
setStreamState(initialAIHubState());
|
|
585
|
+
// Refresh the agents catalog so the picker's per-caller session_count
|
|
586
|
+
// reflects the new row on the next visit. Best-effort — failure isn't
|
|
587
|
+
// fatal.
|
|
588
|
+
void refreshAgents();
|
|
589
|
+
return session;
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
const patchSession: AIHubContextValue["patchSession"] = async (id, patch) => {
|
|
593
|
+
const updated = await client.patchSession(id, patch);
|
|
594
|
+
setSessions((prev) => {
|
|
595
|
+
const next = prev.map((s) => (s.id === id ? updated : s));
|
|
596
|
+
// If the row was archived and the filter is off, drop it from view.
|
|
597
|
+
if (
|
|
598
|
+
patch.isArchived === true &&
|
|
599
|
+
!includeArchivedSessions
|
|
600
|
+
) {
|
|
601
|
+
return next.filter((s) => !s.is_archived);
|
|
602
|
+
}
|
|
603
|
+
return next;
|
|
604
|
+
});
|
|
605
|
+
if (patch.isArchived === true && id === activeSessionId) {
|
|
606
|
+
setActiveSessionIdState(null);
|
|
607
|
+
}
|
|
608
|
+
return updated;
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
const sendMessage: AIHubContextValue["sendMessage"] = async (text) => {
|
|
612
|
+
const sessionId = activeSessionId;
|
|
613
|
+
if (!sessionId) {
|
|
614
|
+
throw new AIHubRequestError(
|
|
615
|
+
"validation",
|
|
616
|
+
"Open a session before sending a message.",
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
if (typeof text !== "string" || text.length === 0) {
|
|
620
|
+
throw new AIHubRequestError("validation", "Cannot send an empty message.");
|
|
621
|
+
}
|
|
622
|
+
// Optimistically append the user's message so the bubble shows up
|
|
623
|
+
// immediately. The server doesn't echo user messages on the stream —
|
|
624
|
+
// they're already in the conversation by the time we ask Letta to
|
|
625
|
+
// produce a reply. Stream-end refetches `getSessionMessages` and
|
|
626
|
+
// replaces the array with Letta's canonical history, so this
|
|
627
|
+
// pending-* id is short-lived; we dedupe defensively anyway.
|
|
628
|
+
const optimisticUser: AIHubMessage = {
|
|
629
|
+
id: `pending-${Date.now()}`,
|
|
630
|
+
role: "user",
|
|
631
|
+
content: text,
|
|
632
|
+
created_at: new Date().toISOString(),
|
|
633
|
+
};
|
|
634
|
+
setMessages((prev) => {
|
|
635
|
+
if (prev.some((m) => m.id === optimisticUser.id)) return prev;
|
|
636
|
+
return [...prev, optimisticUser];
|
|
637
|
+
});
|
|
638
|
+
setStreamingAssistantText("");
|
|
639
|
+
setLastTurnError(null);
|
|
640
|
+
|
|
641
|
+
const controller = new AbortController();
|
|
642
|
+
streamRef.current = { controller, turnId: null };
|
|
643
|
+
|
|
644
|
+
let accumulated = "";
|
|
645
|
+
// Streaming reasoning + tool-call accumulators — kept locally
|
|
646
|
+
// (closure-scoped) so we can flush them into the canonicalized
|
|
647
|
+
// message at end-of-stream OR clear them on cancel/error. Both
|
|
648
|
+
// mirror to React state so the live bubble renders while the turn
|
|
649
|
+
// runs.
|
|
650
|
+
let accumulatedReasoning = "";
|
|
651
|
+
const toolCallsByCallId = new Map<
|
|
652
|
+
string,
|
|
653
|
+
{
|
|
654
|
+
tool_name: string;
|
|
655
|
+
input: string;
|
|
656
|
+
status: "success" | "error";
|
|
657
|
+
output: string | null;
|
|
658
|
+
error: string | null;
|
|
659
|
+
}
|
|
660
|
+
>();
|
|
661
|
+
function snapshotToolCalls(): ReadonlyArray<AIHubToolCallRecord> {
|
|
662
|
+
const out: AIHubToolCallRecord[] = [];
|
|
663
|
+
for (const [callId, info] of toolCallsByCallId.entries()) {
|
|
664
|
+
out.push({
|
|
665
|
+
tool_call_id: callId,
|
|
666
|
+
tool_name: info.tool_name,
|
|
667
|
+
input: info.input,
|
|
668
|
+
status: info.status,
|
|
669
|
+
output: info.output,
|
|
670
|
+
error: info.error,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
return out;
|
|
674
|
+
}
|
|
675
|
+
setStreamingReasoning(null);
|
|
676
|
+
setStreamingToolCalls([]);
|
|
677
|
+
// Track outcome signals collected during the stream. Used after the
|
|
678
|
+
// loop to decide whether to:
|
|
679
|
+
// - canonicalize against Letta's history (replace optimistic
|
|
680
|
+
// state with the real one)
|
|
681
|
+
// - leave the optimistic state in place + surface an error
|
|
682
|
+
// - leave the optimistic state in place + stay silent (user
|
|
683
|
+
// cancelled — they didn't ask for an error)
|
|
684
|
+
let sawAssistantOutput = false;
|
|
685
|
+
let sawError = false;
|
|
686
|
+
let endReason: "done" | "cancelled" | "error" | null = null;
|
|
687
|
+
try {
|
|
688
|
+
const iter = client.streamChat(
|
|
689
|
+
{ sessionId, userMessage: text, effort },
|
|
690
|
+
{ signal: controller.signal },
|
|
691
|
+
);
|
|
692
|
+
for await (const event of iter) {
|
|
693
|
+
// Drive the §10 reducer (turn state).
|
|
694
|
+
setStreamState((prev) => reduceAIHubState(prev, event));
|
|
695
|
+
if (event.event === "stream_start") {
|
|
696
|
+
if (streamRef.current) {
|
|
697
|
+
streamRef.current.turnId = event.data.turn_id;
|
|
698
|
+
}
|
|
699
|
+
} else if (event.event === "assistant_delta") {
|
|
700
|
+
accumulated += event.data.delta;
|
|
701
|
+
setStreamingAssistantText(accumulated);
|
|
702
|
+
sawAssistantOutput = true;
|
|
703
|
+
} else if (event.event === "reasoning_delta") {
|
|
704
|
+
accumulatedReasoning += event.data.delta;
|
|
705
|
+
setStreamingReasoning(accumulatedReasoning);
|
|
706
|
+
} else if (event.event === "tool_call_started") {
|
|
707
|
+
const callId = event.data.tool_call_id;
|
|
708
|
+
if (!toolCallsByCallId.has(callId)) {
|
|
709
|
+
toolCallsByCallId.set(callId, {
|
|
710
|
+
tool_name: event.data.tool_name,
|
|
711
|
+
input: "",
|
|
712
|
+
status: "success",
|
|
713
|
+
output: null,
|
|
714
|
+
error: null,
|
|
715
|
+
});
|
|
716
|
+
setStreamingToolCalls(snapshotToolCalls());
|
|
717
|
+
}
|
|
718
|
+
} else if (event.event === "tool_call_input_delta") {
|
|
719
|
+
const info = toolCallsByCallId.get(event.data.tool_call_id);
|
|
720
|
+
if (info) {
|
|
721
|
+
info.input += event.data.partial_json;
|
|
722
|
+
setStreamingToolCalls(snapshotToolCalls());
|
|
723
|
+
}
|
|
724
|
+
} else if (event.event === "tool_call_dispatched") {
|
|
725
|
+
// Phase transition is handled by the reducer; we don't need to
|
|
726
|
+
// touch the accumulator (the input is already captured via
|
|
727
|
+
// tool_call_input_delta — `event.data.input` may be empty per
|
|
728
|
+
// the server's translator).
|
|
729
|
+
} else if (event.event === "tool_call_result") {
|
|
730
|
+
const info = toolCallsByCallId.get(event.data.tool_call_id);
|
|
731
|
+
if (info) {
|
|
732
|
+
if ("output" in event.data) {
|
|
733
|
+
info.status = "success";
|
|
734
|
+
info.output =
|
|
735
|
+
typeof event.data.output === "string"
|
|
736
|
+
? event.data.output
|
|
737
|
+
: JSON.stringify(event.data.output, null, 2);
|
|
738
|
+
info.error = null;
|
|
739
|
+
} else if ("error" in event.data) {
|
|
740
|
+
info.status = "error";
|
|
741
|
+
info.error =
|
|
742
|
+
typeof event.data.error === "string"
|
|
743
|
+
? event.data.error
|
|
744
|
+
: JSON.stringify(event.data.error, null, 2);
|
|
745
|
+
info.output = null;
|
|
746
|
+
}
|
|
747
|
+
setStreamingToolCalls(snapshotToolCalls());
|
|
748
|
+
}
|
|
749
|
+
} else if (event.event === "assistant_message_complete") {
|
|
750
|
+
// Dedupe — if `assistant_message_complete` fires twice for the
|
|
751
|
+
// same id (server quirk, hot-reload, SSE reconnect), append
|
|
752
|
+
// only the first occurrence. Enrich the final message with
|
|
753
|
+
// the streaming-accumulated reasoning + tool calls so the
|
|
754
|
+
// historical bubble carries the same data the live bubble
|
|
755
|
+
// showed (the server's `/sessions/:id/messages` projector
|
|
756
|
+
// also returns these fields, but we don't wait for a refetch).
|
|
757
|
+
const finalMsg: AIHubMessage = { ...event.data.message };
|
|
758
|
+
if (accumulatedReasoning.length > 0 && !finalMsg.reasoning) {
|
|
759
|
+
finalMsg.reasoning = accumulatedReasoning;
|
|
760
|
+
}
|
|
761
|
+
const calls = snapshotToolCalls();
|
|
762
|
+
if (calls.length > 0 && !finalMsg.tool_calls) {
|
|
763
|
+
finalMsg.tool_calls = [...calls];
|
|
764
|
+
}
|
|
765
|
+
setMessages((prev) =>
|
|
766
|
+
prev.some((m) => m.id === finalMsg.id) ? prev : [...prev, finalMsg],
|
|
767
|
+
);
|
|
768
|
+
accumulated = "";
|
|
769
|
+
accumulatedReasoning = "";
|
|
770
|
+
toolCallsByCallId.clear();
|
|
771
|
+
setStreamingAssistantText(null);
|
|
772
|
+
setStreamingReasoning(null);
|
|
773
|
+
setStreamingToolCalls([]);
|
|
774
|
+
sawAssistantOutput = true;
|
|
775
|
+
} else if (event.event === "usage") {
|
|
776
|
+
// Live usage stamp — the runner emits this right before
|
|
777
|
+
// `stream_end{done}` carrying the cache-hit/write counts for
|
|
778
|
+
// the turn. We attach it to the matching assistant bubble so
|
|
779
|
+
// `<CacheBadge />` appears immediately (without waiting for
|
|
780
|
+
// the post-stream `/messages` refetch). The refetch path
|
|
781
|
+
// also stamps the same `usage` from the persisted
|
|
782
|
+
// `message_usage` row, so refresh + history retrieval show
|
|
783
|
+
// the badge too.
|
|
784
|
+
const targetId = event.data.letta_message_id;
|
|
785
|
+
if (typeof targetId === "string") {
|
|
786
|
+
const usage = {
|
|
787
|
+
input: event.data.input ?? null,
|
|
788
|
+
output: event.data.output ?? null,
|
|
789
|
+
cache_read: event.data.cache_read ?? null,
|
|
790
|
+
cache_write: event.data.cache_write ?? null,
|
|
791
|
+
reasoning: event.data.reasoning ?? null,
|
|
792
|
+
context: event.data.context ?? null,
|
|
793
|
+
total: event.data.total ?? null,
|
|
794
|
+
};
|
|
795
|
+
setMessages((prev) =>
|
|
796
|
+
prev.map((m) => (m.id === targetId ? { ...m, usage } : m)),
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
} else if (event.event === "error") {
|
|
800
|
+
setLastTurnError(event.data.message);
|
|
801
|
+
sawError = true;
|
|
802
|
+
} else if (event.event === "stream_end") {
|
|
803
|
+
// Terminal — record the reason and clear the streaming
|
|
804
|
+
// accumulator. The final assistant message arrived via
|
|
805
|
+
// `assistant_message_complete` in a healthy stream; in
|
|
806
|
+
// `cancelled` / `error` outcomes the accumulator may carry
|
|
807
|
+
// partial text the UI still wants to show, so we flush it
|
|
808
|
+
// into a synthetic message before clearing.
|
|
809
|
+
endReason = event.data.reason;
|
|
810
|
+
if (accumulated.length > 0) {
|
|
811
|
+
const partialId = `partial-${streamRef.current?.turnId ?? Date.now()}`;
|
|
812
|
+
const partialMsg: AIHubMessage = {
|
|
813
|
+
id: partialId,
|
|
814
|
+
role: "assistant",
|
|
815
|
+
content: accumulated,
|
|
816
|
+
created_at: new Date().toISOString(),
|
|
817
|
+
};
|
|
818
|
+
if (accumulatedReasoning.length > 0) {
|
|
819
|
+
partialMsg.reasoning = accumulatedReasoning;
|
|
820
|
+
}
|
|
821
|
+
const calls = snapshotToolCalls();
|
|
822
|
+
if (calls.length > 0) {
|
|
823
|
+
partialMsg.tool_calls = [...calls];
|
|
824
|
+
}
|
|
825
|
+
setMessages((prev) => [...prev, partialMsg]);
|
|
826
|
+
accumulated = "";
|
|
827
|
+
sawAssistantOutput = true;
|
|
828
|
+
}
|
|
829
|
+
accumulatedReasoning = "";
|
|
830
|
+
toolCallsByCallId.clear();
|
|
831
|
+
setStreamingAssistantText(null);
|
|
832
|
+
setStreamingReasoning(null);
|
|
833
|
+
setStreamingToolCalls([]);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
} catch (err) {
|
|
837
|
+
// Aborts (from `stop()` or a session switch) are not errors. We
|
|
838
|
+
// record the outcome explicitly so the post-stream logic below
|
|
839
|
+
// can canonicalize Letta's history (the server may have persisted
|
|
840
|
+
// a partial response before the cancel landed). Anything else is
|
|
841
|
+
// a genuine transport failure.
|
|
842
|
+
if (controller.signal.aborted) {
|
|
843
|
+
endReason = "cancelled";
|
|
844
|
+
setStreamingAssistantText(null);
|
|
845
|
+
setStreamingReasoning(null);
|
|
846
|
+
setStreamingToolCalls([]);
|
|
847
|
+
accumulatedReasoning = "";
|
|
848
|
+
toolCallsByCallId.clear();
|
|
849
|
+
// Transition state out of `running` so the input re-enables
|
|
850
|
+
// immediately. The reducer normally drives this off the server's
|
|
851
|
+
// `(status, phase)` snapshot on `stream_end`, but the abort
|
|
852
|
+
// severed the connection before that event arrived.
|
|
853
|
+
setStreamState((s) => ({ ...s, status: "cancelled", phase: null }));
|
|
854
|
+
} else {
|
|
855
|
+
const msg =
|
|
856
|
+
err instanceof Error ? err.message : "Stream failed.";
|
|
857
|
+
setLastTurnError(msg);
|
|
858
|
+
setStreamingAssistantText(null);
|
|
859
|
+
setStreamingReasoning(null);
|
|
860
|
+
setStreamingToolCalls([]);
|
|
861
|
+
accumulatedReasoning = "";
|
|
862
|
+
toolCallsByCallId.clear();
|
|
863
|
+
if (err instanceof AIHubRequestError) {
|
|
864
|
+
onError?.({
|
|
865
|
+
code: err.code,
|
|
866
|
+
message: err.message,
|
|
867
|
+
...(err.status !== undefined ? { status: err.status } : {}),
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
} finally {
|
|
872
|
+
if (streamRef.current?.controller === controller) {
|
|
873
|
+
streamRef.current = null;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Post-stream housekeeping — three branches, gated on the outcome
|
|
878
|
+
// recorded during the loop / catch:
|
|
879
|
+
//
|
|
880
|
+
// - `cancelled` — user pressed Stop (or switched sessions). Don't
|
|
881
|
+
// show an error; canonicalize so any partial
|
|
882
|
+
// response the agent finished generating before
|
|
883
|
+
// the cancel landed shows up properly.
|
|
884
|
+
// - `error` — server emitted an explicit error event during
|
|
885
|
+
// the turn. `lastTurnError` already carries the
|
|
886
|
+
// specific message; don't canonicalize (the
|
|
887
|
+
// server's history may be empty / partial and
|
|
888
|
+
// overwriting `messages` would erase the
|
|
889
|
+
// optimistic user bubble).
|
|
890
|
+
// - `done` / unknown — happy path OR "stream ended cleanly without
|
|
891
|
+
// producing assistant output" (e.g. an upstream
|
|
892
|
+
// agent-configuration problem returning an empty
|
|
893
|
+
// completion). Canonicalize on real output;
|
|
894
|
+
// otherwise surface a generic user-facing error
|
|
895
|
+
// pointing at the administrator.
|
|
896
|
+
if (endReason === "cancelled") {
|
|
897
|
+
try {
|
|
898
|
+
const canonical = await client.getSessionMessages(sessionId);
|
|
899
|
+
if (activeSessionIdRef.current === sessionId) {
|
|
900
|
+
const merged = mergeOptimisticAffordances(
|
|
901
|
+
canonical,
|
|
902
|
+
messagesRef.current,
|
|
903
|
+
);
|
|
904
|
+
setMessages(merged);
|
|
905
|
+
// Rollback detection: when Letta's authoritative cancel
|
|
906
|
+
// rolled back the in-progress step AND there were no
|
|
907
|
+
// previously-committed steps, the user's message disappears
|
|
908
|
+
// from history entirely (single-step cancel — empirically
|
|
909
|
+
// verified). Test by content (the rolled-back optimistic
|
|
910
|
+
// message had `content === text`; if no canonical user
|
|
911
|
+
// message carries that content, it was rolled back). The
|
|
912
|
+
// earlier `length <= baseline` check was right in the happy
|
|
913
|
+
// case but missed scenarios where the session already had
|
|
914
|
+
// sibling rows (e.g. memory_metadata) whose count made the
|
|
915
|
+
// comparison wobbly.
|
|
916
|
+
const trimmed = text.trim();
|
|
917
|
+
const userMessagePreserved = canonical.some(
|
|
918
|
+
(m) => m.role === "user" && m.content.trim() === trimmed,
|
|
919
|
+
);
|
|
920
|
+
if (!userMessagePreserved) {
|
|
921
|
+
setPendingDraft(text);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
} catch {
|
|
925
|
+
// Canonical refetch failed (network blip while the user was
|
|
926
|
+
// cancelling). Restore the typed text so they can retry; we'd
|
|
927
|
+
// rather over-restore than lose the input.
|
|
928
|
+
setPendingDraft(text);
|
|
929
|
+
}
|
|
930
|
+
setIsStoppingTurn(false);
|
|
931
|
+
} else if (sawError) {
|
|
932
|
+
/* `lastTurnError` already set in the loop — do nothing */
|
|
933
|
+
setIsStoppingTurn(false);
|
|
934
|
+
} else if (!sawAssistantOutput) {
|
|
935
|
+
// The stream finished but produced nothing renderable. Surface a
|
|
936
|
+
// user-facing error — kept generic on purpose (no upstream
|
|
937
|
+
// implementation names; users are pointed at the operator).
|
|
938
|
+
setLastTurnError(
|
|
939
|
+
"The agent didn't produce a response. Contact your administrator if this persists.",
|
|
940
|
+
);
|
|
941
|
+
setIsStoppingTurn(false);
|
|
942
|
+
} else if (!controller.signal.aborted) {
|
|
943
|
+
try {
|
|
944
|
+
const canonical = await client.getSessionMessages(sessionId);
|
|
945
|
+
if (activeSessionIdRef.current === sessionId) {
|
|
946
|
+
// Merge optimistic reasoning + tool_calls into the canonical
|
|
947
|
+
// list: the streaming accumulators captured rich data the
|
|
948
|
+
// server's projector might not yet (Letta's cross-row
|
|
949
|
+
// aggregation has a small window where the
|
|
950
|
+
// tool_return_message hasn't been committed yet, or the
|
|
951
|
+
// operator is running an older server build without the
|
|
952
|
+
// §10.x projector). Preserves the affordances either way.
|
|
953
|
+
const merged = mergeOptimisticAffordances(
|
|
954
|
+
canonical,
|
|
955
|
+
messagesRef.current,
|
|
956
|
+
);
|
|
957
|
+
setMessages(merged);
|
|
958
|
+
}
|
|
959
|
+
} catch {
|
|
960
|
+
/* keep optimistic state; user can refresh */
|
|
961
|
+
}
|
|
962
|
+
setIsStoppingTurn(false);
|
|
963
|
+
} else {
|
|
964
|
+
setIsStoppingTurn(false);
|
|
965
|
+
}
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
const stop: AIHubContextValue["stop"] = async () => {
|
|
969
|
+
const active = streamRef.current;
|
|
970
|
+
if (!active) return;
|
|
971
|
+
const { turnId } = active;
|
|
972
|
+
// The right design here is the OPPOSITE of "abort the iterator":
|
|
973
|
+
// we want the server's authoritative cancel (Letta's
|
|
974
|
+
// `agents.messages.cancel`, fired by `/chat/:id/cancel`) to wind
|
|
975
|
+
// the run down server-side, then deliver `stream_end{cancelled}`
|
|
976
|
+
// naturally on the SSE stream. The post-stream-end branch in
|
|
977
|
+
// `sendMessage` then canonicalizes against Letta's history — which
|
|
978
|
+
// correctly shows the rolled-back state (in-progress step gone;
|
|
979
|
+
// completed steps preserved).
|
|
980
|
+
//
|
|
981
|
+
// Aborting the iterator here would race the cancel: the connection
|
|
982
|
+
// closes before the server can deliver `stream_end{cancelled}`,
|
|
983
|
+
// and we'd lose the chance to capture the canonical end-state
|
|
984
|
+
// through the established post-stream path.
|
|
985
|
+
//
|
|
986
|
+
// The UX gap (network round-trip to fire the cancel + Letta
|
|
987
|
+
// processing time, typically ~300ms) is covered by
|
|
988
|
+
// `isStoppingTurn` — `<MessageInput>` shows "Stopping…" and keeps
|
|
989
|
+
// the input disabled. We don't need a hard-abort timeout for the
|
|
990
|
+
// common case; even a slow Letta cancel resolves in low seconds.
|
|
991
|
+
// If the cancel POST itself fails the runner's local AbortSignal
|
|
992
|
+
// (fired server-side as fallback) still produces `stream_end`.
|
|
993
|
+
setIsStoppingTurn(true);
|
|
994
|
+
if (turnId) {
|
|
995
|
+
try {
|
|
996
|
+
await client.cancelTurn(turnId);
|
|
997
|
+
} catch {
|
|
998
|
+
// Ignored — the server's `/cancel` handler also fires the
|
|
999
|
+
// local registry abort, so the runner will emit `stream_end`
|
|
1000
|
+
// even if our POST hit a transient failure.
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
const themeSwitcherEnabled = deployment?.theme_switcher_enabled ?? true;
|
|
1006
|
+
const role: AIHubRole | null = user?.role ?? null;
|
|
1007
|
+
|
|
1008
|
+
// Refresh the feature toggles on demand — used after the super-admin
|
|
1009
|
+
// settings page saves a toggle change (Step 12), so the calling
|
|
1010
|
+
// super_admin sees the effect without a page reload.
|
|
1011
|
+
const refreshFeatureToggles = useCallback(async (): Promise<void> => {
|
|
1012
|
+
try {
|
|
1013
|
+
const toggles = await client.getFeatureToggles();
|
|
1014
|
+
setFeatureToggles(toggles);
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
if (err instanceof AIHubRequestError) {
|
|
1017
|
+
onError?.({
|
|
1018
|
+
code: err.code,
|
|
1019
|
+
message: err.message,
|
|
1020
|
+
...(err.status !== undefined ? { status: err.status } : {}),
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}, [client, onError]);
|
|
1025
|
+
|
|
1026
|
+
const value: AIHubContextValue = {
|
|
1027
|
+
client,
|
|
1028
|
+
user,
|
|
1029
|
+
role,
|
|
1030
|
+
deployment,
|
|
1031
|
+
mustChangePassword,
|
|
1032
|
+
hydrating,
|
|
1033
|
+
login,
|
|
1034
|
+
logout,
|
|
1035
|
+
changePassword,
|
|
1036
|
+
state: streamState,
|
|
1037
|
+
effort,
|
|
1038
|
+
setEffort,
|
|
1039
|
+
theme,
|
|
1040
|
+
setTheme,
|
|
1041
|
+
themeSwitcherEnabled,
|
|
1042
|
+
agents,
|
|
1043
|
+
agentsLoading,
|
|
1044
|
+
refreshAgents,
|
|
1045
|
+
activeAgentId,
|
|
1046
|
+
setActiveAgent,
|
|
1047
|
+
sessions,
|
|
1048
|
+
sessionsLoading,
|
|
1049
|
+
refreshSessions,
|
|
1050
|
+
includeArchivedSessions,
|
|
1051
|
+
setIncludeArchivedSessions,
|
|
1052
|
+
activeSessionId,
|
|
1053
|
+
setActiveSession,
|
|
1054
|
+
createSession,
|
|
1055
|
+
patchSession,
|
|
1056
|
+
messages,
|
|
1057
|
+
messagesLoading,
|
|
1058
|
+
streamingAssistantText,
|
|
1059
|
+
streamingReasoning,
|
|
1060
|
+
streamingToolCalls,
|
|
1061
|
+
sendMessage,
|
|
1062
|
+
stop,
|
|
1063
|
+
isStoppingTurn,
|
|
1064
|
+
pendingDraft,
|
|
1065
|
+
consumePendingDraft: () => {
|
|
1066
|
+
const draft = pendingDraft;
|
|
1067
|
+
if (draft !== null) setPendingDraft(null);
|
|
1068
|
+
return draft;
|
|
1069
|
+
},
|
|
1070
|
+
lastTurnError,
|
|
1071
|
+
featureToggles,
|
|
1072
|
+
refreshFeatureToggles,
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
return (
|
|
1076
|
+
<aiHubContext.Provider value={value}>
|
|
1077
|
+
<div className="data-club-ai-hub" data-theme={theme}>
|
|
1078
|
+
{children}
|
|
1079
|
+
</div>
|
|
1080
|
+
</aiHubContext.Provider>
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
// `PatchSessionOptions` is re-exported elsewhere; keep the import live.
|
|
1084
|
+
export type { PatchSessionOptions };
|