@solidxai/core-ui 0.1.5-beta.9 → 0.1.7-beta.0
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/components/auth/AuthTabs.d.ts +14 -0
- package/dist/components/auth/AuthTabs.d.ts.map +1 -0
- package/dist/components/auth/AuthTabs.js +19 -0
- package/dist/components/auth/AuthTabs.js.map +1 -0
- package/dist/components/auth/AuthTabs.tsx +38 -0
- package/dist/components/auth/GoogleAuthChecking.d.ts.map +1 -1
- package/dist/components/auth/GoogleAuthChecking.js +10 -10
- package/dist/components/auth/GoogleAuthChecking.js.map +1 -1
- package/dist/components/auth/GoogleAuthChecking.tsx +8 -9
- package/dist/components/auth/SolidChangeForcePassword.d.ts.map +1 -1
- package/dist/components/auth/SolidChangeForcePassword.js +9 -10
- package/dist/components/auth/SolidChangeForcePassword.js.map +1 -1
- package/dist/components/auth/SolidChangeForcePassword.tsx +6 -9
- package/dist/components/auth/SolidForgotPassword.d.ts.map +1 -1
- package/dist/components/auth/SolidForgotPassword.js +8 -8
- package/dist/components/auth/SolidForgotPassword.js.map +1 -1
- package/dist/components/auth/SolidForgotPassword.tsx +6 -8
- package/dist/components/auth/SolidInitialLoginOtp.d.ts.map +1 -1
- package/dist/components/auth/SolidInitialLoginOtp.js +57 -57
- package/dist/components/auth/SolidInitialLoginOtp.js.map +1 -1
- package/dist/components/auth/SolidInitialLoginOtp.tsx +10 -11
- package/dist/components/auth/SolidInitiateRegisterOtp.d.ts.map +1 -1
- package/dist/components/auth/SolidInitiateRegisterOtp.js +57 -57
- package/dist/components/auth/SolidInitiateRegisterOtp.js.map +1 -1
- package/dist/components/auth/SolidInitiateRegisterOtp.tsx +10 -11
- package/dist/components/auth/SolidLogin.d.ts.map +1 -1
- package/dist/components/auth/SolidLogin.js +12 -12
- package/dist/components/auth/SolidLogin.js.map +1 -1
- package/dist/components/auth/SolidLogin.tsx +11 -16
- package/dist/components/auth/SolidRegister.d.ts.map +1 -1
- package/dist/components/auth/SolidRegister.js +19 -19
- package/dist/components/auth/SolidRegister.js.map +1 -1
- package/dist/components/auth/SolidRegister.tsx +18 -23
- package/dist/components/auth/SolidResetPassword.d.ts.map +1 -1
- package/dist/components/auth/SolidResetPassword.js +15 -15
- package/dist/components/auth/SolidResetPassword.js.map +1 -1
- package/dist/components/auth/SolidResetPassword.tsx +7 -8
- package/dist/components/common/GeneralSettings.d.ts.map +1 -1
- package/dist/components/common/GeneralSettings.js +87 -120
- package/dist/components/common/GeneralSettings.js.map +1 -1
- package/dist/components/common/GeneralSettings.tsx +12 -42
- package/dist/components/common/GlobalToast.d.ts +2 -0
- package/dist/components/common/GlobalToast.d.ts.map +1 -0
- package/dist/components/common/GlobalToast.js +25 -0
- package/dist/components/common/GlobalToast.js.map +1 -0
- package/dist/components/common/GlobalToast.tsx +25 -0
- package/dist/components/common/SolidErrorStatePage.d.ts +12 -0
- package/dist/components/common/SolidErrorStatePage.d.ts.map +1 -0
- package/dist/components/common/SolidErrorStatePage.js +16 -0
- package/dist/components/common/SolidErrorStatePage.js.map +1 -0
- package/dist/components/common/SolidErrorStatePage.tsx +55 -0
- package/dist/components/common/SolidExport.d.ts.map +1 -1
- package/dist/components/common/SolidExport.js +19 -31
- package/dist/components/common/SolidExport.js.map +1 -1
- package/dist/components/common/SolidExport.tsx +7 -19
- package/dist/components/common/SolidFormStepper.d.ts.map +1 -1
- package/dist/components/common/SolidFormStepper.js +41 -41
- package/dist/components/common/SolidFormStepper.js.map +1 -1
- package/dist/components/common/SolidFormStepper.tsx +5 -6
- package/dist/components/core/common/SolidAccountSettings/SolidChangePassword.d.ts.map +1 -1
- package/dist/components/core/common/SolidAccountSettings/SolidChangePassword.js +9 -9
- package/dist/components/core/common/SolidAccountSettings/SolidChangePassword.js.map +1 -1
- package/dist/components/core/common/SolidAccountSettings/SolidChangePassword.tsx +8 -9
- package/dist/components/core/common/SolidAccountSettings/SolidNotifications.d.ts.map +1 -1
- package/dist/components/core/common/SolidAccountSettings/SolidNotifications.js +8 -8
- package/dist/components/core/common/SolidAccountSettings/SolidNotifications.js.map +1 -1
- package/dist/components/core/common/SolidAccountSettings/SolidNotifications.tsx +7 -9
- package/dist/components/core/common/SolidAccountSettings/SolidPersonalInfo.d.ts.map +1 -1
- package/dist/components/core/common/SolidAccountSettings/SolidPersonalInfo.js +11 -11
- package/dist/components/core/common/SolidAccountSettings/SolidPersonalInfo.js.map +1 -1
- package/dist/components/core/common/SolidAccountSettings/SolidPersonalInfo.tsx +10 -11
- package/dist/components/core/common/SolidGenericImport/SolidImportDropzone.d.ts.map +1 -1
- package/dist/components/core/common/SolidGenericImport/SolidImportDropzone.js +9 -9
- package/dist/components/core/common/SolidGenericImport/SolidImportDropzone.js.map +1 -1
- package/dist/components/core/common/SolidGenericImport/SolidImportDropzone.tsx +8 -9
- package/dist/components/core/common/SolidGenericImport/SolidImportTransaction.d.ts.map +1 -1
- package/dist/components/core/common/SolidGenericImport/SolidImportTransaction.js +7 -7
- package/dist/components/core/common/SolidGenericImport/SolidImportTransaction.js.map +1 -1
- package/dist/components/core/common/SolidGenericImport/SolidImportTransaction.tsx +6 -7
- package/dist/components/core/common/SolidGlobalSearchElement.d.ts.map +1 -1
- package/dist/components/core/common/SolidGlobalSearchElement.js +78 -21
- package/dist/components/core/common/SolidGlobalSearchElement.js.map +1 -1
- package/dist/components/core/common/SolidGlobalSearchElement.tsx +65 -10
- package/dist/components/core/extension/solid-core/modelMetadata/list/DeleteModelRowAction.d.ts.map +1 -1
- package/dist/components/core/extension/solid-core/modelMetadata/list/DeleteModelRowAction.js +6 -8
- package/dist/components/core/extension/solid-core/modelMetadata/list/DeleteModelRowAction.js.map +1 -1
- package/dist/components/core/extension/solid-core/modelMetadata/list/DeleteModelRowAction.tsx +5 -9
- package/dist/components/core/extension/solid-core/modelMetadata/list/GenerateModelCodeRowAction.d.ts.map +1 -1
- package/dist/components/core/extension/solid-core/modelMetadata/list/GenerateModelCodeRowAction.js +9 -11
- package/dist/components/core/extension/solid-core/modelMetadata/list/GenerateModelCodeRowAction.js.map +1 -1
- package/dist/components/core/extension/solid-core/modelMetadata/list/GenerateModelCodeRowAction.tsx +5 -10
- package/dist/components/core/extension/solid-core/moduleMetadata/list/DeleteModuleRowAction.d.ts.map +1 -1
- package/dist/components/core/extension/solid-core/moduleMetadata/list/DeleteModuleRowAction.js +5 -6
- package/dist/components/core/extension/solid-core/moduleMetadata/list/DeleteModuleRowAction.js.map +1 -1
- package/dist/components/core/extension/solid-core/moduleMetadata/list/DeleteModuleRowAction.tsx +5 -8
- package/dist/components/core/extension/solid-core/moduleMetadata/list/GenerateModuleCodeRowAction.d.ts.map +1 -1
- package/dist/components/core/extension/solid-core/moduleMetadata/list/GenerateModuleCodeRowAction.js +10 -12
- package/dist/components/core/extension/solid-core/moduleMetadata/list/GenerateModuleCodeRowAction.js.map +1 -1
- package/dist/components/core/extension/solid-core/moduleMetadata/list/GenerateModuleCodeRowAction.tsx +6 -11
- package/dist/components/core/extension/solid-core/roleMetadata/RolePermissionsManyToManyFieldWidget.d.ts.map +1 -1
- package/dist/components/core/extension/solid-core/roleMetadata/RolePermissionsManyToManyFieldWidget.js +11 -6
- package/dist/components/core/extension/solid-core/roleMetadata/RolePermissionsManyToManyFieldWidget.js.map +1 -1
- package/dist/components/core/extension/solid-core/roleMetadata/RolePermissionsManyToManyFieldWidget.tsx +13 -1
- package/dist/components/core/form/SolidFormUserViewLayout.d.ts.map +1 -1
- package/dist/components/core/form/SolidFormUserViewLayout.js +7 -8
- package/dist/components/core/form/SolidFormUserViewLayout.js.map +1 -1
- package/dist/components/core/form/SolidFormUserViewLayout.tsx +5 -7
- package/dist/components/core/form/SolidFormView.d.ts.map +1 -1
- package/dist/components/core/form/SolidFormView.js +12 -21
- package/dist/components/core/form/SolidFormView.js.map +1 -1
- package/dist/components/core/form/SolidFormView.tsx +11 -33
- package/dist/components/core/form/fields/SolidMediaSingleField.d.ts.map +1 -1
- package/dist/components/core/form/fields/SolidMediaSingleField.js +22 -15
- package/dist/components/core/form/fields/SolidMediaSingleField.js.map +1 -1
- package/dist/components/core/form/fields/SolidMediaSingleField.tsx +14 -2
- package/dist/components/core/form/fields/SolidTimeField.d.ts.map +1 -1
- package/dist/components/core/form/fields/SolidTimeField.js +4 -2
- package/dist/components/core/form/fields/SolidTimeField.js.map +1 -1
- package/dist/components/core/form/fields/SolidTimeField.tsx +5 -5
- package/dist/components/core/form/fields/relations/SolidRelationManyToManyField.d.ts.map +1 -1
- package/dist/components/core/form/fields/relations/SolidRelationManyToManyField.js +37 -26
- package/dist/components/core/form/fields/relations/SolidRelationManyToManyField.js.map +1 -1
- package/dist/components/core/form/fields/relations/SolidRelationManyToManyField.tsx +38 -5
- package/dist/components/core/form/fields/relations/SolidRelationManyToOneField.d.ts.map +1 -1
- package/dist/components/core/form/fields/relations/SolidRelationManyToOneField.js +11 -11
- package/dist/components/core/form/fields/relations/SolidRelationManyToOneField.js.map +1 -1
- package/dist/components/core/form/fields/relations/SolidRelationManyToOneField.tsx +9 -11
- package/dist/components/core/kanban/KanbanUserViewLayout.d.ts.map +1 -1
- package/dist/components/core/kanban/KanbanUserViewLayout.js +7 -8
- package/dist/components/core/kanban/KanbanUserViewLayout.js.map +1 -1
- package/dist/components/core/kanban/KanbanUserViewLayout.tsx +4 -6
- package/dist/components/core/kanban/SolidKanbanView.d.ts.map +1 -1
- package/dist/components/core/kanban/SolidKanbanView.js +5 -7
- package/dist/components/core/kanban/SolidKanbanView.js.map +1 -1
- package/dist/components/core/kanban/SolidKanbanView.tsx +4 -9
- package/dist/components/core/list/PLAN.md +92 -0
- package/dist/components/core/list/SolidColumnSelector/SolidListColumnSelector.d.ts.map +1 -1
- package/dist/components/core/list/SolidColumnSelector/SolidListColumnSelector.js +13 -13
- package/dist/components/core/list/SolidColumnSelector/SolidListColumnSelector.js.map +1 -1
- package/dist/components/core/list/SolidColumnSelector/SolidListColumnSelector.tsx +5 -6
- package/dist/components/core/list/SolidDataTable.d.ts +58 -0
- package/dist/components/core/list/SolidDataTable.d.ts.map +1 -0
- package/dist/components/core/list/SolidDataTable.js +141 -0
- package/dist/components/core/list/SolidDataTable.js.map +1 -0
- package/dist/components/core/list/SolidDataTable.tsx +314 -0
- package/dist/components/core/list/SolidListView.d.ts.map +1 -1
- package/dist/components/core/list/SolidListView.js +20 -67
- package/dist/components/core/list/SolidListView.js.map +1 -1
- package/dist/components/core/list/SolidListView.tsx +9 -76
- package/dist/components/core/model/CreateModel.d.ts.map +1 -1
- package/dist/components/core/model/CreateModel.js +15 -25
- package/dist/components/core/model/CreateModel.js.map +1 -1
- package/dist/components/core/model/CreateModel.tsx +12 -32
- package/dist/components/core/model/FieldMetaData.d.ts.map +1 -1
- package/dist/components/core/model/FieldMetaData.js +6 -17
- package/dist/components/core/model/FieldMetaData.js.map +1 -1
- package/dist/components/core/model/FieldMetaData.tsx +5 -26
- package/dist/components/core/model/ModelMetaData.d.ts.map +1 -1
- package/dist/components/core/model/ModelMetaData.js +48 -55
- package/dist/components/core/model/ModelMetaData.js.map +1 -1
- package/dist/components/core/model/ModelMetaData.tsx +4 -22
- package/dist/components/core/module/CreateModule.d.ts.map +1 -1
- package/dist/components/core/module/CreateModule.js +42 -44
- package/dist/components/core/module/CreateModule.js.map +1 -1
- package/dist/components/core/module/CreateModule.tsx +13 -27
- package/dist/components/core/module/ModuleListViewData.d.ts.map +1 -1
- package/dist/components/core/module/ModuleListViewData.js +11 -7
- package/dist/components/core/module/ModuleListViewData.js.map +1 -1
- package/dist/components/core/module/ModuleListViewData.tsx +10 -8
- package/dist/components/core/solid-ai/SolidAiChat.d.ts +3 -0
- package/dist/components/core/solid-ai/SolidAiChat.d.ts.map +1 -0
- package/dist/components/core/solid-ai/SolidAiChat.js +1043 -0
- package/dist/components/core/solid-ai/SolidAiChat.js.map +1 -0
- package/dist/components/core/solid-ai/SolidAiChat.module.css +1339 -0
- package/dist/components/core/solid-ai/SolidAiChat.tsx +1237 -0
- package/dist/components/core/tree/SolidTreeView.d.ts.map +1 -1
- package/dist/components/core/tree/SolidTreeView.js +32 -69
- package/dist/components/core/tree/SolidTreeView.js.map +1 -1
- package/dist/components/core/tree/SolidTreeView.tsx +8 -47
- package/dist/components/core/users/CreateUser.d.ts.map +1 -1
- package/dist/components/core/users/CreateUser.js +24 -37
- package/dist/components/core/users/CreateUser.js.map +1 -1
- package/dist/components/core/users/CreateUser.tsx +8 -46
- package/dist/components/core/users/UserListView.d.ts.map +1 -1
- package/dist/components/core/users/UserListView.js +9 -16
- package/dist/components/core/users/UserListView.js.map +1 -1
- package/dist/components/core/users/UserListView.tsx +5 -21
- package/dist/components/layout/AdminLayout.d.ts.map +1 -1
- package/dist/components/layout/AdminLayout.js +4 -2
- package/dist/components/layout/AdminLayout.js.map +1 -1
- package/dist/components/layout/AdminLayout.tsx +4 -2
- package/dist/components/layout/AdminTopHeader.d.ts +2 -0
- package/dist/components/layout/AdminTopHeader.d.ts.map +1 -0
- package/dist/components/layout/AdminTopHeader.js +80 -0
- package/dist/components/layout/AdminTopHeader.js.map +1 -0
- package/dist/components/layout/AdminTopHeader.tsx +165 -0
- package/dist/components/layout/AppSidebar.d.ts.map +1 -1
- package/dist/components/layout/AppSidebar.js +1 -2
- package/dist/components/layout/AppSidebar.js.map +1 -1
- package/dist/components/layout/AppSidebar.tsx +0 -2
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +2 -1
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/layout/Layout.tsx +2 -0
- package/dist/components/layout/SolidAiStudioLayout.d.ts +10 -0
- package/dist/components/layout/SolidAiStudioLayout.d.ts.map +1 -0
- package/dist/components/layout/SolidAiStudioLayout.js +159 -0
- package/dist/components/layout/SolidAiStudioLayout.js.map +1 -0
- package/dist/components/layout/SolidAiStudioLayout.tsx +333 -0
- package/dist/components/layout/navbar-one.d.ts.map +1 -1
- package/dist/components/layout/navbar-one.js +1 -2
- package/dist/components/layout/navbar-one.js.map +1 -1
- package/dist/components/layout/navbar-one.tsx +0 -2
- package/dist/components/layout/navbar-two-menu.d.ts.map +1 -1
- package/dist/components/layout/navbar-two-menu.js +50 -24
- package/dist/components/layout/navbar-two-menu.js.map +1 -1
- package/dist/components/layout/navbar-two-menu.tsx +48 -30
- package/dist/components/shad-cn-ui/SolidAutocomplete.d.ts +24 -0
- package/dist/components/shad-cn-ui/SolidAutocomplete.d.ts.map +1 -0
- package/dist/components/shad-cn-ui/SolidAutocomplete.js +224 -0
- package/dist/components/shad-cn-ui/SolidAutocomplete.js.map +1 -0
- package/dist/components/shad-cn-ui/SolidAutocomplete.tsx +339 -0
- package/dist/components/shad-cn-ui/SolidButton.d.ts +14 -0
- package/dist/components/shad-cn-ui/SolidButton.d.ts.map +1 -0
- package/dist/components/shad-cn-ui/SolidButton.js +36 -0
- package/dist/components/shad-cn-ui/SolidButton.js.map +1 -0
- package/dist/components/shad-cn-ui/SolidButton.tsx +54 -0
- package/dist/components/shad-cn-ui/SolidInput.d.ts +5 -0
- package/dist/components/shad-cn-ui/SolidInput.d.ts.map +1 -0
- package/dist/components/shad-cn-ui/SolidInput.js +35 -0
- package/dist/components/shad-cn-ui/SolidInput.js.map +1 -0
- package/dist/components/shad-cn-ui/SolidInput.tsx +12 -0
- package/dist/components/shad-cn-ui/SolidNumberInput.d.ts +10 -0
- package/dist/components/shad-cn-ui/SolidNumberInput.d.ts.map +1 -0
- package/dist/components/shad-cn-ui/SolidNumberInput.js +33 -0
- package/dist/components/shad-cn-ui/SolidNumberInput.js.map +1 -0
- package/dist/components/shad-cn-ui/SolidNumberInput.tsx +24 -0
- package/dist/components/shad-cn-ui/SolidSelect.d.ts +16 -0
- package/dist/components/shad-cn-ui/SolidSelect.d.ts.map +1 -0
- package/dist/components/shad-cn-ui/SolidSelect.js +26 -0
- package/dist/components/shad-cn-ui/SolidSelect.js.map +1 -0
- package/dist/components/shad-cn-ui/SolidSelect.tsx +65 -0
- package/dist/components/shad-cn-ui/SolidTabs.d.ts +18 -0
- package/dist/components/shad-cn-ui/SolidTabs.d.ts.map +1 -0
- package/dist/components/shad-cn-ui/SolidTabs.js +22 -0
- package/dist/components/shad-cn-ui/SolidTabs.js.map +1 -0
- package/dist/components/shad-cn-ui/SolidTabs.tsx +73 -0
- package/dist/components/shad-cn-ui/index.d.ts +7 -0
- package/dist/components/shad-cn-ui/index.d.ts.map +1 -0
- package/dist/components/shad-cn-ui/index.js +7 -0
- package/dist/components/shad-cn-ui/index.js.map +1 -0
- package/dist/components/shad-cn-ui/index.ts +6 -0
- package/dist/helpers/studioSandbox.d.ts +31 -0
- package/dist/helpers/studioSandbox.d.ts.map +1 -0
- package/dist/helpers/studioSandbox.js +104 -0
- package/dist/helpers/studioSandbox.js.map +1 -0
- package/dist/helpers/studioSandbox.ts +117 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/index.ts +4 -0
- package/dist/redux/features/solidStudioSlice.d.ts +9 -0
- package/dist/redux/features/solidStudioSlice.d.ts.map +1 -0
- package/dist/redux/features/solidStudioSlice.js +72 -0
- package/dist/redux/features/solidStudioSlice.js.map +1 -0
- package/dist/redux/features/solidStudioSlice.ts +78 -0
- package/dist/redux/features/toastSlice.d.ts +15 -0
- package/dist/redux/features/toastSlice.d.ts.map +1 -0
- package/dist/redux/features/toastSlice.js +20 -0
- package/dist/redux/features/toastSlice.js.map +1 -0
- package/dist/redux/features/toastSlice.ts +35 -0
- package/dist/redux/store/defaultStoreConfig.d.ts +1 -0
- package/dist/redux/store/defaultStoreConfig.d.ts.map +1 -1
- package/dist/redux/store/defaultStoreConfig.js +2 -1
- package/dist/redux/store/defaultStoreConfig.js.map +1 -1
- package/dist/redux/store/defaultStoreConfig.ts +2 -0
- package/dist/resources/images/errors/error-astronaut-404.png +0 -0
- package/dist/resources/shadcn-base.css +4171 -0
- package/dist/routes/SolidLayoutRegistry.d.ts +51 -0
- package/dist/routes/SolidLayoutRegistry.d.ts.map +1 -0
- package/dist/routes/SolidLayoutRegistry.js +108 -0
- package/dist/routes/SolidLayoutRegistry.js.map +1 -0
- package/dist/routes/SolidLayoutRegistry.tsx +157 -0
- package/dist/routes/guards/AdminGuard.d.ts +2 -0
- package/dist/routes/guards/AdminGuard.d.ts.map +1 -0
- package/dist/routes/guards/AdminGuard.js +16 -0
- package/dist/routes/guards/AdminGuard.js.map +1 -0
- package/dist/routes/guards/AdminGuard.tsx +17 -0
- package/dist/routes/pages/studio/StudioHomePage.d.ts +2 -0
- package/dist/routes/pages/studio/StudioHomePage.d.ts.map +1 -0
- package/dist/routes/pages/studio/StudioHomePage.js +22 -0
- package/dist/routes/pages/studio/StudioHomePage.js.map +1 -0
- package/dist/routes/pages/studio/StudioHomePage.tsx +106 -0
- package/dist/routes/pages/studio/StudioLandingPage.d.ts +2 -0
- package/dist/routes/pages/studio/StudioLandingPage.d.ts.map +1 -0
- package/dist/routes/pages/studio/StudioLandingPage.js +78 -0
- package/dist/routes/pages/studio/StudioLandingPage.js.map +1 -0
- package/dist/routes/pages/studio/StudioLandingPage.tsx +320 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1237 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
2
|
+
import ReactMarkdown from "react-markdown";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import { loadSession } from "../../../adapters/auth/storage";
|
|
5
|
+
import styles from "./SolidAiChat.module.css";
|
|
6
|
+
|
|
7
|
+
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
interface ToolProgressStep {
|
|
10
|
+
label: string;
|
|
11
|
+
status: "running" | "done";
|
|
12
|
+
durationMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Message {
|
|
16
|
+
id: string;
|
|
17
|
+
role: "user" | "assistant" | "event";
|
|
18
|
+
content: string;
|
|
19
|
+
timestamp: Date;
|
|
20
|
+
// event messages only
|
|
21
|
+
eventState?: "active" | "done";
|
|
22
|
+
// tool call events only
|
|
23
|
+
toolName?: string;
|
|
24
|
+
toolDesc?: string;
|
|
25
|
+
toolArgs?: Record<string, unknown>;
|
|
26
|
+
toolProgress?: ToolProgressStep[];
|
|
27
|
+
durationMs?: number;
|
|
28
|
+
toolStatus?: "success" | "warning" | "error";
|
|
29
|
+
toolOutput?: string;
|
|
30
|
+
toolOutputRaw?: string;
|
|
31
|
+
// step tracking for dividers
|
|
32
|
+
step?: number;
|
|
33
|
+
// error flag for styled error bubble
|
|
34
|
+
isError?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SessionSummary {
|
|
38
|
+
session_id: string;
|
|
39
|
+
status: string;
|
|
40
|
+
total_steps: number;
|
|
41
|
+
created_at: string | null;
|
|
42
|
+
preview: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
const WS_URL = "ws://localhost:8765/ws/agent";
|
|
48
|
+
const API_BASE = "http://localhost:8765";
|
|
49
|
+
const RECONNECT_DELAY = 3000;
|
|
50
|
+
const PAGE_SIZE = 50;
|
|
51
|
+
|
|
52
|
+
// ── Session persistence ───────────────────────────────────────────────────────
|
|
53
|
+
// Module-level vars survive sidebar collapse; localStorage survives hard reload.
|
|
54
|
+
const LS_SESSION_ID = "solidx.ai.session_id";
|
|
55
|
+
const LS_HISTORY_ID = "solidx.ai.history_session_id";
|
|
56
|
+
|
|
57
|
+
function readLS(key: string): string | null {
|
|
58
|
+
try { return localStorage.getItem(key); } catch { return null; }
|
|
59
|
+
}
|
|
60
|
+
function writeLS(key: string, val: string | null) {
|
|
61
|
+
try { val ? localStorage.setItem(key, val) : localStorage.removeItem(key); } catch { /* ignore */ }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let _persistedSessionId: string | null = readLS(LS_SESSION_ID);
|
|
65
|
+
let _historySessionId: string | null = readLS(LS_HISTORY_ID);
|
|
66
|
+
|
|
67
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function formatSessionDate(iso: string | null): string {
|
|
70
|
+
if (!iso) return "Unknown date";
|
|
71
|
+
const d = new Date(iso);
|
|
72
|
+
const diffDays = Math.floor((Date.now() - d.getTime()) / 86400000);
|
|
73
|
+
if (diffDays === 0) return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
74
|
+
if (diffDays === 1) return "Yesterday";
|
|
75
|
+
if (diffDays < 7) return d.toLocaleDateString([], { weekday: "short" });
|
|
76
|
+
return d.toLocaleDateString([], { month: "short", day: "numeric" });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Sub-components ────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function CopyButton({ text }: { text: string }) {
|
|
82
|
+
const [copied, setCopied] = useState(false);
|
|
83
|
+
const handleCopy = async (e: React.MouseEvent) => {
|
|
84
|
+
e.stopPropagation();
|
|
85
|
+
await navigator.clipboard.writeText(text);
|
|
86
|
+
setCopied(true);
|
|
87
|
+
setTimeout(() => setCopied(false), 2000);
|
|
88
|
+
};
|
|
89
|
+
return (
|
|
90
|
+
<button className={styles.CopyBtn} onClick={handleCopy} title="Copy" aria-label="Copy message">
|
|
91
|
+
<i className={`pi ${copied ? "pi-check" : "pi-copy"}`} style={{ fontSize: "11px" }} />
|
|
92
|
+
</button>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function CodeBlock({ children, className }: { children?: React.ReactNode; className?: string }) {
|
|
97
|
+
const [copied, setCopied] = useState(false);
|
|
98
|
+
const code = String(children ?? "").replace(/\n$/, "");
|
|
99
|
+
const lang = className?.replace("language-", "") ?? "";
|
|
100
|
+
const handleCopy = async () => {
|
|
101
|
+
await navigator.clipboard.writeText(code);
|
|
102
|
+
setCopied(true);
|
|
103
|
+
setTimeout(() => setCopied(false), 2000);
|
|
104
|
+
};
|
|
105
|
+
return (
|
|
106
|
+
<div className={styles.CodeBlockWrapper}>
|
|
107
|
+
<div className={styles.CodeBlockHeader}>
|
|
108
|
+
<span className={styles.CodeLang}>{lang || "code"}</span>
|
|
109
|
+
<button className={styles.CodeCopyBtn} onClick={handleCopy}>
|
|
110
|
+
<i className={`pi ${copied ? "pi-check" : "pi-copy"}`} style={{ fontSize: "11px" }} />
|
|
111
|
+
{copied ? "Copied" : "Copy"}
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
<pre className={styles.CodePre}><code>{code}</code></pre>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const markdownComponents = {
|
|
120
|
+
code({ node, inline, className, children, ...props }: any) {
|
|
121
|
+
// Detect by className — only named fenced blocks (```lang) become CodeBlock.
|
|
122
|
+
// Inline backticks and unnamed fenced blocks render as inline code.
|
|
123
|
+
const isBlock = /language-/.test(className || "");
|
|
124
|
+
if (!isBlock) return <code className={styles.InlineCode} {...props}>{children}</code>;
|
|
125
|
+
return <CodeBlock className={className}>{children}</CodeBlock>;
|
|
126
|
+
},
|
|
127
|
+
pre({ children }: any) { return <>{children}</>; },
|
|
128
|
+
table({ children }: any) {
|
|
129
|
+
return <div className={styles.MarkdownTableWrapper}><table>{children}</table></div>;
|
|
130
|
+
},
|
|
131
|
+
a({ href, children }: any) {
|
|
132
|
+
return (
|
|
133
|
+
<a href={href} target="_blank" rel="noopener noreferrer" className={styles.MarkdownLink}>
|
|
134
|
+
{children}
|
|
135
|
+
</a>
|
|
136
|
+
);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// ── Tool helpers ──────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
const TOOL_CATEGORIES: Record<string, string[]> = {
|
|
143
|
+
"Read": ["solid_get_module_metadata", "solid_get_model_metadata", "solid_get_field_metadata", "solid_get_layout_context", "select_relevant_code_context"],
|
|
144
|
+
"Create": ["solid_create_module", "solid_create_model_with_fields", "solid_add_fields_to_model", "solid_modify_fields_in_model", "solid_update_layout", "solid_patch_layout", "solid_add_or_update_menu", "edit_solid_code"],
|
|
145
|
+
"Chat": ["solid_chat_with_ozzy"],
|
|
146
|
+
"Shell": ["bash"],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function getToolCategory(name: string): string {
|
|
150
|
+
for (const [cat, tools] of Object.entries(TOOL_CATEGORIES)) {
|
|
151
|
+
if (tools.includes(name)) return cat;
|
|
152
|
+
}
|
|
153
|
+
return "Tool";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function getToolIconClass(name: string): string {
|
|
157
|
+
if (name === "bash") return "pi-terminal";
|
|
158
|
+
const cat = getToolCategory(name);
|
|
159
|
+
if (cat === "Read") return "pi-search";
|
|
160
|
+
if (cat === "Create") return "pi-file-edit";
|
|
161
|
+
if (cat === "Chat") return "pi-comments";
|
|
162
|
+
return "pi-cog";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function describeToolArgs(args: Record<string, unknown>): string {
|
|
166
|
+
const priority = ["cmd", "command", "query", "path", "file", "module_name", "model_name", "name", "id", "content"];
|
|
167
|
+
for (const key of priority) {
|
|
168
|
+
if (args[key] != null) {
|
|
169
|
+
const val = String(args[key]).trim().replace(/\n/g, " ");
|
|
170
|
+
return val.length > 72 ? val.slice(0, 72) + "…" : val;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const first = Object.values(args)[0];
|
|
174
|
+
if (first != null) {
|
|
175
|
+
const val = String(first).trim().replace(/\n/g, " ");
|
|
176
|
+
return val.length > 72 ? val.slice(0, 72) + "…" : val;
|
|
177
|
+
}
|
|
178
|
+
return "";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function formatDuration(ms?: number): string {
|
|
182
|
+
if (ms == null) return "";
|
|
183
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
184
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Parses tool output to extract semantic status and clean display text.
|
|
189
|
+
* Handles nested JSON structures like the Solid agent uses.
|
|
190
|
+
*/
|
|
191
|
+
function parseToolResult(output: string): { status: Message["toolStatus"]; text: string } {
|
|
192
|
+
if (!output) return { status: "success", text: "" };
|
|
193
|
+
try {
|
|
194
|
+
const outer = JSON.parse(output);
|
|
195
|
+
let status: Message["toolStatus"] = "success";
|
|
196
|
+
let text = "";
|
|
197
|
+
|
|
198
|
+
// The Solid agent often wraps the real result in a 'data' string or object
|
|
199
|
+
const innerData = outer.data;
|
|
200
|
+
if (typeof innerData === "string") {
|
|
201
|
+
try {
|
|
202
|
+
const inner = JSON.parse(innerData);
|
|
203
|
+
if (inner.generation_status === "error" || inner.status === "error") {
|
|
204
|
+
status = "warning";
|
|
205
|
+
text = inner.instructions || (inner.errors && inner.errors.join(", ")) || innerData;
|
|
206
|
+
} else {
|
|
207
|
+
text = inner.message || inner.content || innerData;
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
text = innerData;
|
|
211
|
+
}
|
|
212
|
+
} else if (innerData && typeof innerData === "object") {
|
|
213
|
+
if (innerData.generation_status === "error" || innerData.status === "error") {
|
|
214
|
+
status = "warning";
|
|
215
|
+
text = innerData.instructions || (innerData.errors && innerData.errors.join(", ")) || JSON.stringify(innerData);
|
|
216
|
+
} else {
|
|
217
|
+
text = innerData.message || innerData.content || JSON.stringify(innerData);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
text = outer.content || outer.message || output;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Check if the outer status is error
|
|
224
|
+
if (outer.status === "error" || outer.errors?.length > 0) {
|
|
225
|
+
status = "error";
|
|
226
|
+
text = outer.message || (outer.errors && outer.errors.join(", ")) || text;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { status, text: text.length > 500 ? text.slice(0, 500) + "…" : text };
|
|
230
|
+
} catch {
|
|
231
|
+
return { status: "success", text: output.length > 120 ? output.slice(0, 120) + "…" : output };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Detects LLM metadata strings that should not be rendered as chat messages.
|
|
237
|
+
* These include token counts, model info, finish_reason, and message-list summaries.
|
|
238
|
+
*/
|
|
239
|
+
function isLlmMetadata(content: string): boolean {
|
|
240
|
+
if (!content) return true;
|
|
241
|
+
return /\d+\s*(input|output)\s*tokens/i.test(content)
|
|
242
|
+
|| /finish_reason:/i.test(content)
|
|
243
|
+
|| /^\d+\s+messages\s*\(/i.test(content)
|
|
244
|
+
|| /\|\s*model:\s*\S+/i.test(content);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Message grouping ──────────────────────────────────────────────────────────
|
|
248
|
+
// Groups consecutive event + assistant messages into a single AI turn so we can
|
|
249
|
+
// render the avatar once per turn rather than per message.
|
|
250
|
+
|
|
251
|
+
type MsgGroup =
|
|
252
|
+
| { kind: "user"; msg: Message }
|
|
253
|
+
| { kind: "ai"; msgs: Message[]; groupKey: string };
|
|
254
|
+
|
|
255
|
+
function groupMessages(messages: Message[]): MsgGroup[] {
|
|
256
|
+
const result: MsgGroup[] = [];
|
|
257
|
+
let aiBuffer: Message[] = [];
|
|
258
|
+
for (const msg of messages) {
|
|
259
|
+
if (msg.role === "user") {
|
|
260
|
+
if (aiBuffer.length > 0) {
|
|
261
|
+
result.push({ kind: "ai", msgs: aiBuffer, groupKey: aiBuffer[0].id });
|
|
262
|
+
aiBuffer = [];
|
|
263
|
+
}
|
|
264
|
+
result.push({ kind: "user", msg });
|
|
265
|
+
} else {
|
|
266
|
+
aiBuffer.push(msg);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (aiBuffer.length > 0) {
|
|
270
|
+
result.push({ kind: "ai", msgs: aiBuffer, groupKey: aiBuffer[0].id });
|
|
271
|
+
}
|
|
272
|
+
return result;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Maps the new backend "event-based" JSON history into a flattened Message list for UI.
|
|
277
|
+
* Handles merging ToolResult into ToolCalling, and processing StepStarted etc.
|
|
278
|
+
*/
|
|
279
|
+
function processHistoryEvents(backendMsgs: any[]): Message[] {
|
|
280
|
+
const result: Message[] = [];
|
|
281
|
+
// Tracks the "active" message so we can update its duration/progress inline.
|
|
282
|
+
let currentEventIdx: number | null = null;
|
|
283
|
+
|
|
284
|
+
for (const m of backendMsgs) {
|
|
285
|
+
const type = m.event_type;
|
|
286
|
+
const data = m.event_data || {};
|
|
287
|
+
const id = `hist-${m.id}`;
|
|
288
|
+
// The backend ISO timestamp or the current time as fallback.
|
|
289
|
+
const ts = m.timestamp ? new Date(m.timestamp) : new Date();
|
|
290
|
+
|
|
291
|
+
switch (type) {
|
|
292
|
+
case "UserMessage":
|
|
293
|
+
result.push({
|
|
294
|
+
id,
|
|
295
|
+
role: "user",
|
|
296
|
+
content: m.content || data.content || "",
|
|
297
|
+
timestamp: ts,
|
|
298
|
+
});
|
|
299
|
+
currentEventIdx = null;
|
|
300
|
+
break;
|
|
301
|
+
|
|
302
|
+
case "AgentStarted":
|
|
303
|
+
case "StepStarted":
|
|
304
|
+
// Create a "Thinking…" event row.
|
|
305
|
+
currentEventIdx = result.length;
|
|
306
|
+
result.push({
|
|
307
|
+
id,
|
|
308
|
+
role: "event",
|
|
309
|
+
content: "Thinking…",
|
|
310
|
+
timestamp: ts,
|
|
311
|
+
eventState: "done",
|
|
312
|
+
});
|
|
313
|
+
break;
|
|
314
|
+
|
|
315
|
+
case "ToolCalling": {
|
|
316
|
+
const toolName = data.tool_name || m.tool_name || "tool";
|
|
317
|
+
const toolArgs = data.arguments || {};
|
|
318
|
+
currentEventIdx = result.length;
|
|
319
|
+
result.push({
|
|
320
|
+
id,
|
|
321
|
+
role: "event",
|
|
322
|
+
content: toolName,
|
|
323
|
+
timestamp: ts,
|
|
324
|
+
eventState: "done",
|
|
325
|
+
toolName,
|
|
326
|
+
toolDesc: describeToolArgs(toolArgs),
|
|
327
|
+
toolArgs,
|
|
328
|
+
});
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
case "ToolProgress": {
|
|
333
|
+
if (currentEventIdx != null) {
|
|
334
|
+
const label = data.label || "";
|
|
335
|
+
const progressStatus = data.status || "running";
|
|
336
|
+
const stepStatus: ToolProgressStep["status"] =
|
|
337
|
+
progressStatus === "success" || progressStatus === "done" ? "done" : "running";
|
|
338
|
+
const durationMs: number | undefined = data.duration_ms;
|
|
339
|
+
|
|
340
|
+
const prev = result[currentEventIdx];
|
|
341
|
+
if (prev && prev.role === "event") {
|
|
342
|
+
const existing = prev.toolProgress || [];
|
|
343
|
+
const existingIdx = existing.findIndex((p) => p.label === label);
|
|
344
|
+
if (existingIdx >= 0) {
|
|
345
|
+
const updated = [...existing];
|
|
346
|
+
updated[existingIdx] = {
|
|
347
|
+
label,
|
|
348
|
+
status: stepStatus,
|
|
349
|
+
durationMs: durationMs ?? updated[existingIdx].durationMs,
|
|
350
|
+
};
|
|
351
|
+
prev.toolProgress = updated;
|
|
352
|
+
} else {
|
|
353
|
+
prev.toolProgress = [...existing, { label, status: stepStatus, durationMs }];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
case "ToolResult": {
|
|
361
|
+
if (currentEventIdx != null) {
|
|
362
|
+
const prev = result[currentEventIdx];
|
|
363
|
+
if (prev && prev.role === "event") {
|
|
364
|
+
prev.durationMs = data.duration_ms || m.duration_ms;
|
|
365
|
+
const outputStr = data.output || m.content || "";
|
|
366
|
+
if (outputStr) {
|
|
367
|
+
const { status, text } = parseToolResult(outputStr);
|
|
368
|
+
prev.toolStatus = status;
|
|
369
|
+
prev.toolOutput = text;
|
|
370
|
+
prev.toolOutputRaw = outputStr;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
case "LlmComplete": {
|
|
378
|
+
const llmContent = m.content || data.content || "";
|
|
379
|
+
if (!llmContent || isLlmMetadata(llmContent)) break;
|
|
380
|
+
const prevAssistantLlm = [...result].reverse().find(r => r.role === "assistant");
|
|
381
|
+
if (prevAssistantLlm?.content === llmContent) break;
|
|
382
|
+
result.push({ id, role: "assistant", content: llmContent, timestamp: ts });
|
|
383
|
+
currentEventIdx = null;
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
case "TurnCompleteEvent": {
|
|
387
|
+
const tcContent = m.content || data.content || "";
|
|
388
|
+
if (!tcContent) { currentEventIdx = null; break; }
|
|
389
|
+
const prevAssistantTc = [...result].reverse().find(r => r.role === "assistant");
|
|
390
|
+
if (prevAssistantTc?.content === tcContent) { currentEventIdx = null; break; }
|
|
391
|
+
result.push({ id, role: "assistant", content: tcContent, timestamp: ts });
|
|
392
|
+
currentEventIdx = null;
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
case "StepComplete":
|
|
397
|
+
case "AgentComplete":
|
|
398
|
+
currentEventIdx = null;
|
|
399
|
+
break;
|
|
400
|
+
|
|
401
|
+
case "AgentError":
|
|
402
|
+
case "error":
|
|
403
|
+
result.push({
|
|
404
|
+
id,
|
|
405
|
+
role: "assistant",
|
|
406
|
+
content: `Error: ${data.error || m.content || "Unknown error"}`,
|
|
407
|
+
timestamp: ts,
|
|
408
|
+
});
|
|
409
|
+
currentEventIdx = null;
|
|
410
|
+
break;
|
|
411
|
+
|
|
412
|
+
default:
|
|
413
|
+
if (m.role === "user") {
|
|
414
|
+
result.push({ id, role: "user", content: m.content || "", timestamp: ts });
|
|
415
|
+
currentEventIdx = null;
|
|
416
|
+
} else if (m.role === "assistant") {
|
|
417
|
+
const fallbackContent = m.content || "";
|
|
418
|
+
if (fallbackContent && !isLlmMetadata(fallbackContent)) {
|
|
419
|
+
const prevAst = [...result].reverse().find(r => r.role === "assistant");
|
|
420
|
+
if (prevAst?.content !== fallbackContent) {
|
|
421
|
+
result.push({ id, role: "assistant", content: fallbackContent, timestamp: ts });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
currentEventIdx = null;
|
|
425
|
+
}
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
return result;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── Main component ────────────────────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
export const SolidAiChat: React.FC = () => {
|
|
435
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
436
|
+
const [input, setInput] = useState("");
|
|
437
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
438
|
+
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
439
|
+
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
|
440
|
+
const [hasMoreHistory, setHasMoreHistory] = useState(false);
|
|
441
|
+
const [historyPage, setHistoryPage] = useState(1);
|
|
442
|
+
|
|
443
|
+
const [showSessionMenu, setShowSessionMenu] = useState(false);
|
|
444
|
+
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
|
445
|
+
const [isLoadingSessions, setIsLoadingSessions] = useState(false);
|
|
446
|
+
const [isProcessing, setIsProcessing] = useState(false);
|
|
447
|
+
const [activeHistoryId, setActiveHistoryId] = useState<string | null>(null);
|
|
448
|
+
const [isDeletingSessionId, setIsDeletingSessionId] = useState<string | null>(null);
|
|
449
|
+
const [expandedToolIds, setExpandedToolIds] = useState<Set<string>>(new Set());
|
|
450
|
+
|
|
451
|
+
const sessionMenuRef = useRef<HTMLDivElement>(null);
|
|
452
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
453
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
454
|
+
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
455
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
456
|
+
const hasStreamedRef = useRef(false);
|
|
457
|
+
const intentionalCloseRef = useRef(false);
|
|
458
|
+
const isPrependingRef = useRef(false);
|
|
459
|
+
const prevScrollHeightRef = useRef(0);
|
|
460
|
+
// Tracks the id of the current "live" event message so we can update it in-place.
|
|
461
|
+
const liveEventIdRef = useRef<string | null>(null);
|
|
462
|
+
const currentStepRef = useRef<number | null>(null);
|
|
463
|
+
const activeSessionIdRef = useRef<string | null>(_persistedSessionId);
|
|
464
|
+
|
|
465
|
+
// ── Close session menu on outside click ──────────────────────────────────
|
|
466
|
+
useEffect(() => {
|
|
467
|
+
if (!showSessionMenu) return;
|
|
468
|
+
const handler = (e: MouseEvent) => {
|
|
469
|
+
if (sessionMenuRef.current && !sessionMenuRef.current.contains(e.target as Node)) {
|
|
470
|
+
setShowSessionMenu(false);
|
|
471
|
+
}
|
|
472
|
+
};
|
|
473
|
+
document.addEventListener("mousedown", handler);
|
|
474
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
475
|
+
}, [showSessionMenu]);
|
|
476
|
+
|
|
477
|
+
// ── Scroll management ─────────────────────────────────────────────────────
|
|
478
|
+
const scrollToBottom = useCallback(() => {
|
|
479
|
+
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
480
|
+
}, []);
|
|
481
|
+
|
|
482
|
+
useEffect(() => {
|
|
483
|
+
if (isPrependingRef.current && scrollRef.current) {
|
|
484
|
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight - prevScrollHeightRef.current;
|
|
485
|
+
isPrependingRef.current = false;
|
|
486
|
+
} else {
|
|
487
|
+
scrollToBottom();
|
|
488
|
+
}
|
|
489
|
+
}, [messages, scrollToBottom]);
|
|
490
|
+
|
|
491
|
+
// ── History fetch ─────────────────────────────────────────────────────────
|
|
492
|
+
const fetchHistory = useCallback(async (sid: string, page: number, prepend: boolean) => {
|
|
493
|
+
setIsLoadingHistory(true);
|
|
494
|
+
try {
|
|
495
|
+
const res = await fetch(`${API_BASE}/api/agent/sessions/${sid}/messages?page=${page}&page_size=${PAGE_SIZE}`);
|
|
496
|
+
if (!res.ok) return;
|
|
497
|
+
const data = await res.json();
|
|
498
|
+
const fetched = processHistoryEvents(data.messages ?? []);
|
|
499
|
+
if (prepend) {
|
|
500
|
+
prevScrollHeightRef.current = scrollRef.current?.scrollHeight ?? 0;
|
|
501
|
+
isPrependingRef.current = true;
|
|
502
|
+
setMessages((prev) => [...fetched, ...prev]);
|
|
503
|
+
} else {
|
|
504
|
+
setMessages(fetched);
|
|
505
|
+
}
|
|
506
|
+
setHasMoreHistory(data.has_more ?? false);
|
|
507
|
+
setHistoryPage(page);
|
|
508
|
+
} catch {
|
|
509
|
+
// silently skip
|
|
510
|
+
} finally {
|
|
511
|
+
setIsLoadingHistory(false);
|
|
512
|
+
}
|
|
513
|
+
}, []);
|
|
514
|
+
|
|
515
|
+
// ── Sessions list fetch ───────────────────────────────────────────────────
|
|
516
|
+
const fetchSessions = useCallback(async () => {
|
|
517
|
+
setIsLoadingSessions(true);
|
|
518
|
+
try {
|
|
519
|
+
const userId = loadSession()?.user?.id ?? null;
|
|
520
|
+
const url = userId
|
|
521
|
+
? `${API_BASE}/api/agent/sessions/history?user_id=${userId}`
|
|
522
|
+
: `${API_BASE}/api/agent/sessions/history`;
|
|
523
|
+
const res = await fetch(url);
|
|
524
|
+
if (!res.ok) return;
|
|
525
|
+
setSessions(await res.json());
|
|
526
|
+
} catch {
|
|
527
|
+
// silently ignore
|
|
528
|
+
} finally {
|
|
529
|
+
setIsLoadingSessions(false);
|
|
530
|
+
}
|
|
531
|
+
}, []);
|
|
532
|
+
|
|
533
|
+
// ── WebSocket ─────────────────────────────────────────────────────────────
|
|
534
|
+
const connectWebSocket = useCallback(() => {
|
|
535
|
+
const state = wsRef.current?.readyState;
|
|
536
|
+
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) return;
|
|
537
|
+
intentionalCloseRef.current = false;
|
|
538
|
+
|
|
539
|
+
const ws = new WebSocket(WS_URL);
|
|
540
|
+
|
|
541
|
+
ws.onopen = () => {
|
|
542
|
+
setIsConnected(true);
|
|
543
|
+
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
|
544
|
+
const userId = loadSession()?.user?.id ?? null;
|
|
545
|
+
if (_persistedSessionId) {
|
|
546
|
+
ws.send(JSON.stringify({ action: "resume_session", session_id: _persistedSessionId, user_id: userId }));
|
|
547
|
+
} else {
|
|
548
|
+
ws.send(JSON.stringify({ action: "start_session", user_id: userId }));
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
ws.onmessage = (event) => {
|
|
553
|
+
try {
|
|
554
|
+
const data = JSON.parse(event.data);
|
|
555
|
+
|
|
556
|
+
// Filtering: Only process messages for the active session.
|
|
557
|
+
// data.session_id is the primary field; sometimes it's nested in data.data
|
|
558
|
+
const msgSessionId = data.session_id || data.data?.session_id;
|
|
559
|
+
|
|
560
|
+
// If we have an active session and the message is for a different one, ignore it.
|
|
561
|
+
// Exception: session_started events are always allowed as they establish the new active ID.
|
|
562
|
+
if (data.type !== "session_started" && msgSessionId && activeSessionIdRef.current && msgSessionId !== activeSessionIdRef.current) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Add a new persistent event row, auto-freezing any previous live one.
|
|
567
|
+
// Pass extra fields (toolName, toolDesc, …) for tool card events.
|
|
568
|
+
const addEvent = (content: string, extra?: Partial<Message>) => {
|
|
569
|
+
const prevId = liveEventIdRef.current;
|
|
570
|
+
const id = `evt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
571
|
+
liveEventIdRef.current = id;
|
|
572
|
+
setMessages((prev) => {
|
|
573
|
+
const base = prevId
|
|
574
|
+
? prev.map((m) => m.id === prevId ? { ...m, eventState: "done" as const } : m)
|
|
575
|
+
: prev;
|
|
576
|
+
return [...base, { id, role: "event" as const, content, timestamp: new Date(), eventState: "active" as const, ...extra }];
|
|
577
|
+
});
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// Freeze the current live event as done.
|
|
581
|
+
const freezeEvent = () => {
|
|
582
|
+
const id = liveEventIdRef.current;
|
|
583
|
+
if (!id) return;
|
|
584
|
+
liveEventIdRef.current = null;
|
|
585
|
+
setMessages((prev) => prev.map((m) => m.id === id ? { ...m, eventState: "done" as const } : m));
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
switch (data.type) {
|
|
589
|
+
// ── Session lifecycle ──────────────────────────────────
|
|
590
|
+
case "session_started": {
|
|
591
|
+
const sid: string = data.session_id;
|
|
592
|
+
// Prefer the backend's history_session_id if provided.
|
|
593
|
+
// This fixes the "ID chaining" issue where resuming a restored session
|
|
594
|
+
// would create a new ID. We want to stick to the "original" ID for history.
|
|
595
|
+
const historySid: string = data.history_session_id || sid;
|
|
596
|
+
|
|
597
|
+
_persistedSessionId = sid;
|
|
598
|
+
_historySessionId = historySid;
|
|
599
|
+
writeLS(LS_SESSION_ID, sid);
|
|
600
|
+
writeLS(LS_HISTORY_ID, historySid);
|
|
601
|
+
setSessionId(sid);
|
|
602
|
+
activeSessionIdRef.current = sid;
|
|
603
|
+
setActiveHistoryId(historySid);
|
|
604
|
+
|
|
605
|
+
// Only fetch history if we don't have any messages yet (initial load or session switch)
|
|
606
|
+
setMessages((prev) => {
|
|
607
|
+
if (prev.length === 0) fetchHistory(historySid, 1, false);
|
|
608
|
+
return prev;
|
|
609
|
+
});
|
|
610
|
+
break;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
case "session_ended":
|
|
614
|
+
setSessionId(null);
|
|
615
|
+
break;
|
|
616
|
+
|
|
617
|
+
// ── Agent lifecycle ────────────────────────────────────
|
|
618
|
+
case "AgentStarted":
|
|
619
|
+
setIsProcessing(true);
|
|
620
|
+
currentStepRef.current = null;
|
|
621
|
+
addEvent("Thinking…");
|
|
622
|
+
break;
|
|
623
|
+
|
|
624
|
+
case "StepStarted": {
|
|
625
|
+
const step: number = data.data?.step ?? 1;
|
|
626
|
+
currentStepRef.current = step;
|
|
627
|
+
// Only create a "Thinking…" row when there's no active live event
|
|
628
|
+
// (e.g. step 2+ after tool results already froze the previous card).
|
|
629
|
+
if (!liveEventIdRef.current) addEvent("Thinking…", { step });
|
|
630
|
+
break;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
case "StepComplete":
|
|
634
|
+
freezeEvent();
|
|
635
|
+
break;
|
|
636
|
+
|
|
637
|
+
case "ToolResult": {
|
|
638
|
+
const durationMs: number | undefined = data.data?.duration_ms;
|
|
639
|
+
const outputStr: string = data.data?.output || "";
|
|
640
|
+
const id = liveEventIdRef.current;
|
|
641
|
+
if (id) {
|
|
642
|
+
setMessages((prev) => prev.map((m) => {
|
|
643
|
+
if (m.id === id) {
|
|
644
|
+
const update: Partial<Message> = { durationMs };
|
|
645
|
+
if (outputStr) {
|
|
646
|
+
const { status, text } = parseToolResult(outputStr);
|
|
647
|
+
update.toolStatus = status;
|
|
648
|
+
update.toolOutput = text;
|
|
649
|
+
update.toolOutputRaw = outputStr;
|
|
650
|
+
}
|
|
651
|
+
return { ...m, ...update };
|
|
652
|
+
}
|
|
653
|
+
return m;
|
|
654
|
+
}));
|
|
655
|
+
}
|
|
656
|
+
freezeEvent();
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
case "AgentComplete":
|
|
661
|
+
freezeEvent();
|
|
662
|
+
break;
|
|
663
|
+
|
|
664
|
+
// ── LLM streaming ──────────────────────────────────────
|
|
665
|
+
case "LlmToken": {
|
|
666
|
+
const delta: string = data.data?.delta ?? "";
|
|
667
|
+
if (!delta) break;
|
|
668
|
+
hasStreamedRef.current = true;
|
|
669
|
+
// Freeze the live event row on the first token, then stream.
|
|
670
|
+
const evtId = liveEventIdRef.current;
|
|
671
|
+
liveEventIdRef.current = null;
|
|
672
|
+
setMessages((prev) => {
|
|
673
|
+
let msgs = evtId
|
|
674
|
+
? prev.map((m) => m.id === evtId ? { ...m, eventState: "done" as const } : m)
|
|
675
|
+
: prev;
|
|
676
|
+
const last = msgs[msgs.length - 1];
|
|
677
|
+
if (last?.role === "assistant" && last.id.startsWith("stream-")) {
|
|
678
|
+
return [...msgs.slice(0, -1), { ...last, content: last.content + delta }];
|
|
679
|
+
}
|
|
680
|
+
return [...msgs, { id: `stream-${Date.now()}`, role: "assistant", content: delta, timestamp: new Date() }];
|
|
681
|
+
});
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ── LLM complete (intermediate text before tool calls) ─
|
|
686
|
+
case "LlmComplete": {
|
|
687
|
+
const llmContent: string = data.data?.content ?? "";
|
|
688
|
+
if (llmContent && !isLlmMetadata(llmContent)) {
|
|
689
|
+
const evtId = liveEventIdRef.current;
|
|
690
|
+
liveEventIdRef.current = null;
|
|
691
|
+
setMessages((prev) => {
|
|
692
|
+
let msgs = evtId
|
|
693
|
+
? prev.map((m) => m.id === evtId ? { ...m, eventState: "done" as const } : m)
|
|
694
|
+
: prev;
|
|
695
|
+
const lastAssistant = [...msgs].reverse().find(m => m.role === "assistant");
|
|
696
|
+
if (!lastAssistant || lastAssistant.content !== llmContent) {
|
|
697
|
+
msgs = [...msgs, { id: `llm-${Date.now()}`, role: "assistant" as const, content: llmContent, timestamp: new Date() }];
|
|
698
|
+
}
|
|
699
|
+
return msgs;
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
break;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ── Tool activity ──────────────────────────────────────
|
|
706
|
+
case "ToolCalling": {
|
|
707
|
+
const toolName: string = data.data?.tool_name ?? "tool";
|
|
708
|
+
const toolArgs: Record<string, unknown> = data.data?.arguments ?? {};
|
|
709
|
+
addEvent(toolName, { toolName, toolDesc: describeToolArgs(toolArgs), toolArgs });
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
case "ToolProgress": {
|
|
714
|
+
const label: string = data.data?.label ?? "";
|
|
715
|
+
const progressStatus: string = data.data?.status ?? "running";
|
|
716
|
+
const durationMs: number | undefined = data.data?.duration_ms;
|
|
717
|
+
const stepStatus: ToolProgressStep["status"] =
|
|
718
|
+
progressStatus === "success" || progressStatus === "done" ? "done" : "running";
|
|
719
|
+
const id = liveEventIdRef.current;
|
|
720
|
+
if (!id) break;
|
|
721
|
+
setMessages((prev) => prev.map((m) => {
|
|
722
|
+
if (m.id !== id) return m;
|
|
723
|
+
const existing = m.toolProgress ?? [];
|
|
724
|
+
const idx = existing.findIndex((p) => p.label === label);
|
|
725
|
+
if (idx >= 0) {
|
|
726
|
+
const updated = [...existing];
|
|
727
|
+
updated[idx] = { label, status: stepStatus, durationMs: durationMs ?? updated[idx].durationMs };
|
|
728
|
+
return { ...m, toolProgress: updated };
|
|
729
|
+
}
|
|
730
|
+
return { ...m, toolProgress: [...existing, { label, status: stepStatus, durationMs }] };
|
|
731
|
+
}));
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ── Turn complete ──────────────────────────────────────
|
|
736
|
+
case "TurnCompleteEvent":
|
|
737
|
+
case "turn_complete": {
|
|
738
|
+
setIsProcessing(false);
|
|
739
|
+
const content: string = data.data?.content ?? data.content ?? "";
|
|
740
|
+
const evtId = liveEventIdRef.current;
|
|
741
|
+
liveEventIdRef.current = null;
|
|
742
|
+
setMessages((prev) => {
|
|
743
|
+
let msgs = evtId
|
|
744
|
+
? prev.map((m) => m.id === evtId ? { ...m, eventState: "done" as const } : m)
|
|
745
|
+
: prev;
|
|
746
|
+
if (content) {
|
|
747
|
+
const lastAssistant = [...msgs].reverse().find(m => m.role === "assistant");
|
|
748
|
+
const alreadyRendered = lastAssistant && lastAssistant.content === content;
|
|
749
|
+
if (!hasStreamedRef.current && !alreadyRendered) {
|
|
750
|
+
msgs = [...msgs, { id: `tc-${Date.now()}`, role: "assistant", content, timestamp: new Date() }];
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return msgs;
|
|
754
|
+
});
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ── Errors ─────────────────────────────────────────────
|
|
759
|
+
case "AgentError":
|
|
760
|
+
case "error": {
|
|
761
|
+
setIsProcessing(false);
|
|
762
|
+
const errMsg: string = data.data?.error ?? "Unknown error";
|
|
763
|
+
const evtId = liveEventIdRef.current;
|
|
764
|
+
liveEventIdRef.current = null;
|
|
765
|
+
setMessages((prev) => {
|
|
766
|
+
const msgs = evtId
|
|
767
|
+
? prev.map((m) => m.id === evtId ? { ...m, eventState: "done" as const } : m)
|
|
768
|
+
: prev;
|
|
769
|
+
return [...msgs, { id: `err-${Date.now()}`, role: "assistant" as const, content: errMsg, timestamp: new Date(), isError: true }];
|
|
770
|
+
});
|
|
771
|
+
break;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
default:
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
} catch {
|
|
778
|
+
// ignore JSON parse errors
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
ws.onclose = () => {
|
|
783
|
+
setIsConnected(false);
|
|
784
|
+
setSessionId(null);
|
|
785
|
+
setIsProcessing(false);
|
|
786
|
+
liveEventIdRef.current = null;
|
|
787
|
+
if (!intentionalCloseRef.current) {
|
|
788
|
+
// Unexpected disconnect — banner is shown via !isConnected state; schedule reconnect.
|
|
789
|
+
reconnectTimerRef.current = setTimeout(connectWebSocket, RECONNECT_DELAY);
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
ws.onerror = () => {
|
|
794
|
+
setIsConnected(false);
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
wsRef.current = ws;
|
|
798
|
+
}, [fetchHistory]);
|
|
799
|
+
|
|
800
|
+
useEffect(() => {
|
|
801
|
+
connectWebSocket();
|
|
802
|
+
return () => {
|
|
803
|
+
reconnectTimerRef.current && clearTimeout(reconnectTimerRef.current);
|
|
804
|
+
intentionalCloseRef.current = true;
|
|
805
|
+
wsRef.current?.close();
|
|
806
|
+
};
|
|
807
|
+
}, [connectWebSocket]);
|
|
808
|
+
|
|
809
|
+
// ── Send message ──────────────────────────────────────────────────────────
|
|
810
|
+
const handleSend = useCallback(() => {
|
|
811
|
+
const trimmed = input.trim();
|
|
812
|
+
if (!trimmed || !isConnected || !sessionId || isProcessing) return;
|
|
813
|
+
|
|
814
|
+
setIsProcessing(true);
|
|
815
|
+
hasStreamedRef.current = false;
|
|
816
|
+
currentStepRef.current = null;
|
|
817
|
+
liveEventIdRef.current = null;
|
|
818
|
+
setMessages((prev) => [...prev, { id: `u-${Date.now()}`, role: "user", content: trimmed, timestamp: new Date() }]);
|
|
819
|
+
wsRef.current?.send(JSON.stringify({ action: "message", session_id: sessionId, content: trimmed }));
|
|
820
|
+
setInput("");
|
|
821
|
+
if (textareaRef.current) {
|
|
822
|
+
textareaRef.current.style.height = "auto";
|
|
823
|
+
textareaRef.current.focus();
|
|
824
|
+
}
|
|
825
|
+
}, [input, isConnected, sessionId, isProcessing]);
|
|
826
|
+
|
|
827
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
828
|
+
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
const autoResize = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
832
|
+
setInput(e.target.value);
|
|
833
|
+
const el = e.target;
|
|
834
|
+
el.style.height = "auto";
|
|
835
|
+
el.style.height = `${Math.min(el.scrollHeight, 480)}px`;
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
// ── Session actions ───────────────────────────────────────────────────────
|
|
839
|
+
const handleNewChat = useCallback(() => {
|
|
840
|
+
_persistedSessionId = null;
|
|
841
|
+
_historySessionId = null;
|
|
842
|
+
writeLS(LS_SESSION_ID, null);
|
|
843
|
+
writeLS(LS_HISTORY_ID, null);
|
|
844
|
+
liveEventIdRef.current = null;
|
|
845
|
+
activeSessionIdRef.current = "__switching__";
|
|
846
|
+
setMessages([]);
|
|
847
|
+
setIsProcessing(false);
|
|
848
|
+
setExpandedToolIds(new Set());
|
|
849
|
+
setSessionId(null);
|
|
850
|
+
setActiveHistoryId(null);
|
|
851
|
+
setHasMoreHistory(false);
|
|
852
|
+
setHistoryPage(1);
|
|
853
|
+
setShowSessionMenu(false);
|
|
854
|
+
const userId = loadSession()?.user?.id ?? null;
|
|
855
|
+
wsRef.current?.send(JSON.stringify({ action: "start_session", user_id: userId }));
|
|
856
|
+
}, []);
|
|
857
|
+
|
|
858
|
+
const handleSelectSession = useCallback((targetSessionId: string) => {
|
|
859
|
+
if (targetSessionId === activeHistoryId) {
|
|
860
|
+
setShowSessionMenu(false);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
_persistedSessionId = targetSessionId;
|
|
864
|
+
_historySessionId = targetSessionId;
|
|
865
|
+
writeLS(LS_SESSION_ID, targetSessionId);
|
|
866
|
+
writeLS(LS_HISTORY_ID, targetSessionId);
|
|
867
|
+
liveEventIdRef.current = null;
|
|
868
|
+
activeSessionIdRef.current = targetSessionId;
|
|
869
|
+
setMessages([]);
|
|
870
|
+
setSessionId(null);
|
|
871
|
+
setActiveHistoryId(targetSessionId);
|
|
872
|
+
setExpandedToolIds(new Set());
|
|
873
|
+
setHasMoreHistory(false);
|
|
874
|
+
setHistoryPage(1);
|
|
875
|
+
setShowSessionMenu(false);
|
|
876
|
+
const userId = loadSession()?.user?.id ?? null;
|
|
877
|
+
wsRef.current?.send(JSON.stringify({ action: "resume_session", session_id: targetSessionId, user_id: userId }));
|
|
878
|
+
}, [activeHistoryId]);
|
|
879
|
+
|
|
880
|
+
const handleDeleteSession = useCallback(async (e: React.MouseEvent, targetId: string) => {
|
|
881
|
+
e.stopPropagation();
|
|
882
|
+
if (!window.confirm("Are you sure you want to delete this conversation?")) return;
|
|
883
|
+
|
|
884
|
+
setIsDeletingSessionId(targetId);
|
|
885
|
+
try {
|
|
886
|
+
const res = await fetch(`${API_BASE}/api/agent/sessions/${targetId}`, { method: "DELETE" });
|
|
887
|
+
if (res.ok) {
|
|
888
|
+
setSessions((prev) => prev.filter((s) => s.session_id !== targetId));
|
|
889
|
+
if (targetId === activeHistoryId) {
|
|
890
|
+
handleNewChat();
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
} catch {
|
|
894
|
+
// ignore
|
|
895
|
+
} finally {
|
|
896
|
+
setIsDeletingSessionId(null);
|
|
897
|
+
}
|
|
898
|
+
}, [activeHistoryId, handleNewChat]);
|
|
899
|
+
|
|
900
|
+
const handleToggleSessionMenu = useCallback(() => {
|
|
901
|
+
setShowSessionMenu((prev) => { if (!prev) fetchSessions(); return !prev; });
|
|
902
|
+
}, [fetchSessions]);
|
|
903
|
+
|
|
904
|
+
const handleLoadOlder = useCallback(() => {
|
|
905
|
+
const sid = _historySessionId ?? sessionId;
|
|
906
|
+
if (!sid || isLoadingHistory) return;
|
|
907
|
+
fetchHistory(sid, historyPage + 1, true);
|
|
908
|
+
}, [sessionId, isLoadingHistory, historyPage, fetchHistory]);
|
|
909
|
+
|
|
910
|
+
// ── Derived ───────────────────────────────────────────────────────────────
|
|
911
|
+
const canSend = isConnected && !!sessionId && !!input.trim() && !isProcessing;
|
|
912
|
+
const inputPlaceholder = !isConnected ? "Connecting to server…" : !sessionId ? "Establishing session…" : isProcessing ? "SolidX AI is thinking…" : "Message SolidX AI…";
|
|
913
|
+
|
|
914
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
915
|
+
return (
|
|
916
|
+
<div className={styles.Container}>
|
|
917
|
+
|
|
918
|
+
{/* ── Header ── */}
|
|
919
|
+
<header className={styles.Header}>
|
|
920
|
+
<span className={`${styles.StatusDot} ${isProcessing ? styles.StatusRunning : isConnected ? styles.StatusOnline : styles.StatusOffline}`} />
|
|
921
|
+
<span className={styles.StatusLabel}>
|
|
922
|
+
<i className={`pi ${isConnected ? "pi-wifi" : "pi-times-circle"}`} style={{ fontSize: "11px" }} />
|
|
923
|
+
{isConnected ? (isProcessing ? "Running…" : sessionId ? "Connected" : "Starting…") : "Offline"}
|
|
924
|
+
</span>
|
|
925
|
+
|
|
926
|
+
<div className={styles.HeaderSpacer} />
|
|
927
|
+
|
|
928
|
+
<button className={styles.NewChatHeaderBtn} onClick={handleNewChat} title="New conversation">
|
|
929
|
+
<i className="pi pi-plus" style={{ fontSize: "10px" }} />
|
|
930
|
+
New Chat
|
|
931
|
+
</button>
|
|
932
|
+
|
|
933
|
+
{/* Hamburger + sessions dropdown — rightmost */}
|
|
934
|
+
<div className={styles.SessionMenuAnchor} ref={sessionMenuRef}>
|
|
935
|
+
<button className={styles.HamburgerBtn} onClick={handleToggleSessionMenu} title="Session history" aria-label="Open session history">
|
|
936
|
+
<i className="pi pi-bars" style={{ fontSize: "13px" }} />
|
|
937
|
+
</button>
|
|
938
|
+
|
|
939
|
+
{showSessionMenu && (
|
|
940
|
+
<div className={styles.SessionMenu}>
|
|
941
|
+
<button className={styles.SessionMenuNewChat} onClick={handleNewChat}>
|
|
942
|
+
<i className="pi pi-plus" style={{ fontSize: "12px" }} />
|
|
943
|
+
New Chat
|
|
944
|
+
</button>
|
|
945
|
+
<div className={styles.SessionMenuSectionLabel}>Recent Sessions</div>
|
|
946
|
+
<div className={styles.SessionList}>
|
|
947
|
+
{isLoadingSessions ? (
|
|
948
|
+
<div className={styles.SessionMenuLoading}>
|
|
949
|
+
<i className="pi pi-spin pi-spinner" style={{ fontSize: "11px" }} />
|
|
950
|
+
Loading…
|
|
951
|
+
</div>
|
|
952
|
+
) : sessions.length === 0 ? (
|
|
953
|
+
<div className={styles.SessionMenuEmpty}>No past sessions found</div>
|
|
954
|
+
) : (
|
|
955
|
+
sessions.map((s) => (
|
|
956
|
+
<div
|
|
957
|
+
key={s.session_id}
|
|
958
|
+
className={`${styles.SessionItem} ${s.session_id === activeHistoryId ? styles.SessionItemActive : ""}`}
|
|
959
|
+
onClick={() => handleSelectSession(s.session_id)}
|
|
960
|
+
>
|
|
961
|
+
<div className={styles.SessionItemMain}>
|
|
962
|
+
<span className={styles.SessionItemPreview}>{s.preview || "New conversation"}</span>
|
|
963
|
+
<span className={styles.SessionItemMeta}>
|
|
964
|
+
{formatSessionDate(s.created_at)}
|
|
965
|
+
{s.total_steps > 0 && ` · ${s.total_steps} turn${s.total_steps !== 1 ? "s" : ""}`}
|
|
966
|
+
</span>
|
|
967
|
+
</div>
|
|
968
|
+
<button
|
|
969
|
+
className={styles.SessionDeleteBtn}
|
|
970
|
+
onClick={(e) => handleDeleteSession(e, s.session_id)}
|
|
971
|
+
disabled={isDeletingSessionId === s.session_id}
|
|
972
|
+
title="Delete conversation"
|
|
973
|
+
>
|
|
974
|
+
<i className={`pi ${isDeletingSessionId === s.session_id ? "pi-spin pi-spinner" : "pi-trash"}`} style={{ fontSize: "11px" }} />
|
|
975
|
+
</button>
|
|
976
|
+
</div>
|
|
977
|
+
))
|
|
978
|
+
)}
|
|
979
|
+
</div>
|
|
980
|
+
</div>
|
|
981
|
+
)}
|
|
982
|
+
</div>
|
|
983
|
+
</header>
|
|
984
|
+
|
|
985
|
+
{/* ── Reconnecting banner ── */}
|
|
986
|
+
{!isConnected && (
|
|
987
|
+
<div className={styles.ReconnectBanner}>
|
|
988
|
+
<i className="pi pi-spin pi-spinner" style={{ fontSize: "11px" }} />
|
|
989
|
+
Reconnecting…
|
|
990
|
+
</div>
|
|
991
|
+
)}
|
|
992
|
+
|
|
993
|
+
{/* ── Message list ── */}
|
|
994
|
+
<div className={styles.MessageList} ref={scrollRef}>
|
|
995
|
+
{hasMoreHistory && (
|
|
996
|
+
<div className={styles.LoadOlderWrap}>
|
|
997
|
+
<button className={styles.LoadOlderBtn} onClick={handleLoadOlder} disabled={isLoadingHistory}>
|
|
998
|
+
{isLoadingHistory
|
|
999
|
+
? <><i className="pi pi-spin pi-spinner" style={{ fontSize: "11px" }} /> Loading…</>
|
|
1000
|
+
: <><i className="pi pi-history" style={{ fontSize: "11px" }} /> Load older messages</>
|
|
1001
|
+
}
|
|
1002
|
+
</button>
|
|
1003
|
+
</div>
|
|
1004
|
+
)}
|
|
1005
|
+
|
|
1006
|
+
{isLoadingHistory && messages.length === 0 && (
|
|
1007
|
+
<div className={styles.HistoryLoading}>
|
|
1008
|
+
<i className="pi pi-spin pi-spinner" style={{ fontSize: "16px" }} />
|
|
1009
|
+
<span>Loading conversation…</span>
|
|
1010
|
+
</div>
|
|
1011
|
+
)}
|
|
1012
|
+
|
|
1013
|
+
{messages.length === 0 && !isLoadingHistory && (
|
|
1014
|
+
<div className={styles.EmptyState}>
|
|
1015
|
+
<div className={styles.EmptyIcon}>
|
|
1016
|
+
<i className="pi pi-comments" style={{ fontSize: "28px" }} />
|
|
1017
|
+
</div>
|
|
1018
|
+
<p className={styles.EmptyTitle}>How can I help you today?</p>
|
|
1019
|
+
<p className={styles.EmptySubtitle}>Ask me anything about your SolidX project.</p>
|
|
1020
|
+
</div>
|
|
1021
|
+
)}
|
|
1022
|
+
|
|
1023
|
+
{groupMessages(messages).map((group) => {
|
|
1024
|
+
// ── User message ────────────────────────────────────────
|
|
1025
|
+
if (group.kind === "user") {
|
|
1026
|
+
const msg = group.msg;
|
|
1027
|
+
return (
|
|
1028
|
+
<div key={msg.id} className={`${styles.Row} ${styles.RowUser}`}>
|
|
1029
|
+
{/* Avatar first in DOM → rightmost with row-reverse */}
|
|
1030
|
+
<div className={`${styles.Avatar} ${styles.AvatarUser}`}>
|
|
1031
|
+
<i className="pi pi-user" style={{ fontSize: "12px" }} />
|
|
1032
|
+
</div>
|
|
1033
|
+
<div className={styles.BubbleGroup}>
|
|
1034
|
+
<div className={`${styles.Bubble} ${styles.BubbleUser}`}>
|
|
1035
|
+
<p className={styles.UserText}>{msg.content}</p>
|
|
1036
|
+
</div>
|
|
1037
|
+
<div className={`${styles.BubbleMeta} ${styles.BubbleMetaUser}`}>
|
|
1038
|
+
<span className={styles.Timestamp}>
|
|
1039
|
+
{msg.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
|
1040
|
+
</span>
|
|
1041
|
+
<CopyButton text={msg.content} />
|
|
1042
|
+
</div>
|
|
1043
|
+
</div>
|
|
1044
|
+
</div>
|
|
1045
|
+
);
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// ── AI turn: avatar once, events + response stacked ─────
|
|
1049
|
+
return (
|
|
1050
|
+
<div key={group.groupKey} className={styles.AiTurnGroup}>
|
|
1051
|
+
<div className={styles.Avatar}>
|
|
1052
|
+
<i className="pi pi-android" style={{ fontSize: "12px" }} />
|
|
1053
|
+
</div>
|
|
1054
|
+
<div className={styles.AiTurnContent}>
|
|
1055
|
+
{group.msgs.flatMap((msg) => {
|
|
1056
|
+
const items: React.ReactNode[] = [];
|
|
1057
|
+
|
|
1058
|
+
if (msg.role === "event") {
|
|
1059
|
+
const active = msg.eventState === "active";
|
|
1060
|
+
|
|
1061
|
+
// ── Tool call card ──────────────────────────────
|
|
1062
|
+
if (msg.toolName) {
|
|
1063
|
+
const status = msg.toolStatus || (active ? "running" : "success");
|
|
1064
|
+
const cardClass = status === "running" ? styles.ToolCardRunning :
|
|
1065
|
+
status === "warning" ? styles.ToolCardWarning :
|
|
1066
|
+
status === "error" ? styles.ToolCardError :
|
|
1067
|
+
styles.ToolCardDone;
|
|
1068
|
+
|
|
1069
|
+
const statusIcon = status === "running" ? "pi-spin pi-spinner" :
|
|
1070
|
+
status === "warning" ? "pi-exclamation-triangle" :
|
|
1071
|
+
status === "error" ? "pi-times-circle" :
|
|
1072
|
+
"pi-check-circle";
|
|
1073
|
+
|
|
1074
|
+
const statusIconClass = status === "warning" ? styles.ToolCardStatusWarning :
|
|
1075
|
+
status === "error" ? styles.ToolCardStatusError :
|
|
1076
|
+
status === "success" ? styles.ToolCardStatusDone :
|
|
1077
|
+
styles.ToolCardStatusIcon;
|
|
1078
|
+
|
|
1079
|
+
const hasExpandable = !!msg.toolOutput || !!msg.toolArgs;
|
|
1080
|
+
const isExpanded = expandedToolIds.has(msg.id);
|
|
1081
|
+
const toggleExpand = hasExpandable && status !== "running"
|
|
1082
|
+
? () => setExpandedToolIds((prev) => {
|
|
1083
|
+
const next = new Set(prev);
|
|
1084
|
+
if (next.has(msg.id)) next.delete(msg.id); else next.add(msg.id);
|
|
1085
|
+
return next;
|
|
1086
|
+
})
|
|
1087
|
+
: undefined;
|
|
1088
|
+
|
|
1089
|
+
items.push(
|
|
1090
|
+
<div key={msg.id} className={`${styles.ToolCard} ${cardClass}`}>
|
|
1091
|
+
<button
|
|
1092
|
+
className={styles.ToolCardHeaderBtn}
|
|
1093
|
+
onClick={toggleExpand}
|
|
1094
|
+
data-expandable={hasExpandable && status !== "running" ? "true" : undefined}
|
|
1095
|
+
>
|
|
1096
|
+
<i className={`pi ${statusIcon} ${statusIconClass}`} />
|
|
1097
|
+
<i className={`pi ${getToolIconClass(msg.toolName)} ${styles.ToolCardToolIcon}`} />
|
|
1098
|
+
<span className={styles.ToolCardName}>{msg.toolName}</span>
|
|
1099
|
+
{msg.toolDesc && <span className={styles.ToolCardDesc} title={msg.toolDesc}>{msg.toolDesc}</span>}
|
|
1100
|
+
<span className={styles.ToolCardBadge}>{getToolCategory(msg.toolName)}</span>
|
|
1101
|
+
{msg.durationMs != null && (
|
|
1102
|
+
<span className={styles.ToolCardDuration}>{formatDuration(msg.durationMs)}</span>
|
|
1103
|
+
)}
|
|
1104
|
+
{hasExpandable && status !== "running" && (
|
|
1105
|
+
<i className={`pi pi-chevron-right ${styles.ToolCardChevron} ${isExpanded ? styles.ToolCardChevronOpen : ""}`} />
|
|
1106
|
+
)}
|
|
1107
|
+
</button>
|
|
1108
|
+
{/* Progress substeps: always visible */}
|
|
1109
|
+
{msg.toolProgress && msg.toolProgress.length > 0 && (
|
|
1110
|
+
<div className={styles.ToolCardProgress}>
|
|
1111
|
+
{msg.toolProgress.map((p, i) => (
|
|
1112
|
+
<div key={i} className={`${styles.ToolProgressItem} ${p.status === "running" ? styles.ToolProgressRunning : styles.ToolProgressDone}`}>
|
|
1113
|
+
<i className={`pi ${p.status === "running" ? "pi-spin pi-spinner" : "pi-check-circle"}`} style={{ fontSize: "10px" }} />
|
|
1114
|
+
<span className={styles.ToolProgressLabel}>{p.label}</span>
|
|
1115
|
+
{p.durationMs != null && p.status === "done" && (
|
|
1116
|
+
<span className={styles.ToolProgressDuration}>{formatDuration(p.durationMs)}</span>
|
|
1117
|
+
)}
|
|
1118
|
+
</div>
|
|
1119
|
+
))}
|
|
1120
|
+
</div>
|
|
1121
|
+
)}
|
|
1122
|
+
{isExpanded && (msg.toolArgs || msg.toolOutput) && (
|
|
1123
|
+
<div className={styles.ToolCardExpandable}>
|
|
1124
|
+
{msg.toolArgs && Object.keys(msg.toolArgs).length > 0 && (
|
|
1125
|
+
<div className={styles.ToolCardOutput}>
|
|
1126
|
+
<div className={styles.ToolOutputHeader}>
|
|
1127
|
+
<span>Arguments</span>
|
|
1128
|
+
</div>
|
|
1129
|
+
<pre className={styles.ToolOutputText}>
|
|
1130
|
+
{JSON.stringify(msg.toolArgs, null, 2)}
|
|
1131
|
+
</pre>
|
|
1132
|
+
</div>
|
|
1133
|
+
)}
|
|
1134
|
+
{msg.toolOutput && (
|
|
1135
|
+
<div className={styles.ToolCardOutput}>
|
|
1136
|
+
<div className={styles.ToolOutputHeader}>
|
|
1137
|
+
<span>Result</span>
|
|
1138
|
+
</div>
|
|
1139
|
+
<pre className={`${styles.ToolOutputText} ${status === "warning" ? styles.ToolOutputWarning : status === "error" ? styles.ToolOutputError : ""}`}>
|
|
1140
|
+
{msg.toolOutputRaw || msg.toolOutput}
|
|
1141
|
+
</pre>
|
|
1142
|
+
</div>
|
|
1143
|
+
)}
|
|
1144
|
+
</div>
|
|
1145
|
+
)}
|
|
1146
|
+
</div>
|
|
1147
|
+
);
|
|
1148
|
+
return items;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// ── Thinking bubble: only show while active ──────
|
|
1152
|
+
if (active) {
|
|
1153
|
+
items.push(
|
|
1154
|
+
<div key={msg.id} className={styles.ThinkingBubble}>
|
|
1155
|
+
<div className={styles.ThinkingDots}>
|
|
1156
|
+
<span className={styles.ThinkingDot} />
|
|
1157
|
+
<span className={styles.ThinkingDot} />
|
|
1158
|
+
<span className={styles.ThinkingDot} />
|
|
1159
|
+
</div>
|
|
1160
|
+
<span className={styles.ThinkingLabel}>Thinking…</span>
|
|
1161
|
+
</div>
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
return items;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// ── Error bubble ────────────────────────────────────
|
|
1168
|
+
if (msg.isError) {
|
|
1169
|
+
items.push(
|
|
1170
|
+
<div key={msg.id} className={styles.ErrorBubble}>
|
|
1171
|
+
<div className={styles.ErrorIcon}>
|
|
1172
|
+
<i className="pi pi-exclamation-circle" style={{ fontSize: "13px" }} />
|
|
1173
|
+
</div>
|
|
1174
|
+
<div className={styles.ErrorContent}>
|
|
1175
|
+
<p className={styles.ErrorTitle}>Error</p>
|
|
1176
|
+
<p className={styles.ErrorText}>{msg.content}</p>
|
|
1177
|
+
</div>
|
|
1178
|
+
</div>
|
|
1179
|
+
);
|
|
1180
|
+
return items;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// ── Assistant message ───────────────────────────────
|
|
1184
|
+
const isStreaming = msg.id.startsWith("stream-") && isProcessing;
|
|
1185
|
+
items.push(
|
|
1186
|
+
<div key={msg.id} className={styles.BubbleGroup}>
|
|
1187
|
+
<div className={`${styles.Bubble} ${styles.BubbleAssistant}`}>
|
|
1188
|
+
<div className={styles.MarkdownRoot}>
|
|
1189
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
|
1190
|
+
{msg.content}
|
|
1191
|
+
</ReactMarkdown>
|
|
1192
|
+
</div>
|
|
1193
|
+
{isStreaming && <span className={styles.StreamingCursor} />}
|
|
1194
|
+
</div>
|
|
1195
|
+
<div className={`${styles.BubbleMeta} ${styles.BubbleMetaAssistant}`}>
|
|
1196
|
+
<span className={styles.Timestamp}>
|
|
1197
|
+
{msg.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
|
1198
|
+
</span>
|
|
1199
|
+
<CopyButton text={msg.content} />
|
|
1200
|
+
</div>
|
|
1201
|
+
</div>
|
|
1202
|
+
);
|
|
1203
|
+
return items;
|
|
1204
|
+
})}
|
|
1205
|
+
</div>
|
|
1206
|
+
</div>
|
|
1207
|
+
);
|
|
1208
|
+
})}
|
|
1209
|
+
</div>
|
|
1210
|
+
|
|
1211
|
+
{/* ── Input area ── */}
|
|
1212
|
+
<div className={styles.InputArea}>
|
|
1213
|
+
<div className={`${styles.InputBox} ${!isConnected || !sessionId ? styles.InputBoxDisabled : ""}`}>
|
|
1214
|
+
<textarea
|
|
1215
|
+
ref={textareaRef}
|
|
1216
|
+
className={styles.Textarea}
|
|
1217
|
+
placeholder={inputPlaceholder}
|
|
1218
|
+
value={input}
|
|
1219
|
+
onChange={autoResize}
|
|
1220
|
+
onKeyDown={handleKeyDown}
|
|
1221
|
+
disabled={!isConnected || !sessionId || isProcessing}
|
|
1222
|
+
rows={1}
|
|
1223
|
+
/>
|
|
1224
|
+
<button
|
|
1225
|
+
className={`${styles.SendBtn} ${canSend ? styles.SendBtnActive : ""}`}
|
|
1226
|
+
disabled={!canSend}
|
|
1227
|
+
onClick={handleSend}
|
|
1228
|
+
aria-label="Send message"
|
|
1229
|
+
>
|
|
1230
|
+
<i className="pi pi-send" style={{ fontSize: "12px" }} />
|
|
1231
|
+
</button>
|
|
1232
|
+
</div>
|
|
1233
|
+
</div>
|
|
1234
|
+
|
|
1235
|
+
</div>
|
|
1236
|
+
);
|
|
1237
|
+
};
|