@k2b/cloud 0.6.0-rc.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/README.md +98 -0
- package/package.json +128 -0
- package/public/logo.svg +1 -0
- package/scripts/app-favicon.test.ts +49 -0
- package/scripts/app-favicon.ts +51 -0
- package/scripts/build.ts +205 -0
- package/scripts/preload.ts +164 -0
- package/scripts/sync-recovery-smoke.ts +200 -0
- package/src/_internal/app-favicon.ts +2 -0
- package/src/_internal/app-presentation.ts +61 -0
- package/src/_internal/app-workspace-state.ts +24 -0
- package/src/_internal/bounded-json.ts +47 -0
- package/src/_internal/build-metadata.ts +9 -0
- package/src/_internal/capabilities.ts +1118 -0
- package/src/_internal/define-app.ts +801 -0
- package/src/_internal/heartbeat.ts +167 -0
- package/src/_internal/help-catalog.ts +241 -0
- package/src/_internal/help.ts +95 -0
- package/src/_internal/nats-connection.ts +34 -0
- package/src/_internal/page-responses.ts +40 -0
- package/src/_internal/process-sync.ts +117 -0
- package/src/_internal/readiness.ts +3 -0
- package/src/_internal/registry-validation.ts +136 -0
- package/src/_internal/registry-watch.ts +52 -0
- package/src/_internal/registry.ts +338 -0
- package/src/_internal/runtime-compatibility.ts +64 -0
- package/src/_internal/runtime-context.ts +84 -0
- package/src/_internal/runtime-watcher.ts +78 -0
- package/src/_internal/static-assets.ts +86 -0
- package/src/_internal/status-preserving-ssr.ts +49 -0
- package/src/access/PermissionEditor.tsx +486 -0
- package/src/access/ResourceApiKeys.tsx +271 -0
- package/src/access/messages.ts +121 -0
- package/src/access/ui.ts +16 -0
- package/src/account/Avatar.tsx +28 -0
- package/src/account/EntitySearch.tsx +363 -0
- package/src/account/avatar-upload-dialog.tsx +197 -0
- package/src/account/avatar-upload.ts +149 -0
- package/src/account/ui.ts +3 -0
- package/src/ai/admin.ts +47 -0
- package/src/ai/approval-routes.ts +130 -0
- package/src/ai/approvals.ts +139 -0
- package/src/ai/assistant-models.ts +61 -0
- package/src/ai/attachments.ts +40 -0
- package/src/ai/browser.ts +92 -0
- package/src/ai/capabilities.ts +714 -0
- package/src/ai/capability-execution.ts +151 -0
- package/src/ai/chat/blocks.render.test.tsx +1108 -0
- package/src/ai/chat/blocks.tsx +730 -0
- package/src/ai/chat/builtin-tools.tsx +428 -0
- package/src/ai/chat/capability-table.tsx +87 -0
- package/src/ai/chat/composer-adapter.ts +169 -0
- package/src/ai/chat/file-tools.tsx +81 -0
- package/src/ai/chat/message-actions.tsx +370 -0
- package/src/ai/chat/message-utils.ts +479 -0
- package/src/ai/chat/messages.ts +43 -0
- package/src/ai/chat/presentation.tsx +395 -0
- package/src/ai/chat/primitives.tsx +3 -0
- package/src/ai/chat/tool-disclosure.tsx +41 -0
- package/src/ai/chat/turn-navigator-utils.ts +28 -0
- package/src/ai/chat/turn-navigator.tsx +164 -0
- package/src/ai/chat/user-message.tsx +149 -0
- package/src/ai/chat/visual-tools.tsx +435 -0
- package/src/ai/chat/web-tools.tsx +202 -0
- package/src/ai/chat-task-contracts.ts +113 -0
- package/src/ai/chat-tasks.ts +952 -0
- package/src/ai/client/controller.ts +1211 -0
- package/src/ai/client/file-source.ts +95 -0
- package/src/ai/client/live-connection.ts +140 -0
- package/src/ai/client/projection.ts +178 -0
- package/src/ai/client/transport.ts +111 -0
- package/src/ai/compaction.ts +182 -0
- package/src/ai/credentials.ts +124 -0
- package/src/ai/default-tool-contracts.ts +108 -0
- package/src/ai/default-tools.ts +127 -0
- package/src/ai/enrich.ts +280 -0
- package/src/ai/executor.ts +1624 -0
- package/src/ai/fetch-file-tool.ts +313 -0
- package/src/ai/file-context.ts +92 -0
- package/src/ai/file-media-type.ts +28 -0
- package/src/ai/file-mount.ts +16 -0
- package/src/ai/file-tools.ts +566 -0
- package/src/ai/files-store.ts +390 -0
- package/src/ai/firecrawl-tools.ts +305 -0
- package/src/ai/grids-skill.ts +126 -0
- package/src/ai/http.ts +265 -0
- package/src/ai/index.ts +337 -0
- package/src/ai/kit-skill.ts +22 -0
- package/src/ai/limits.ts +5 -0
- package/src/ai/live-events.ts +289 -0
- package/src/ai/live-messages.ts +45 -0
- package/src/ai/live-outbox.ts +102 -0
- package/src/ai/live-routes.ts +412 -0
- package/src/ai/live.ts +2 -0
- package/src/ai/maintenance.ts +302 -0
- package/src/ai/markdown-pdf-tool.ts +95 -0
- package/src/ai/memories.ts +605 -0
- package/src/ai/memory-learning-runs.ts +222 -0
- package/src/ai/memory-learning.ts +619 -0
- package/src/ai/memory-tool.ts +120 -0
- package/src/ai/memory-workflow-evidence.ts +158 -0
- package/src/ai/migrate.ts +2082 -0
- package/src/ai/model-access-migrate.ts +30 -0
- package/src/ai/model-access.ts +191 -0
- package/src/ai/personal-agent.ts +6 -0
- package/src/ai/prefs.ts +72 -0
- package/src/ai/project-tool.ts +94 -0
- package/src/ai/projects-routes.ts +277 -0
- package/src/ai/projects.ts +902 -0
- package/src/ai/protocol.ts +279 -0
- package/src/ai/provider.ts +73 -0
- package/src/ai/public-projection.ts +25 -0
- package/src/ai/resource-markers.ts +31 -0
- package/src/ai/resource-refs.ts +70 -0
- package/src/ai/routes.ts +1131 -0
- package/src/ai/runtime-tools.ts +39 -0
- package/src/ai/runtime.ts +544 -0
- package/src/ai/settings.ts +529 -0
- package/src/ai/short-id.ts +45 -0
- package/src/ai/skill-catalog.ts +73 -0
- package/src/ai/skill-format.ts +145 -0
- package/src/ai/skill-seeds.ts +430 -0
- package/src/ai/skill-tool.ts +81 -0
- package/src/ai/skills-routes.ts +143 -0
- package/src/ai/skills.ts +934 -0
- package/src/ai/solid.ts +27 -0
- package/src/ai/store.ts +3113 -0
- package/src/ai/stream.ts +308 -0
- package/src/ai/structured-runs.ts +66 -0
- package/src/ai/structured.ts +186 -0
- package/src/ai/system-prompt.ts +170 -0
- package/src/ai/task-prompt.ts +22 -0
- package/src/ai/timeline.ts +148 -0
- package/src/ai/tool-audit.ts +177 -0
- package/src/ai/tool-result-budget.ts +16 -0
- package/src/ai/tools.ts +155 -0
- package/src/ai/types.ts +967 -0
- package/src/ai/ui.tsx +39 -0
- package/src/ai/usage-migrate.ts +81 -0
- package/src/ai/usage.ts +315 -0
- package/src/ai/validate.ts +54 -0
- package/src/ai/vision-tool.ts +79 -0
- package/src/api/accounts-entities.ts +174 -0
- package/src/api/admin-ai-projects.ts +74 -0
- package/src/api/admin-ai-skills.ts +76 -0
- package/src/api/admin-ai-usage.ts +42 -0
- package/src/api/admin-core-settings.ts +508 -0
- package/src/api/admin-identity.ts +148 -0
- package/src/api/admin-lifecycle.ts +206 -0
- package/src/api/admin-linux-identities.ts +66 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/app-approval.ts +129 -0
- package/src/api/apps.ts +51 -0
- package/src/api/auth/schemas.ts +54 -0
- package/src/api/auth.ts +370 -0
- package/src/api/capabilities.ts +658 -0
- package/src/api/help-messages.ts +19 -0
- package/src/api/help.ts +78 -0
- package/src/api/index.ts +108 -0
- package/src/api/mcp.ts +744 -0
- package/src/api/me-rail.ts +45 -0
- package/src/api/me.ts +753 -0
- package/src/api/search/schemas.ts +79 -0
- package/src/api/search.bench.ts +185 -0
- package/src/api/search.ts +407 -0
- package/src/api/sync-ops.ts +79 -0
- package/src/api/widgets.ts +143 -0
- package/src/browser/CloudResourceSearch.behavior.test.tsx +74 -0
- package/src/browser/CloudResourceSearch.tsx +791 -0
- package/src/browser/app-approval-vault.ts +305 -0
- package/src/browser/app-approval.ts +234 -0
- package/src/browser/live-websocket.ts +268 -0
- package/src/browser/locale-preference.ts +25 -0
- package/src/browser/notification-target.ts +27 -0
- package/src/browser/notification-timeout.ts +14 -0
- package/src/browser/notifications.ts +181 -0
- package/src/browser/resource-clipboard.ts +70 -0
- package/src/browser/resource-picker.tsx +39 -0
- package/src/browser/resource-search-messages.ts +55 -0
- package/src/browser/resource-search.ts +27 -0
- package/src/capabilities/client.ts +149 -0
- package/src/capabilities/response.ts +39 -0
- package/src/capabilities/server.ts +215 -0
- package/src/capabilities/signals.ts +4 -0
- package/src/capabilities/testing.ts +12 -0
- package/src/capabilities/types.ts +32 -0
- package/src/cli/access.ts +499 -0
- package/src/cli/account.ts +382 -0
- package/src/cli/admin/account-administration.ts +45 -0
- package/src/cli/admin/account-categories.ts +49 -0
- package/src/cli/admin/ai-usage.ts +118 -0
- package/src/cli/admin/app-sign-in.ts +49 -0
- package/src/cli/admin/data.ts +343 -0
- package/src/cli/admin/documentation.ts +34 -0
- package/src/cli/admin/gateway.ts +155 -0
- package/src/cli/admin/index.ts +61 -0
- package/src/cli/admin/instance.ts +250 -0
- package/src/cli/admin/jobs.ts +223 -0
- package/src/cli/admin/legal.ts +122 -0
- package/src/cli/admin/linux.ts +53 -0
- package/src/cli/admin/logs.ts +324 -0
- package/src/cli/admin/metrics.ts +142 -0
- package/src/cli/admin/nats.ts +167 -0
- package/src/cli/admin/notifications.ts +516 -0
- package/src/cli/admin/shared.ts +113 -0
- package/src/cli/admin/sync.ts +188 -0
- package/src/cli/admin/telemetry.ts +314 -0
- package/src/cli/admin/webhooks.ts +222 -0
- package/src/cli/admin/workflows.ts +367 -0
- package/src/cli/apps.ts +40 -0
- package/src/cli/capabilities.ts +239 -0
- package/src/cli/commands.ts +604 -0
- package/src/cli/index.ts +61 -0
- package/src/cli/locale.ts +31 -0
- package/src/cli/output.ts +58 -0
- package/src/clients/core.ts +20 -0
- package/src/config/env.ts +54 -0
- package/src/config/index.ts +6 -0
- package/src/contracts/account-categories.ts +62 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app-approval.ts +197 -0
- package/src/contracts/app.ts +162 -0
- package/src/contracts/capabilities.ts +484 -0
- package/src/contracts/index.ts +14 -0
- package/src/contracts/notification-live.ts +66 -0
- package/src/contracts/notification-types.ts +235 -0
- package/src/contracts/posix.ts +69 -0
- package/src/contracts/profile.ts +85 -0
- package/src/contracts/rail-preferences.ts +53 -0
- package/src/contracts/registry.ts +110 -0
- package/src/contracts/resource-clipboard.ts +33 -0
- package/src/contracts/settings-types.ts +98 -0
- package/src/contracts/shared.ts +408 -0
- package/src/contracts/user-notifications.ts +101 -0
- package/src/contracts/widgets.ts +183 -0
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/index.ts +58 -0
- package/src/server/actor.ts +41 -0
- package/src/server/api/index.ts +2 -0
- package/src/server/api/respond.ts +96 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/help.ts +187 -0
- package/src/server/index.ts +89 -0
- package/src/server/locale.ts +55 -0
- package/src/server/middleware/auth.ts +386 -0
- package/src/server/middleware/index.ts +28 -0
- package/src/server/middleware/invocation.ts +55 -0
- package/src/server/middleware/middleware.ts +65 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +125 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/route-template.ts +46 -0
- package/src/server/middleware/runtime.ts +22 -0
- package/src/server/middleware/settings.ts +55 -0
- package/src/server/middleware/validator.ts +49 -0
- package/src/server/middleware/workload.ts +22 -0
- package/src/server/ratelimit.ts +151 -0
- package/src/server/request-cookies.ts +18 -0
- package/src/server/services/access.ts +644 -0
- package/src/server/services/freeipa/client.ts +137 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +123 -0
- package/src/server/services/freeipa/test-certificates.ts +44 -0
- package/src/server/services/freeipa/tls.ts +44 -0
- package/src/server/services/freeipa/transport.ts +116 -0
- package/src/server/services/freeipa/util.ts +59 -0
- package/src/server/services/geo.ts +198 -0
- package/src/server/services/index.ts +48 -0
- package/src/server/services/pagination.ts +22 -0
- package/src/server/services/services.ts +13 -0
- package/src/server/time.ts +33 -0
- package/src/services/account-category-policy.ts +51 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +940 -0
- package/src/services/account-lifecycle/ipa-backfill.ts +152 -0
- package/src/services/account-lifecycle/notification-sender.ts +13 -0
- package/src/services/account-lifecycle/scheduler.ts +506 -0
- package/src/services/account-model.ts +22 -0
- package/src/services/accounts/app.ts +1671 -0
- package/src/services/accounts/authz.ts +44 -0
- package/src/services/accounts/avatar.ts +120 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +46 -0
- package/src/services/accounts/duplicate-emails.ts +82 -0
- package/src/services/accounts/entities.ts +622 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +272 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +66 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +499 -0
- package/src/services/accounts/model.ts +129 -0
- package/src/services/accounts/notification-sender.ts +31 -0
- package/src/services/accounts/posix.ts +386 -0
- package/src/services/accounts/request-policy.ts +11 -0
- package/src/services/accounts/switching.ts +126 -0
- package/src/services/accounts/users.ts +745 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/app-approval.ts +498 -0
- package/src/services/audit/index.ts +438 -0
- package/src/services/auth-flows/index.ts +15 -0
- package/src/services/auth-flows/ipa.ts +197 -0
- package/src/services/auth-flows/magic-link.ts +185 -0
- package/src/services/auth-flows/notification-sender.ts +10 -0
- package/src/services/auth-flows/password-reset.ts +268 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/document-extraction.ts +148 -0
- package/src/services/freeipa-config.ts +179 -0
- package/src/services/gateway.ts +186 -0
- package/src/services/identity/constants.ts +18 -0
- package/src/services/identity/index.ts +52 -0
- package/src/services/identity/invocation-actor.ts +120 -0
- package/src/services/identity/invocation-authority.ts +53 -0
- package/src/services/identity/invocation-operations.ts +15 -0
- package/src/services/identity/invocation-token.ts +276 -0
- package/src/services/identity/key-config.ts +41 -0
- package/src/services/identity/key-ring.ts +530 -0
- package/src/services/identity/metrics.ts +29 -0
- package/src/services/identity/routes.ts +18 -0
- package/src/services/identity/runtime-config.ts +88 -0
- package/src/services/identity/session-token.ts +152 -0
- package/src/services/identity/signer-refresh.ts +44 -0
- package/src/services/identity/workload-auth.ts +64 -0
- package/src/services/index.ts +187 -0
- package/src/services/ipa/auth.ts +216 -0
- package/src/services/ipa/connection.ts +57 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/groups.ts +722 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/posix.ts +19 -0
- package/src/services/ipa/profile.ts +132 -0
- package/src/services/ipa/runtime.ts +28 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.ts +100 -0
- package/src/services/ipa/sync.ts +862 -0
- package/src/services/ipa/users.ts +806 -0
- package/src/services/legal-consent.ts +76 -0
- package/src/services/logging/index.ts +477 -0
- package/src/services/logging/redaction.ts +20 -0
- package/src/services/logging/trace.ts +1231 -0
- package/src/services/mandates/index.ts +855 -0
- package/src/services/mandates/policy.ts +85 -0
- package/src/services/network-security.ts +31 -0
- package/src/services/notifications/batches.ts +722 -0
- package/src/services/notifications/browser.ts +252 -0
- package/src/services/notifications/catalog.ts +113 -0
- package/src/services/notifications/channels.ts +104 -0
- package/src/services/notifications/dispatcher.ts +139 -0
- package/src/services/notifications/email.ts +118 -0
- package/src/services/notifications/index.ts +633 -0
- package/src/services/notifications/live.ts +50 -0
- package/src/services/notifications/observability.ts +407 -0
- package/src/services/notifications/platform.ts +421 -0
- package/src/services/notifications/runtime.ts +120 -0
- package/src/services/notifications/user.ts +248 -0
- package/src/services/notifications/web-push-transport.ts +135 -0
- package/src/services/oauth-tokens.ts +209 -0
- package/src/services/outbox.ts +187 -0
- package/src/services/pdf/gotenberg.ts +290 -0
- package/src/services/pdf/index.ts +43 -0
- package/src/services/pdf/markdown.ts +209 -0
- package/src/services/pdf/template-preview.ts +122 -0
- package/src/services/postgres.ts +76 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +51 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +263 -0
- package/src/services/rail-preferences.ts +27 -0
- package/src/services/runtime-lifecycle.ts +191 -0
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.ts +891 -0
- package/src/services/service-accounts.ts +184 -0
- package/src/services/session/index.ts +236 -0
- package/src/services/session/test-fixture.ts +18 -0
- package/src/services/session/user.ts +144 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +86 -0
- package/src/services/settings/core-settings.ts +796 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +374 -0
- package/src/services/settings/index.ts +215 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +241 -0
- package/src/services/settings/templates.ts +4 -0
- package/src/services/sync-ops.ts +403 -0
- package/src/services/topic-cursor.ts +22 -0
- package/src/services/weather/forecast.ts +323 -0
- package/src/services/weather/geo.ts +119 -0
- package/src/services/weather/index.ts +67 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +133 -0
- package/src/services/weather/migrate.ts +45 -0
- package/src/services/weather/settings.ts +66 -0
- package/src/services/weather/short-id.ts +57 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/services/webauthn.ts +385 -0
- package/src/shared/account-action-notice.ts +70 -0
- package/src/shared/account-display.ts +15 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/ai-platform-prompt.ts +134 -0
- package/src/shared/ai-usage.ts +49 -0
- package/src/shared/app-presentation.ts +48 -0
- package/src/shared/app-url.ts +12 -0
- package/src/shared/branding.ts +14 -0
- package/src/shared/capability-messages.ts +70 -0
- package/src/shared/email-html.ts +54 -0
- package/src/shared/format.ts +91 -0
- package/src/shared/help.ts +68 -0
- package/src/shared/icons.ts +400 -0
- package/src/shared/index.ts +54 -0
- package/src/shared/locale.ts +48 -0
- package/src/shared/login-method.ts +44 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +105 -0
- package/src/shared/markdown/extensions/guided-help.ts +100 -0
- package/src/shared/markdown/extensions/images.ts +79 -0
- package/src/shared/markdown/extensions/info-blocks.ts +97 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +43 -0
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +109 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +193 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/network-address.ts +106 -0
- package/src/shared/redirect.ts +56 -0
- package/src/shared/template-rendering.ts +140 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +53 -0
- package/src/ssr/AdminSidebar.tsx +50 -0
- package/src/ssr/AppLaunchpad.island.tsx +236 -0
- package/src/ssr/AppWorkspaceController.island.tsx +21 -0
- package/src/ssr/BrowserNotifications.island.tsx +135 -0
- package/src/ssr/GlobalAnnouncements.island.tsx +142 -0
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +96 -0
- package/src/ssr/GlobalSearchDialog.tsx +35 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +104 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +55 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +55 -0
- package/src/ssr/Layout.render.test.tsx +148 -0
- package/src/ssr/Layout.tsx +292 -0
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutFooter.tsx +22 -0
- package/src/ssr/LayoutHeader.tsx +81 -0
- package/src/ssr/LayoutHelp.tsx +869 -0
- package/src/ssr/LayoutPreferences.island.tsx +36 -0
- package/src/ssr/LayoutRail.tsx +58 -0
- package/src/ssr/MinimalLayout.tsx +46 -0
- package/src/ssr/PageError.tsx +29 -0
- package/src/ssr/ProfilePreferences.island.tsx +92 -0
- package/src/ssr/RailApps.island.tsx +61 -0
- package/src/ssr/RailEditor.tsx +293 -0
- package/src/ssr/RegisteredHelpDocuments.island.tsx +3 -0
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/admin-active-link.ts +47 -0
- package/src/ssr/admin-navigation.ts +78 -0
- package/src/ssr/app-appearance.ts +23 -0
- package/src/ssr/app-navigation.ts +41 -0
- package/src/ssr/help-messages.ts +104 -0
- package/src/ssr/index.ts +14 -0
- package/src/ssr/islands/SearchBar.island.tsx +80 -0
- package/src/ssr/islands/index.ts +16 -0
- package/src/ssr/layout-context.ts +17 -0
- package/src/ssr/layout-help-markdown.ts +9 -0
- package/src/ssr/layout-help-navigation.ts +24 -0
- package/src/ssr/layout-help-url.ts +5 -0
- package/src/ssr/layout-runtime.ts +22 -0
- package/src/ssr/page-error-messages.ts +25 -0
- package/src/ssr/platform-messages.ts +135 -0
- package/src/ssr/preference-controller.ts +44 -0
- package/src/ssr/profile-preferences-messages.ts +29 -0
- package/src/ssr/rail-context.ts +21 -0
- package/src/ssr/rail-messages.ts +66 -0
- package/src/ssr/rail-navigation.ts +60 -0
- package/src/ssr/runtime.ts +31 -0
- package/src/ssr/url-filter.ts +127 -0
- package/src/styles/base-popover.css +36 -0
- package/src/styles/effects.css +851 -0
- package/src/styles/global.css +268 -0
- package/src/styles/input.css +58 -0
- package/src/styles/tokens.css +230 -0
- package/src/styles/utilities-buttons.css +405 -0
- package/src/styles/utilities-data.css +33 -0
- package/src/styles/utilities-feedback.css +232 -0
- package/src/styles/utilities-layout.css +603 -0
- package/src/styles/utilities-markdown-editor.css +525 -0
- package/src/styles/utilities-markdown-table.css +234 -0
- package/src/styles/utilities-navigation.css +1054 -0
- package/src/types/ambient.d.ts +24 -0
- package/src/workflows/ai/index.ts +17 -0
- package/src/workflows/ai/runtime.ts +286 -0
- package/src/workflows/ai/store.ts +314 -0
- package/src/workflows/ai/types.ts +111 -0
- package/src/workflows/ai-actions.ts +269 -0
- package/src/workflows/authoring.ts +88 -0
- package/src/workflows/builtins.ts +193 -0
- package/src/workflows/contracts.ts +280 -0
- package/src/workflows/definition.ts +334 -0
- package/src/workflows/editor.ts +113 -0
- package/src/workflows/index.ts +71 -0
- package/src/workflows/language/binder.ts +45 -0
- package/src/workflows/language/canonical.ts +29 -0
- package/src/workflows/language/compiler.ts +396 -0
- package/src/workflows/language/expressions.ts +34 -0
- package/src/workflows/language/index.ts +20 -0
- package/src/workflows/language/references.ts +55 -0
- package/src/workflows/language/schema.ts +134 -0
- package/src/workflows/language/strict-yaml.ts +131 -0
- package/src/workflows/manifest.ts +24 -0
- package/src/workflows/module.ts +12 -0
- package/src/workflows/runtime/coordinator.ts +115 -0
- package/src/workflows/runtime/executor.ts +1003 -0
- package/src/workflows/runtime/index.ts +60 -0
- package/src/workflows/runtime/instant.ts +8 -0
- package/src/workflows/runtime/ports.ts +213 -0
- package/src/workflows/runtime/schedule.ts +203 -0
- package/src/workflows/runtime/trigger.ts +37 -0
- package/src/workflows/runtime/wake.ts +13 -0
- package/src/workflows/store/actions.ts +438 -0
- package/src/workflows/store/budget.ts +145 -0
- package/src/workflows/store/definitions.ts +345 -0
- package/src/workflows/store/events.ts +383 -0
- package/src/workflows/store/index.ts +107 -0
- package/src/workflows/store/observability.ts +594 -0
- package/src/workflows/store/runs.ts +975 -0
- package/src/workflows/store/transaction.ts +23 -0
- package/src/workflows/store/worker.ts +439 -0
- package/src/workflows/testing/conformance.ts +72 -0
- package/src/workflows/testing/index.ts +11 -0
- package/src/workflows/testing/process-fixtures.ts +275 -0
|
@@ -0,0 +1,1211 @@
|
|
|
1
|
+
import { type Accessor, createMemo, createSignal, onCleanup } from "solid-js";
|
|
2
|
+
import { createStore, reconcile } from "solid-js/store";
|
|
3
|
+
import { type AiAttachmentRef, aiAttachmentMarker } from "../attachments";
|
|
4
|
+
import { type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
|
|
5
|
+
import { type AiResourceMarker, aiResourceMarker } from "../resource-markers";
|
|
6
|
+
import type {
|
|
7
|
+
AiConversation,
|
|
8
|
+
AiConversationTimelineEntry,
|
|
9
|
+
AiDraftContentPart,
|
|
10
|
+
AiMessageFeedback,
|
|
11
|
+
AiStoredMessage,
|
|
12
|
+
AiTurn,
|
|
13
|
+
AiTurnSteer,
|
|
14
|
+
AiUserContentPart,
|
|
15
|
+
} from "../types";
|
|
16
|
+
import {
|
|
17
|
+
type AiChatProjection,
|
|
18
|
+
activeTurnFromSnapshot,
|
|
19
|
+
emptyProjection,
|
|
20
|
+
mergeActiveTurn,
|
|
21
|
+
reconcileActiveTurnActions,
|
|
22
|
+
reduceProjection,
|
|
23
|
+
visibleMessages,
|
|
24
|
+
} from "./projection";
|
|
25
|
+
import { type AiConversationStreamTransport, type AiStreamHandle, aiSseConversationStreamTransport } from "./transport";
|
|
26
|
+
|
|
27
|
+
type ComposerDraftInput = {
|
|
28
|
+
message?: string;
|
|
29
|
+
content?: AiUserContentPart[];
|
|
30
|
+
files?: File[];
|
|
31
|
+
resources?: AiResourceMarker[];
|
|
32
|
+
storedFiles?: Array<{ path: string; mediaType: string; size: number; version: number }>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type AiChatRunStatus = "idle" | "streaming" | "waiting_for_action" | "stopping" | "failed";
|
|
36
|
+
export type AiStreamStatus = "idle" | "connecting" | "open" | "reconnecting";
|
|
37
|
+
|
|
38
|
+
export type AiFrontendToolHandler = (request: {
|
|
39
|
+
name: string;
|
|
40
|
+
callId: string;
|
|
41
|
+
args: unknown;
|
|
42
|
+
turnId: string;
|
|
43
|
+
}) => unknown | Promise<unknown>;
|
|
44
|
+
|
|
45
|
+
type AiConversationDetail = {
|
|
46
|
+
conversation: AiConversation;
|
|
47
|
+
messages: AiStoredMessage[];
|
|
48
|
+
hasMoreMessages?: boolean;
|
|
49
|
+
activeTurn: AiTurnSnapshot | null;
|
|
50
|
+
timeline?: AiConversationTimelineEntry[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type AiMessagesPage = { messages: AiStoredMessage[]; hasMore: boolean };
|
|
54
|
+
type SubmitTurnResult = { turn: AiTurn; message: AiStoredMessage };
|
|
55
|
+
type AiStreamSession = { conversationId: string; generation: number };
|
|
56
|
+
|
|
57
|
+
const detailToProjection = (detail: AiConversationDetail): AiChatProjection => ({
|
|
58
|
+
conversation: detail.conversation,
|
|
59
|
+
messages: detail.messages,
|
|
60
|
+
activeTurn: activeTurnFromSnapshot(detail.activeTurn),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const projectionForConversationOpen = (cached: AiChatProjection | undefined, conversation: AiConversation | null): AiChatProjection =>
|
|
64
|
+
cached ?? emptyProjection(conversation);
|
|
65
|
+
|
|
66
|
+
const claimFrontendCall = (handled: Set<string>, inFlight: Set<string>, key: string): boolean => {
|
|
67
|
+
if (handled.has(key) || inFlight.has(key)) return false;
|
|
68
|
+
inFlight.add(key);
|
|
69
|
+
return true;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const settleFrontendCall = (handled: Set<string>, inFlight: Set<string>, key: string, submitted: boolean): void => {
|
|
73
|
+
inFlight.delete(key);
|
|
74
|
+
if (submitted) handled.add(key);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const isCurrentStreamSession = (current: AiStreamSession | null, candidate: AiStreamSession): boolean =>
|
|
78
|
+
current?.conversationId === candidate.conversationId && current.generation === candidate.generation;
|
|
79
|
+
|
|
80
|
+
const DEFAULT_RUN_ERROR = "Assistant response failed.";
|
|
81
|
+
|
|
82
|
+
const conversationRunError = (conversation: AiConversation | null | undefined): string | null =>
|
|
83
|
+
conversation?.runStatus === "failed" ? conversation.runError?.trim() || DEFAULT_RUN_ERROR : null;
|
|
84
|
+
|
|
85
|
+
const runErrorFromEvent = (event: AiStreamEvent, activeTurnId: string | null | undefined): string | null | undefined => {
|
|
86
|
+
if (event.type === "state") return conversationRunError(event.conversation);
|
|
87
|
+
if (event.type !== "turn_finished" || event.turnId !== activeTurnId) return undefined;
|
|
88
|
+
return event.status === "failed" ? event.error?.trim() || DEFAULT_RUN_ERROR : null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type CreateAiChatControllerOptions = {
|
|
92
|
+
/** Global AI API base path, normally "/api/ai". */
|
|
93
|
+
baseUrl: string;
|
|
94
|
+
params?: Record<string, string> | Accessor<Record<string, string>>;
|
|
95
|
+
initialConversationId?: string | null;
|
|
96
|
+
initialDetail?: AiConversationDetail | null;
|
|
97
|
+
initialTimeline?: AiConversationTimelineEntry[];
|
|
98
|
+
initialError?: string | null;
|
|
99
|
+
/** Persist owner read state for conversation-list unread indicators. */
|
|
100
|
+
trackViewedState?: boolean;
|
|
101
|
+
frontendTools?: Record<string, AiFrontendToolHandler>;
|
|
102
|
+
/** Overrides the default SSE transport, for example with a shared WebSocket channel. */
|
|
103
|
+
streamTransport?: AiConversationStreamTransport;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const isAccessor = <T>(value: T | Accessor<T>): value is Accessor<T> => typeof value === "function";
|
|
107
|
+
|
|
108
|
+
const readError = async (response: Response, fallback: string): Promise<string> => {
|
|
109
|
+
const body = await response.json().catch(() => null);
|
|
110
|
+
return body && typeof body === "object" && "message" in body && typeof body.message === "string" ? body.message : fallback;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const reconcileSteerBlocks = (blocks: AiTurnBlock[], localId: string, steer: AiTurnSteer): AiTurnBlock[] => {
|
|
114
|
+
const stableId = steerMessageBlockId(steer.id);
|
|
115
|
+
const stableExists = blocks.some((block) => block.id === stableId);
|
|
116
|
+
const next = blocks.filter((block) => block.id !== localId);
|
|
117
|
+
if (!stableExists) {
|
|
118
|
+
next.push({ id: stableId, kind: "steer_message", steerId: steer.id, text: steer.text, status: "pending" });
|
|
119
|
+
}
|
|
120
|
+
return next;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const failSteerBlock = (blocks: AiTurnBlock[], blockId: string): AiTurnBlock[] =>
|
|
124
|
+
blocks.map((block) => (block.id === blockId && block.kind === "steer_message" ? { ...block, status: "failed" } : block));
|
|
125
|
+
|
|
126
|
+
const completeFrontendToolBlock = (blocks: AiTurnBlock[], callId: string, result: unknown): AiTurnBlock[] =>
|
|
127
|
+
blocks.map((block) =>
|
|
128
|
+
block.kind === "tool" && block.callId === callId && block.status === "awaiting_client"
|
|
129
|
+
? { ...block, status: "completed", result }
|
|
130
|
+
: block,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const isActiveConversationLoading = (activeConversationId: string | null, loadingConversationId: string | null): boolean =>
|
|
134
|
+
activeConversationId !== null && loadingConversationId === activeConversationId;
|
|
135
|
+
|
|
136
|
+
export const createAiChatController = (options: CreateAiChatControllerOptions) => {
|
|
137
|
+
const [activeConversationId, setActiveConversationIdSignal] = createSignal<string | null>(options.initialConversationId ?? null);
|
|
138
|
+
const [globalError, setGlobalError] = createSignal<string | null>(options.initialError ?? null);
|
|
139
|
+
const [runError, setRunError] = createSignal<string | null>(conversationRunError(options.initialDetail?.conversation));
|
|
140
|
+
const [runStatusRaw, setRunStatusRaw] = createSignal<AiChatRunStatus | null>(null);
|
|
141
|
+
const [streamStatus, setStreamStatus] = createSignal<AiStreamStatus>("idle");
|
|
142
|
+
const initialProjection = options.initialDetail ? detailToProjection(options.initialDetail) : emptyProjection();
|
|
143
|
+
const [state, setState] = createStore<AiChatProjection>(initialProjection);
|
|
144
|
+
|
|
145
|
+
// Cache of projections for conversations opened this session (fast switching).
|
|
146
|
+
const cache = new Map<string, AiChatProjection>();
|
|
147
|
+
const preparedAttachmentRefs = new Map<string, WeakMap<File, Promise<AiAttachmentRef & { version: number }>>>();
|
|
148
|
+
if (options.initialConversationId && options.initialDetail) cache.set(options.initialConversationId, initialProjection);
|
|
149
|
+
// Infinite scroll state per conversation (history is windowed, oldest first).
|
|
150
|
+
const [hasMoreByConversation, setHasMoreByConversation] = createSignal<Record<string, boolean>>({});
|
|
151
|
+
const [loadingOlderConversationId, setLoadingOlderConversationId] = createSignal<string | null>(null);
|
|
152
|
+
const [loadingConversationId, setLoadingConversationId] = createSignal<string | null>(null);
|
|
153
|
+
const [timelineByConversation, setTimelineByConversation] = createSignal<Record<string, AiConversationTimelineEntry[]>>(
|
|
154
|
+
options.initialConversationId && options.initialTimeline ? { [options.initialConversationId]: options.initialTimeline } : {},
|
|
155
|
+
);
|
|
156
|
+
const [loadingTimelineConversationId, setLoadingTimelineConversationId] = createSignal<string | null>(null);
|
|
157
|
+
const setHasMore = (conversationId: string, hasMore: boolean) =>
|
|
158
|
+
setHasMoreByConversation((current) => ({ ...current, [conversationId]: hasMore }));
|
|
159
|
+
const handledFrontendCalls = new Set<string>();
|
|
160
|
+
const inFlightFrontendCalls = new Set<string>();
|
|
161
|
+
const abortRequests = new Map<string, Promise<boolean>>();
|
|
162
|
+
let stream: AiStreamHandle | null = null;
|
|
163
|
+
let streamSession: AiStreamSession | null = null;
|
|
164
|
+
let streamGeneration = 0;
|
|
165
|
+
let conversationOpenGeneration = 0;
|
|
166
|
+
let historyTargetSeq: number | null = null;
|
|
167
|
+
let historyLoadPromise: Promise<boolean> | null = null;
|
|
168
|
+
let draftSaveQueue: Promise<unknown> = Promise.resolve();
|
|
169
|
+
let conversationCreationPromise: Promise<AiConversation | null> | null = null;
|
|
170
|
+
|
|
171
|
+
const isActiveConversation = (conversationId: string) => activeConversationId() === conversationId;
|
|
172
|
+
const timeline = () => {
|
|
173
|
+
const conversationId = activeConversationId();
|
|
174
|
+
return conversationId ? (timelineByConversation()[conversationId] ?? []) : [];
|
|
175
|
+
};
|
|
176
|
+
const setTimeline = (conversationId: string, entries: AiConversationTimelineEntry[]) =>
|
|
177
|
+
setTimelineByConversation((current) => ({ ...current, [conversationId]: entries }));
|
|
178
|
+
const setConversationError = (conversationId: string, message: string) => {
|
|
179
|
+
if (isActiveConversation(conversationId)) setGlobalError(message);
|
|
180
|
+
};
|
|
181
|
+
const invalidateInactiveCache = (conversationId: string): boolean => {
|
|
182
|
+
if (isActiveConversation(conversationId)) return false;
|
|
183
|
+
cache.delete(conversationId);
|
|
184
|
+
return true;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const currentParams = () => (options.params ? (isAccessor(options.params) ? options.params() : options.params) : {});
|
|
188
|
+
const url = (path: string, extra?: Record<string, string>) => {
|
|
189
|
+
const params = new URLSearchParams({ ...currentParams(), ...extra }).toString();
|
|
190
|
+
return `${options.baseUrl}${path}${params ? `?${params}` : ""}`;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const request = async <T>(path: string, init: RequestInit, fallback: string): Promise<T> => {
|
|
194
|
+
const response = await fetch(url(path), { ...init, headers: { "Content-Type": "application/json", ...init.headers } });
|
|
195
|
+
if (!response.ok) throw new Error(await readError(response, fallback));
|
|
196
|
+
return (await response.json()) as T;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const setActiveError = (message: string | null) => {
|
|
200
|
+
if (activeConversationId()) setGlobalError(message);
|
|
201
|
+
else setGlobalError(message);
|
|
202
|
+
};
|
|
203
|
+
const clearErrors = () => {
|
|
204
|
+
setGlobalError(null);
|
|
205
|
+
setRunError(null);
|
|
206
|
+
};
|
|
207
|
+
const error = () => globalError() ?? runError();
|
|
208
|
+
|
|
209
|
+
const activeTurn = () => state.activeTurn;
|
|
210
|
+
const messages = createMemo(() => visibleMessages(state));
|
|
211
|
+
const runStatus = (): AiChatRunStatus => {
|
|
212
|
+
const override = runStatusRaw();
|
|
213
|
+
if (override) return override;
|
|
214
|
+
const turn = state.activeTurn;
|
|
215
|
+
if (!turn) return "idle";
|
|
216
|
+
return turn.status === "waiting_for_action" ? "waiting_for_action" : "streaming";
|
|
217
|
+
};
|
|
218
|
+
const running = () => {
|
|
219
|
+
const status = runStatus();
|
|
220
|
+
return status === "streaming" || status === "waiting_for_action" || status === "stopping";
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// ---- streaming --------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
const reduceEvent = (conversationId: string, event: AiStreamEvent) => {
|
|
226
|
+
const next = reduceProjection({ conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn }, event);
|
|
227
|
+
setState(reconcile(next, { key: "id", merge: true }));
|
|
228
|
+
cache.set(conversationId, next);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Compaction rewrites history (archives messages, inserts the summary) — the
|
|
233
|
+
* additive event fold cannot express that, so the turn ends with one atomic
|
|
234
|
+
* detail refetch. The finished compaction block stays visible until the fresh
|
|
235
|
+
* state replaces it in a single step.
|
|
236
|
+
*/
|
|
237
|
+
const foldCompaction = async (session: AiStreamSession, event: AiStreamEvent) => {
|
|
238
|
+
const detail = await loadDetail(session.conversationId, () => isCurrentStreamSession(streamSession, session));
|
|
239
|
+
if (!isCurrentStreamSession(streamSession, session)) return;
|
|
240
|
+
if (detail) {
|
|
241
|
+
setProjection(detailToProjection(detail), session.conversationId);
|
|
242
|
+
setHasMore(session.conversationId, detail.hasMoreMessages ?? false);
|
|
243
|
+
if (detail.timeline) setTimeline(session.conversationId, detail.timeline);
|
|
244
|
+
} else reduceEvent(session.conversationId, event);
|
|
245
|
+
setRunStatusRaw(null);
|
|
246
|
+
void markConversationViewed(session.conversationId);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const applyEvent = (session: AiStreamSession, event: AiStreamEvent) => {
|
|
250
|
+
if (!isCurrentStreamSession(streamSession, session)) return;
|
|
251
|
+
const conversationId = session.conversationId;
|
|
252
|
+
|
|
253
|
+
if (event.type === "state") setHasMore(conversationId, event.hasMoreMessages ?? false);
|
|
254
|
+
const nextRunError = runErrorFromEvent(event, state.activeTurn?.turnId);
|
|
255
|
+
if (nextRunError !== undefined) setRunError(nextRunError);
|
|
256
|
+
|
|
257
|
+
if (
|
|
258
|
+
event.type === "turn_finished" &&
|
|
259
|
+
state.activeTurn?.turnId === event.turnId &&
|
|
260
|
+
state.activeTurn.blocks.some((block) => block.kind === "compaction")
|
|
261
|
+
) {
|
|
262
|
+
void foldCompaction(session, event);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
reduceEvent(conversationId, event);
|
|
267
|
+
if (event.type === "state" && loadingConversationId() === conversationId) setLoadingConversationId(null);
|
|
268
|
+
if (runStatusRaw() && runStatusRaw() !== "stopping") setRunStatusRaw(null);
|
|
269
|
+
|
|
270
|
+
if (event.type === "turn_finished") {
|
|
271
|
+
setRunStatusRaw(null);
|
|
272
|
+
void markConversationViewed(conversationId);
|
|
273
|
+
void refreshTimeline(conversationId);
|
|
274
|
+
}
|
|
275
|
+
runFrontendTools();
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const closeStream = () => {
|
|
279
|
+
stream?.close();
|
|
280
|
+
stream = null;
|
|
281
|
+
streamSession = null;
|
|
282
|
+
setStreamStatus("idle");
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const openStream = (conversationId: string) => {
|
|
286
|
+
if (streamSession?.conversationId === conversationId && stream) return;
|
|
287
|
+
closeStream();
|
|
288
|
+
const session = { conversationId, generation: ++streamGeneration };
|
|
289
|
+
streamSession = session;
|
|
290
|
+
stream = (options.streamTransport ?? aiSseConversationStreamTransport).subscribe({
|
|
291
|
+
conversationId,
|
|
292
|
+
url: url(`/conversations/${conversationId}/stream`),
|
|
293
|
+
onStatus: (status) => {
|
|
294
|
+
if (isCurrentStreamSession(streamSession, session)) setStreamStatus(status);
|
|
295
|
+
},
|
|
296
|
+
onEvent: (event) => applyEvent(session, event),
|
|
297
|
+
onError: (error) => {
|
|
298
|
+
if (!isCurrentStreamSession(streamSession, session)) return;
|
|
299
|
+
setStreamStatus("idle");
|
|
300
|
+
setConversationError(conversationId, error.message);
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// ---- frontend tools ---------------------------------------------------
|
|
306
|
+
|
|
307
|
+
const runFrontendTools = () => {
|
|
308
|
+
const conversationId = activeConversationId();
|
|
309
|
+
const turn = state.activeTurn;
|
|
310
|
+
if (!conversationId || !turn || turn.status !== "waiting_for_action") return;
|
|
311
|
+
for (const block of turn.blocks) {
|
|
312
|
+
if (block.kind !== "tool" || block.status !== "awaiting_client") continue;
|
|
313
|
+
const mode = block.frontendMode ?? "client";
|
|
314
|
+
// client_interaction tools resolve through their rendered UI; client_view
|
|
315
|
+
// (card) is resolved inline by the executor and never has a pending action —
|
|
316
|
+
// submitting for either would race a request that isn't ours to answer.
|
|
317
|
+
if (mode !== "client") continue;
|
|
318
|
+
const key = `${turn.turnId}:${block.callId}`;
|
|
319
|
+
if (!claimFrontendCall(handledFrontendCalls, inFlightFrontendCalls, key)) continue;
|
|
320
|
+
void executeFrontendTool(conversationId, turn.turnId, block.callId, block.name, block.args).then((submitted) => {
|
|
321
|
+
settleFrontendCall(handledFrontendCalls, inFlightFrontendCalls, key, submitted);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const executeFrontendTool = async (
|
|
327
|
+
conversationId: string,
|
|
328
|
+
turnId: string,
|
|
329
|
+
callId: string,
|
|
330
|
+
name: string,
|
|
331
|
+
args: unknown,
|
|
332
|
+
): Promise<boolean> => {
|
|
333
|
+
const handler = options.frontendTools?.[name];
|
|
334
|
+
// Never fake-answer a client tool: without a registered handler the
|
|
335
|
+
// request must stay pending for whatever UI renders it.
|
|
336
|
+
if (!handler) {
|
|
337
|
+
console.warn(`No frontend handler registered for AI tool "${name}" — leaving the action request pending.`);
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
const result = await handler({ name, callId, args, turnId });
|
|
342
|
+
return submitTurnActionForConversation(conversationId, turnId, callId, { type: "tool_result", result });
|
|
343
|
+
} catch (toolError) {
|
|
344
|
+
return submitTurnActionForConversation(conversationId, turnId, callId, {
|
|
345
|
+
type: "tool_result",
|
|
346
|
+
result: { error: toolError instanceof Error ? toolError.message : "Frontend tool failed" },
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
// ---- conversation loading --------------------------------------------
|
|
352
|
+
|
|
353
|
+
const markConversationViewed = async (conversationId: string): Promise<void> => {
|
|
354
|
+
if (!options.trackViewedState) return;
|
|
355
|
+
if (state.conversation?.id === conversationId) setState("conversation", "unreadCompletion", false);
|
|
356
|
+
try {
|
|
357
|
+
await request(`/conversations/${conversationId}/viewed`, { method: "POST" }, "Failed to mark conversation as viewed");
|
|
358
|
+
} catch {}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const loadDetail = async (conversationId: string, shouldReportError: () => boolean): Promise<AiConversationDetail | null> => {
|
|
362
|
+
try {
|
|
363
|
+
return await request<AiConversationDetail>(`/conversations/${conversationId}`, { method: "GET" }, "Failed to open conversation");
|
|
364
|
+
} catch (loadError) {
|
|
365
|
+
if (shouldReportError()) {
|
|
366
|
+
setConversationError(conversationId, loadError instanceof Error ? loadError.message : "Failed to open conversation");
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const refreshTimeline = async (conversationId = activeConversationId()): Promise<void> => {
|
|
373
|
+
if (!conversationId) return;
|
|
374
|
+
setLoadingTimelineConversationId(conversationId);
|
|
375
|
+
try {
|
|
376
|
+
const entries = await request<AiConversationTimelineEntry[]>(
|
|
377
|
+
`/conversations/${conversationId}/timeline`,
|
|
378
|
+
{ method: "GET" },
|
|
379
|
+
"Failed to load conversation timeline",
|
|
380
|
+
);
|
|
381
|
+
if (isActiveConversation(conversationId)) setTimeline(conversationId, entries);
|
|
382
|
+
} catch {
|
|
383
|
+
// The navigator is optional; transcript navigation remains fully usable.
|
|
384
|
+
} finally {
|
|
385
|
+
if (loadingTimelineConversationId() === conversationId) setLoadingTimelineConversationId(null);
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const setProjection = (projection: AiChatProjection, conversationId: string) => {
|
|
390
|
+
const next = {
|
|
391
|
+
...projection,
|
|
392
|
+
activeTurn:
|
|
393
|
+
state.conversation?.id === conversationId ? mergeActiveTurn(state.activeTurn, projection.activeTurn) : projection.activeTurn,
|
|
394
|
+
};
|
|
395
|
+
cache.set(conversationId, next);
|
|
396
|
+
setState(reconcile(next, { key: "id", merge: true }));
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const openConversation = async (conversationId: string) => {
|
|
400
|
+
if (activeConversationId() === conversationId && state.conversation?.id === conversationId) {
|
|
401
|
+
void markConversationViewed(conversationId);
|
|
402
|
+
return "current" as const;
|
|
403
|
+
}
|
|
404
|
+
const generation = ++conversationOpenGeneration;
|
|
405
|
+
void markConversationViewed(conversationId);
|
|
406
|
+
setGlobalError(null);
|
|
407
|
+
setRunStatusRaw(null);
|
|
408
|
+
|
|
409
|
+
const cached = cache.get(conversationId);
|
|
410
|
+
const conversation = cached?.conversation ?? null;
|
|
411
|
+
setRunError(conversationRunError(cached?.conversation ?? conversation));
|
|
412
|
+
setActiveConversationIdSignal(conversationId);
|
|
413
|
+
setState(reconcile(projectionForConversationOpen(cached, conversation), { key: "id", merge: true }));
|
|
414
|
+
setLoadingConversationId(cached ? null : conversationId);
|
|
415
|
+
|
|
416
|
+
openStream(conversationId);
|
|
417
|
+
const detail = await loadDetail(
|
|
418
|
+
conversationId,
|
|
419
|
+
() => activeConversationId() === conversationId && generation === conversationOpenGeneration,
|
|
420
|
+
);
|
|
421
|
+
if (detail && activeConversationId() === conversationId && generation === conversationOpenGeneration) {
|
|
422
|
+
const viewedDetail = { ...detail, conversation: { ...detail.conversation, unreadCompletion: false } };
|
|
423
|
+
setRunError(conversationRunError(viewedDetail.conversation));
|
|
424
|
+
// A cached view may hold history the fresh window doesn't — preserve it
|
|
425
|
+
// (same rule as the SSE state snapshot) so the scrollback never shrinks.
|
|
426
|
+
const windowOldest = detail.messages[0]?.seq;
|
|
427
|
+
const preservedOlder = cached && windowOldest !== undefined ? cached.messages.filter((message) => message.seq < windowOldest) : [];
|
|
428
|
+
setProjection({ ...detailToProjection(viewedDetail), messages: [...preservedOlder, ...detail.messages] }, conversationId);
|
|
429
|
+
if (preservedOlder.length === 0) setHasMore(conversationId, detail.hasMoreMessages ?? false);
|
|
430
|
+
if (detail.timeline) setTimeline(conversationId, detail.timeline);
|
|
431
|
+
runFrontendTools();
|
|
432
|
+
if (loadingConversationId() === conversationId) setLoadingConversationId(null);
|
|
433
|
+
return "opened" as const;
|
|
434
|
+
}
|
|
435
|
+
if (generation === conversationOpenGeneration && loadingConversationId() === conversationId) setLoadingConversationId(null);
|
|
436
|
+
return generation === conversationOpenGeneration ? ("failed" as const) : ("stale" as const);
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const refreshActiveConversation = async (): Promise<void> => {
|
|
440
|
+
const conversationId = activeConversationId();
|
|
441
|
+
if (!conversationId) return;
|
|
442
|
+
const detail = await request<AiConversationDetail>(
|
|
443
|
+
`/conversations/${conversationId}`,
|
|
444
|
+
{ method: "GET" },
|
|
445
|
+
"Failed to refresh conversation",
|
|
446
|
+
);
|
|
447
|
+
if (!isActiveConversation(conversationId)) return;
|
|
448
|
+
const windowOldest = detail.messages[0]?.seq;
|
|
449
|
+
const preservedOlder = windowOldest === undefined ? [] : state.messages.filter((message) => message.seq < windowOldest);
|
|
450
|
+
setProjection({ ...detailToProjection(detail), messages: [...preservedOlder, ...detail.messages] }, conversationId);
|
|
451
|
+
if (preservedOlder.length === 0) setHasMore(conversationId, detail.hasMoreMessages ?? false);
|
|
452
|
+
if (detail.timeline) setTimeline(conversationId, detail.timeline);
|
|
453
|
+
setRunError(conversationRunError(detail.conversation));
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const requestMessagesPage = async (conversationId: string, before: number, limit: number): Promise<AiMessagesPage> => {
|
|
457
|
+
const response = await fetch(url(`/conversations/${conversationId}/messages`, { before: String(before), limit: String(limit) }));
|
|
458
|
+
if (!response.ok) throw new Error(await readError(response, "Failed to load older messages"));
|
|
459
|
+
return (await response.json()) as AiMessagesPage;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const mergeOlderMessages = (conversationId: string, page: AiMessagesPage): boolean => {
|
|
463
|
+
if (!isActiveConversation(conversationId)) return false;
|
|
464
|
+
const known = new Set(state.messages.map((message) => message.id));
|
|
465
|
+
const fresh = page.messages.filter((message) => !known.has(message.id));
|
|
466
|
+
if (fresh.length > 0) setState("messages", (prev) => [...fresh, ...prev]);
|
|
467
|
+
setHasMore(conversationId, page.hasMore);
|
|
468
|
+
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
469
|
+
return fresh.length > 0;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const fetchOlderMessagesPage = async (conversationId: string, limit: number): Promise<boolean> => {
|
|
473
|
+
if (!(hasMoreByConversation()[conversationId] ?? false)) return false;
|
|
474
|
+
const oldest = state.messages[0]?.seq;
|
|
475
|
+
if (oldest === undefined) return false;
|
|
476
|
+
return mergeOlderMessages(conversationId, await requestMessagesPage(conversationId, oldest, limit));
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
/** Load one older page above the current window (infinite scroll). Returns whether anything was prepended. */
|
|
480
|
+
const loadOlderMessages = async (): Promise<boolean> => {
|
|
481
|
+
const conversationId = activeConversationId();
|
|
482
|
+
if (!conversationId || loadingOlderConversationId() === conversationId || historyLoadPromise) return false;
|
|
483
|
+
setGlobalError(null);
|
|
484
|
+
setLoadingOlderConversationId(conversationId);
|
|
485
|
+
try {
|
|
486
|
+
return await fetchOlderMessagesPage(conversationId, 50);
|
|
487
|
+
} catch (loadError) {
|
|
488
|
+
const error = loadError instanceof Error ? loadError : new Error("Failed to load older messages");
|
|
489
|
+
setConversationError(conversationId, error.message);
|
|
490
|
+
throw error;
|
|
491
|
+
} finally {
|
|
492
|
+
if (loadingOlderConversationId() === conversationId) setLoadingOlderConversationId(null);
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
/** Ensure history stays contiguous while revealing a timeline target. */
|
|
497
|
+
const loadHistoryThroughSeq = (targetSeq: number): Promise<boolean> => {
|
|
498
|
+
const conversationId = activeConversationId();
|
|
499
|
+
if (!conversationId) return Promise.resolve(false);
|
|
500
|
+
historyTargetSeq = historyTargetSeq === null ? targetSeq : Math.min(historyTargetSeq, targetSeq);
|
|
501
|
+
if (state.messages.some((message) => message.seq <= targetSeq)) return Promise.resolve(true);
|
|
502
|
+
if (historyLoadPromise) return historyLoadPromise.then((reached) => reached || loadHistoryThroughSeq(targetSeq));
|
|
503
|
+
|
|
504
|
+
historyLoadPromise = (async () => {
|
|
505
|
+
setGlobalError(null);
|
|
506
|
+
setLoadingOlderConversationId(conversationId);
|
|
507
|
+
try {
|
|
508
|
+
let before = state.messages[0]?.seq;
|
|
509
|
+
let hasMore = hasMoreByConversation()[conversationId] ?? false;
|
|
510
|
+
let accumulated: AiStoredMessage[] = [];
|
|
511
|
+
while (isActiveConversation(conversationId) && before !== undefined) {
|
|
512
|
+
const target = historyTargetSeq;
|
|
513
|
+
if (target === null || before <= target) break;
|
|
514
|
+
if (!hasMore) return false;
|
|
515
|
+
const page = await requestMessagesPage(conversationId, before, 200);
|
|
516
|
+
if (page.messages.length === 0) return false;
|
|
517
|
+
accumulated = [...page.messages, ...accumulated];
|
|
518
|
+
before = page.messages[0]?.seq;
|
|
519
|
+
hasMore = page.hasMore;
|
|
520
|
+
}
|
|
521
|
+
if (!isActiveConversation(conversationId)) return false;
|
|
522
|
+
if (accumulated.length > 0) mergeOlderMessages(conversationId, { messages: accumulated, hasMore });
|
|
523
|
+
const target = historyTargetSeq;
|
|
524
|
+
return target !== null && state.messages.some((message) => message.seq <= target);
|
|
525
|
+
} catch (loadError) {
|
|
526
|
+
const error = loadError instanceof Error ? loadError : new Error("Failed to load conversation history");
|
|
527
|
+
setConversationError(conversationId, error.message);
|
|
528
|
+
return false;
|
|
529
|
+
} finally {
|
|
530
|
+
historyTargetSeq = null;
|
|
531
|
+
historyLoadPromise = null;
|
|
532
|
+
if (loadingOlderConversationId() === conversationId) setLoadingOlderConversationId(null);
|
|
533
|
+
}
|
|
534
|
+
})();
|
|
535
|
+
return historyLoadPromise;
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const setActiveConversationId = (conversationId: string | null) => {
|
|
539
|
+
if (conversationId) void openConversation(conversationId);
|
|
540
|
+
else {
|
|
541
|
+
conversationOpenGeneration += 1;
|
|
542
|
+
clearErrors();
|
|
543
|
+
setActiveConversationIdSignal(null);
|
|
544
|
+
historyTargetSeq = null;
|
|
545
|
+
setLoadingConversationId(null);
|
|
546
|
+
closeStream();
|
|
547
|
+
setState(reconcile(emptyProjection(), { key: "id", merge: true }));
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const createConversation = async (
|
|
552
|
+
input: {
|
|
553
|
+
title?: string;
|
|
554
|
+
projectId?: string;
|
|
555
|
+
draft?: { content: AiDraftContentPart[] };
|
|
556
|
+
preloadTools?: Array<{ name: string } | { appId: string; kind: "query" | "action"; id: string }>;
|
|
557
|
+
} = {},
|
|
558
|
+
) => {
|
|
559
|
+
const generation = ++conversationOpenGeneration;
|
|
560
|
+
clearErrors();
|
|
561
|
+
try {
|
|
562
|
+
const conversation = await request<AiConversation>(
|
|
563
|
+
`/conversations`,
|
|
564
|
+
{ method: "POST", body: JSON.stringify(input) },
|
|
565
|
+
"Failed to create conversation",
|
|
566
|
+
);
|
|
567
|
+
const projection = emptyProjection(conversation);
|
|
568
|
+
cache.set(conversation.id, projection);
|
|
569
|
+
setHasMore(conversation.id, false);
|
|
570
|
+
if (generation !== conversationOpenGeneration) return conversation;
|
|
571
|
+
setState(reconcile(projection, { key: "id", merge: true }));
|
|
572
|
+
setActiveConversationIdSignal(conversation.id);
|
|
573
|
+
setLoadingConversationId(null);
|
|
574
|
+
openStream(conversation.id);
|
|
575
|
+
void markConversationViewed(conversation.id);
|
|
576
|
+
return conversation;
|
|
577
|
+
} catch (createError) {
|
|
578
|
+
if (generation === conversationOpenGeneration) {
|
|
579
|
+
setGlobalError(createError instanceof Error ? createError.message : "Failed to create conversation");
|
|
580
|
+
}
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
const ensureConversation = async (): Promise<string | null> => {
|
|
586
|
+
const active = activeConversationId();
|
|
587
|
+
if (active) return active;
|
|
588
|
+
|
|
589
|
+
if (!conversationCreationPromise) {
|
|
590
|
+
const creation = createConversation();
|
|
591
|
+
conversationCreationPromise = creation;
|
|
592
|
+
void creation.then(() => {
|
|
593
|
+
if (conversationCreationPromise === creation) conversationCreationPromise = null;
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
return (await conversationCreationPromise)?.id ?? null;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// ---- commands ---------------------------------------------------------
|
|
600
|
+
|
|
601
|
+
/** Upload one attachment into the conversation file store; returns its reference. */
|
|
602
|
+
const uploadConversationFile = async (conversationId: string, file: File): Promise<AiAttachmentRef & { version: number }> => {
|
|
603
|
+
let prepared = preparedAttachmentRefs.get(conversationId);
|
|
604
|
+
if (!prepared) {
|
|
605
|
+
prepared = new WeakMap();
|
|
606
|
+
preparedAttachmentRefs.set(conversationId, prepared);
|
|
607
|
+
}
|
|
608
|
+
const existing = prepared.get(file);
|
|
609
|
+
if (existing) return existing;
|
|
610
|
+
const upload = (async () => {
|
|
611
|
+
const form = new FormData();
|
|
612
|
+
form.append("file", file);
|
|
613
|
+
const response = await fetch(url(`/conversations/${conversationId}/files`), { method: "POST", body: form });
|
|
614
|
+
if (!response.ok) throw new Error(await readError(response, `Failed to upload ${file.name}`));
|
|
615
|
+
const body = (await response.json()) as { file: { path: string; mediaType: string; size: number; version: number } };
|
|
616
|
+
return { path: body.file.path, mediaType: body.file.mediaType, size: body.file.size, version: body.file.version };
|
|
617
|
+
})();
|
|
618
|
+
prepared.set(file, upload);
|
|
619
|
+
try {
|
|
620
|
+
return await upload;
|
|
621
|
+
} catch (error) {
|
|
622
|
+
if (prepared.get(file) === upload) prepared.delete(file);
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const persistComposerDraft = async (
|
|
628
|
+
input: ComposerDraftInput,
|
|
629
|
+
conversationId: string | null,
|
|
630
|
+
): Promise<{ conversationId: string; content: AiDraftContentPart[]; revision: number } | null> => {
|
|
631
|
+
if (!conversationId) return null;
|
|
632
|
+
const conversation = isActiveConversation(conversationId) ? state.conversation : cache.get(conversationId)?.conversation;
|
|
633
|
+
if (!conversation) return null;
|
|
634
|
+
|
|
635
|
+
let uploaded: Array<AiAttachmentRef & { version: number }> = [];
|
|
636
|
+
try {
|
|
637
|
+
uploaded = input.files?.length ? await Promise.all(input.files.map((file) => uploadConversationFile(conversationId, file))) : [];
|
|
638
|
+
} catch (uploadError) {
|
|
639
|
+
setConversationError(conversationId, uploadError instanceof Error ? uploadError.message : "Attachment upload failed");
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const textParts: AiDraftContentPart[] = [];
|
|
644
|
+
const message = input.message?.trim();
|
|
645
|
+
if (message && !input.content?.some((part) => typeof part !== "string" && part.type === "text")) {
|
|
646
|
+
textParts.push({ type: "text", text: message });
|
|
647
|
+
}
|
|
648
|
+
for (const part of input.content ?? []) {
|
|
649
|
+
if (typeof part === "string") {
|
|
650
|
+
if (part.trim()) textParts.push({ type: "text", text: part });
|
|
651
|
+
} else if (part.type === "text" && part.text.trim()) {
|
|
652
|
+
textParts.push({ type: "text", text: part.text });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const content: AiDraftContentPart[] = [
|
|
656
|
+
...textParts,
|
|
657
|
+
...(input.resources ?? []).map((resource) => ({ type: "resource" as const, ...resource })),
|
|
658
|
+
...(input.storedFiles ?? []).map((file) => ({ type: "file" as const, ...file })),
|
|
659
|
+
...uploaded.map((file) => ({ type: "file" as const, ...file })),
|
|
660
|
+
];
|
|
661
|
+
try {
|
|
662
|
+
const draft = await request<AiConversation["draft"]>(
|
|
663
|
+
`/conversations/${conversationId}/draft`,
|
|
664
|
+
{ method: "PUT", body: JSON.stringify({ expectedRevision: conversation.draft.revision, content }) },
|
|
665
|
+
"Failed to save conversation draft",
|
|
666
|
+
);
|
|
667
|
+
const nextConversation = { ...conversation, draft };
|
|
668
|
+
const projection = isActiveConversation(conversationId)
|
|
669
|
+
? { conversation: nextConversation, messages: state.messages, activeTurn: state.activeTurn }
|
|
670
|
+
: { ...(cache.get(conversationId) ?? emptyProjection(nextConversation)), conversation: nextConversation };
|
|
671
|
+
cache.set(conversationId, projection);
|
|
672
|
+
if (isActiveConversation(conversationId)) setState("conversation", nextConversation);
|
|
673
|
+
return { conversationId, content, revision: draft.revision };
|
|
674
|
+
} catch (draftError) {
|
|
675
|
+
setConversationError(conversationId, draftError instanceof Error ? draftError.message : "Failed to save conversation draft");
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const queueDraftOperation = <T>(operation: () => Promise<T>): Promise<T> => {
|
|
681
|
+
const queued = draftSaveQueue.then(operation);
|
|
682
|
+
draftSaveQueue = queued.then(
|
|
683
|
+
() => undefined,
|
|
684
|
+
() => undefined,
|
|
685
|
+
);
|
|
686
|
+
return queued;
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
const saveDraft = (input: ComposerDraftInput) => {
|
|
690
|
+
const targetConversation = ensureConversation();
|
|
691
|
+
return queueDraftOperation(async () => persistComposerDraft(input, await targetConversation));
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
const send = (input: ComposerDraftInput & { modelProfileId?: string }): Promise<boolean> => {
|
|
695
|
+
if (!isComposerDraftSendable(input)) return Promise.resolve(false);
|
|
696
|
+
const targetConversation = ensureConversation();
|
|
697
|
+
return queueDraftOperation(async () => {
|
|
698
|
+
const savedDraft = await persistComposerDraft(input, await targetConversation);
|
|
699
|
+
if (!savedDraft) return false;
|
|
700
|
+
const conversationId = savedDraft.conversationId;
|
|
701
|
+
if (isActiveConversation(conversationId) && running()) return false;
|
|
702
|
+
|
|
703
|
+
if (isActiveConversation(conversationId)) clearErrors();
|
|
704
|
+
const baseProjection = isActiveConversation(conversationId)
|
|
705
|
+
? { conversation: state.conversation, messages: [...state.messages], activeTurn: state.activeTurn }
|
|
706
|
+
: (cache.get(conversationId) ?? emptyProjection());
|
|
707
|
+
|
|
708
|
+
// Optimistic view renders attachments through the same marker format the server persists.
|
|
709
|
+
const optimisticContent: AiUserContentPart[] = [
|
|
710
|
+
...savedDraft.content.flatMap((part) => {
|
|
711
|
+
if (part.type === "text") return [{ type: "text" as const, text: part.text }];
|
|
712
|
+
if (part.type === "file") return [{ type: "text" as const, text: aiAttachmentMarker(part) }];
|
|
713
|
+
return [
|
|
714
|
+
{
|
|
715
|
+
type: "text" as const,
|
|
716
|
+
text: aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }),
|
|
717
|
+
},
|
|
718
|
+
];
|
|
719
|
+
}),
|
|
720
|
+
];
|
|
721
|
+
|
|
722
|
+
// Optimistic: show the user message immediately.
|
|
723
|
+
const optimistic: AiStoredMessage = {
|
|
724
|
+
id: `pending-${Date.now()}`,
|
|
725
|
+
shortId: `pending-${Date.now()}`,
|
|
726
|
+
conversationId,
|
|
727
|
+
seq: (baseProjection.messages.at(-1)?.seq ?? 0) + 1,
|
|
728
|
+
kind: "message",
|
|
729
|
+
message: { role: "user", content: optimisticContent },
|
|
730
|
+
loopId: null,
|
|
731
|
+
modelProfileId: null,
|
|
732
|
+
providerModel: null,
|
|
733
|
+
usage: null,
|
|
734
|
+
stopReason: null,
|
|
735
|
+
loopAggregate: null,
|
|
736
|
+
loopDoneReason: null,
|
|
737
|
+
compactedAt: null,
|
|
738
|
+
meta: null,
|
|
739
|
+
createdAt: new Date().toISOString(),
|
|
740
|
+
};
|
|
741
|
+
const optimisticProjection = { ...baseProjection, messages: [...baseProjection.messages, optimistic] };
|
|
742
|
+
cache.set(conversationId, optimisticProjection);
|
|
743
|
+
if (isActiveConversation(conversationId)) {
|
|
744
|
+
setState("messages", (prev) => [...prev, optimistic]);
|
|
745
|
+
setRunStatusRaw("streaming");
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
try {
|
|
749
|
+
const result = await request<SubmitTurnResult>(
|
|
750
|
+
`/conversations/${conversationId}/turns`,
|
|
751
|
+
{
|
|
752
|
+
method: "POST",
|
|
753
|
+
body: JSON.stringify({
|
|
754
|
+
draftRevision: savedDraft.revision,
|
|
755
|
+
modelProfileId: input.modelProfileId,
|
|
756
|
+
}),
|
|
757
|
+
},
|
|
758
|
+
"AI request failed",
|
|
759
|
+
);
|
|
760
|
+
if (invalidateInactiveCache(conversationId)) return true;
|
|
761
|
+
// Replace the optimistic message with the persisted one.
|
|
762
|
+
setState("messages", (prev) => prev.map((message) => (message.id === optimistic.id ? result.message : message)));
|
|
763
|
+
setState(
|
|
764
|
+
"activeTurn",
|
|
765
|
+
(current) =>
|
|
766
|
+
current ?? {
|
|
767
|
+
turnId: result.turn.id,
|
|
768
|
+
attempt: 0,
|
|
769
|
+
seq: 0,
|
|
770
|
+
status: "running",
|
|
771
|
+
blocks: [],
|
|
772
|
+
modelProfileId: result.turn.modelProfileId,
|
|
773
|
+
},
|
|
774
|
+
);
|
|
775
|
+
if (state.conversation) {
|
|
776
|
+
setState("conversation", "draft", {
|
|
777
|
+
content: [],
|
|
778
|
+
revision: savedDraft.revision + 1,
|
|
779
|
+
updatedAt: new Date().toISOString(),
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
783
|
+
void refreshTimeline(conversationId);
|
|
784
|
+
if (input.files?.length) {
|
|
785
|
+
const prepared = preparedAttachmentRefs.get(conversationId);
|
|
786
|
+
for (const file of input.files) prepared?.delete(file);
|
|
787
|
+
}
|
|
788
|
+
return true;
|
|
789
|
+
} catch (sendError) {
|
|
790
|
+
if (invalidateInactiveCache(conversationId)) return false;
|
|
791
|
+
setState("messages", (prev) => prev.filter((message) => message.id !== optimistic.id));
|
|
792
|
+
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
793
|
+
setRunStatusRaw("failed");
|
|
794
|
+
setConversationError(conversationId, sendError instanceof Error ? sendError.message : "AI request failed");
|
|
795
|
+
return false;
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
const submitSteer = async (input: { text: string; clientRequestId: string; blockId: string }) => {
|
|
801
|
+
const conversationId = activeConversationId();
|
|
802
|
+
const turn = state.activeTurn;
|
|
803
|
+
if (!conversationId || !turn || runStatus() === "stopping") return false;
|
|
804
|
+
|
|
805
|
+
try {
|
|
806
|
+
const result = await request<AiTurnSteer>(
|
|
807
|
+
`/conversations/${conversationId}/turns/${turn.turnId}/steer`,
|
|
808
|
+
{ method: "POST", body: JSON.stringify({ message: input.text, clientRequestId: input.clientRequestId }) },
|
|
809
|
+
"Failed to steer the current response",
|
|
810
|
+
);
|
|
811
|
+
if (invalidateInactiveCache(conversationId)) return true;
|
|
812
|
+
setState("activeTurn", (current) => {
|
|
813
|
+
if (!current || current.turnId !== turn.turnId) return current;
|
|
814
|
+
return { ...current, blocks: reconcileSteerBlocks(current.blocks, input.blockId, result) };
|
|
815
|
+
});
|
|
816
|
+
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
817
|
+
void refreshTimeline(conversationId);
|
|
818
|
+
return true;
|
|
819
|
+
} catch (steerError) {
|
|
820
|
+
if (invalidateInactiveCache(conversationId)) return true;
|
|
821
|
+
setState("activeTurn", (current) =>
|
|
822
|
+
current
|
|
823
|
+
? {
|
|
824
|
+
...current,
|
|
825
|
+
blocks: failSteerBlock(current.blocks, input.blockId),
|
|
826
|
+
}
|
|
827
|
+
: current,
|
|
828
|
+
);
|
|
829
|
+
setConversationError(conversationId, steerError instanceof Error ? steerError.message : "Failed to steer the current response");
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
const steer = async (message: string) => {
|
|
835
|
+
const text = message.trim();
|
|
836
|
+
const turn = state.activeTurn;
|
|
837
|
+
if (!text || !turn || runStatus() === "stopping") return false;
|
|
838
|
+
clearErrors();
|
|
839
|
+
const clientRequestId =
|
|
840
|
+
typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
|
841
|
+
const blockId = `steer-request-${clientRequestId}`;
|
|
842
|
+
setState("activeTurn", (current) =>
|
|
843
|
+
current
|
|
844
|
+
? {
|
|
845
|
+
...current,
|
|
846
|
+
blocks: [
|
|
847
|
+
...current.blocks,
|
|
848
|
+
{ id: blockId, kind: "steer_message" as const, steerId: clientRequestId, text, status: "pending" as const },
|
|
849
|
+
],
|
|
850
|
+
}
|
|
851
|
+
: current,
|
|
852
|
+
);
|
|
853
|
+
const conversationId = activeConversationId();
|
|
854
|
+
if (conversationId)
|
|
855
|
+
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
856
|
+
return submitSteer({ text, clientRequestId, blockId });
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const retrySteer = async (block: Extract<AiTurnSnapshot["blocks"][number], { kind: "steer_message" }>) => {
|
|
860
|
+
if (block.status !== "failed") return false;
|
|
861
|
+
clearErrors();
|
|
862
|
+
setState("activeTurn", (current) =>
|
|
863
|
+
current
|
|
864
|
+
? {
|
|
865
|
+
...current,
|
|
866
|
+
blocks: current.blocks.map((candidate) =>
|
|
867
|
+
candidate.id === block.id && candidate.kind === "steer_message" ? { ...candidate, status: "pending" as const } : candidate,
|
|
868
|
+
),
|
|
869
|
+
}
|
|
870
|
+
: current,
|
|
871
|
+
);
|
|
872
|
+
const conversationId = activeConversationId();
|
|
873
|
+
if (conversationId)
|
|
874
|
+
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
875
|
+
return submitSteer({ text: block.text, clientRequestId: block.steerId, blockId: block.id });
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
/** Download URL for a conversation VFS file (present blocks, attachment chips). */
|
|
879
|
+
const fileContentUrl = (path: string): string | null => {
|
|
880
|
+
const conversationId = activeConversationId();
|
|
881
|
+
if (!conversationId) return null;
|
|
882
|
+
return url(`/conversations/${conversationId}/files/content`, { path });
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
const abort = (): Promise<boolean> => {
|
|
886
|
+
const turn = state.activeTurn;
|
|
887
|
+
const conversationId = activeConversationId();
|
|
888
|
+
if (!turn || !conversationId) return Promise.resolve(false);
|
|
889
|
+
const key = `${conversationId}:${turn.turnId}`;
|
|
890
|
+
const inFlight = abortRequests.get(key);
|
|
891
|
+
if (inFlight) return inFlight;
|
|
892
|
+
|
|
893
|
+
clearErrors();
|
|
894
|
+
setRunStatusRaw("stopping");
|
|
895
|
+
const requestPromise = request(
|
|
896
|
+
`/conversations/${conversationId}/turns/${turn.turnId}/abort`,
|
|
897
|
+
{ method: "POST" },
|
|
898
|
+
"Failed to stop AI turn",
|
|
899
|
+
)
|
|
900
|
+
.then(() => true)
|
|
901
|
+
.catch((abortError) => {
|
|
902
|
+
if (isActiveConversation(conversationId) && state.activeTurn?.turnId === turn.turnId) {
|
|
903
|
+
// The server did not accept the abort, so the existing turn is still
|
|
904
|
+
// authoritative and Stop must remain available for another attempt.
|
|
905
|
+
setRunStatusRaw(null);
|
|
906
|
+
setConversationError(conversationId, abortError instanceof Error ? abortError.message : "Failed to stop AI turn");
|
|
907
|
+
}
|
|
908
|
+
return false;
|
|
909
|
+
})
|
|
910
|
+
.finally(() => {
|
|
911
|
+
abortRequests.delete(key);
|
|
912
|
+
});
|
|
913
|
+
abortRequests.set(key, requestPromise);
|
|
914
|
+
return requestPromise;
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
const compactConversation = async (input: { modelProfileId?: string } = {}) => {
|
|
918
|
+
const conversationId = activeConversationId();
|
|
919
|
+
if (!conversationId) {
|
|
920
|
+
setGlobalError("Open a chat before compacting context.");
|
|
921
|
+
return false;
|
|
922
|
+
}
|
|
923
|
+
if (running()) {
|
|
924
|
+
setGlobalError("Stop the current response before compacting context.");
|
|
925
|
+
return false;
|
|
926
|
+
}
|
|
927
|
+
clearErrors();
|
|
928
|
+
setRunStatusRaw("streaming");
|
|
929
|
+
try {
|
|
930
|
+
await request(`/conversations/${conversationId}/compact`, { method: "POST", body: JSON.stringify(input) }, "AI compaction failed");
|
|
931
|
+
return true;
|
|
932
|
+
} catch (compactError) {
|
|
933
|
+
if (!isActiveConversation(conversationId)) return false;
|
|
934
|
+
setRunStatusRaw("failed");
|
|
935
|
+
setConversationError(conversationId, compactError instanceof Error ? compactError.message : "AI compaction failed");
|
|
936
|
+
return false;
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
const retryUserMessage = async (
|
|
941
|
+
messageId: string,
|
|
942
|
+
input: {
|
|
943
|
+
content?: AiUserContentPart[];
|
|
944
|
+
mode?: "retry" | "details" | "concise";
|
|
945
|
+
modelProfileId?: string;
|
|
946
|
+
} = {},
|
|
947
|
+
) => {
|
|
948
|
+
const conversationId = activeConversationId();
|
|
949
|
+
if (!conversationId) return false;
|
|
950
|
+
const status = runStatus();
|
|
951
|
+
if (status === "streaming" || status === "stopping") {
|
|
952
|
+
setConversationError(conversationId, "Stop the current response before trying a message again.");
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
setRunStatusRaw("streaming");
|
|
956
|
+
clearErrors();
|
|
957
|
+
try {
|
|
958
|
+
const result = await request<SubmitTurnResult>(
|
|
959
|
+
`/conversations/${conversationId}/messages/${messageId}/retry`,
|
|
960
|
+
{
|
|
961
|
+
method: "POST",
|
|
962
|
+
body: JSON.stringify({
|
|
963
|
+
mode: input.mode ?? "retry",
|
|
964
|
+
content: input.content,
|
|
965
|
+
modelProfileId: input.modelProfileId,
|
|
966
|
+
}),
|
|
967
|
+
},
|
|
968
|
+
"AI retry failed",
|
|
969
|
+
);
|
|
970
|
+
if (invalidateInactiveCache(conversationId)) return true;
|
|
971
|
+
// Truncate the client view to before the retried message, then show the new one.
|
|
972
|
+
setState("messages", (prev) => [...prev.filter((message) => message.seq < result.message.seq), result.message]);
|
|
973
|
+
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
974
|
+
void refreshTimeline(conversationId);
|
|
975
|
+
return true;
|
|
976
|
+
} catch (retryError) {
|
|
977
|
+
if (invalidateInactiveCache(conversationId)) return false;
|
|
978
|
+
setRunStatusRaw("failed");
|
|
979
|
+
setConversationError(conversationId, retryError instanceof Error ? retryError.message : "AI retry failed");
|
|
980
|
+
return false;
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
const forkMessage = async (messageId: string, input: { title?: string } = {}) => {
|
|
985
|
+
const conversationId = activeConversationId();
|
|
986
|
+
if (!conversationId) return null;
|
|
987
|
+
const generation = ++conversationOpenGeneration;
|
|
988
|
+
clearErrors();
|
|
989
|
+
try {
|
|
990
|
+
const detail = await request<AiConversationDetail>(
|
|
991
|
+
`/conversations/${conversationId}/messages/${messageId}/fork`,
|
|
992
|
+
{ method: "POST", body: JSON.stringify(input) },
|
|
993
|
+
"Failed to fork conversation",
|
|
994
|
+
);
|
|
995
|
+
const projection = detailToProjection(detail);
|
|
996
|
+
cache.set(detail.conversation.id, projection);
|
|
997
|
+
setHasMore(detail.conversation.id, detail.hasMoreMessages ?? false);
|
|
998
|
+
if (detail.timeline) setTimeline(detail.conversation.id, detail.timeline);
|
|
999
|
+
if (!isActiveConversation(conversationId) || generation !== conversationOpenGeneration) return detail.conversation;
|
|
1000
|
+
setState(reconcile(projection, { key: "id", merge: true }));
|
|
1001
|
+
setRunError(conversationRunError(detail.conversation));
|
|
1002
|
+
setActiveConversationIdSignal(detail.conversation.id);
|
|
1003
|
+
setLoadingConversationId(null);
|
|
1004
|
+
openStream(detail.conversation.id);
|
|
1005
|
+
return detail.conversation;
|
|
1006
|
+
} catch (forkError) {
|
|
1007
|
+
setConversationError(conversationId, forkError instanceof Error ? forkError.message : "Failed to fork conversation");
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
const updateMessageFeedback = (conversationId: string, messageId: string, feedback: AiMessageFeedback | null) => {
|
|
1013
|
+
if (!isActiveConversation(conversationId)) {
|
|
1014
|
+
const projection = cache.get(conversationId);
|
|
1015
|
+
if (projection)
|
|
1016
|
+
cache.set(conversationId, {
|
|
1017
|
+
...projection,
|
|
1018
|
+
messages: projection.messages.map((message) =>
|
|
1019
|
+
message.id === messageId || message.shortId === messageId ? { ...message, feedback } : message,
|
|
1020
|
+
),
|
|
1021
|
+
});
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const index = state.messages.findIndex((message) => message.id === messageId || message.shortId === messageId);
|
|
1025
|
+
if (index < 0) return;
|
|
1026
|
+
setState("messages", index, "feedback", feedback);
|
|
1027
|
+
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
const setMessageFeedback = async (messageId: string, feedback: Omit<AiMessageFeedback, "updatedAt">) => {
|
|
1031
|
+
const conversationId = activeConversationId();
|
|
1032
|
+
if (!conversationId) return false;
|
|
1033
|
+
clearErrors();
|
|
1034
|
+
try {
|
|
1035
|
+
const result = await request<{ feedback: AiMessageFeedback }>(
|
|
1036
|
+
`/conversations/${conversationId}/messages/${messageId}/feedback`,
|
|
1037
|
+
{ method: "PUT", body: JSON.stringify(feedback) },
|
|
1038
|
+
"Could not save message feedback",
|
|
1039
|
+
);
|
|
1040
|
+
updateMessageFeedback(conversationId, messageId, result.feedback);
|
|
1041
|
+
return true;
|
|
1042
|
+
} catch (feedbackError) {
|
|
1043
|
+
setConversationError(conversationId, feedbackError instanceof Error ? feedbackError.message : "Could not save message feedback");
|
|
1044
|
+
return false;
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
const clearMessageFeedback = async (messageId: string) => {
|
|
1049
|
+
const conversationId = activeConversationId();
|
|
1050
|
+
if (!conversationId) return false;
|
|
1051
|
+
clearErrors();
|
|
1052
|
+
try {
|
|
1053
|
+
await request(
|
|
1054
|
+
`/conversations/${conversationId}/messages/${messageId}/feedback`,
|
|
1055
|
+
{ method: "DELETE" },
|
|
1056
|
+
"Could not remove message feedback",
|
|
1057
|
+
);
|
|
1058
|
+
updateMessageFeedback(conversationId, messageId, null);
|
|
1059
|
+
return true;
|
|
1060
|
+
} catch (feedbackError) {
|
|
1061
|
+
setConversationError(conversationId, feedbackError instanceof Error ? feedbackError.message : "Could not remove message feedback");
|
|
1062
|
+
return false;
|
|
1063
|
+
}
|
|
1064
|
+
};
|
|
1065
|
+
|
|
1066
|
+
const submitTurnActionForConversation = async (
|
|
1067
|
+
conversationId: string,
|
|
1068
|
+
turnId: string,
|
|
1069
|
+
callId: string,
|
|
1070
|
+
action: { type: "approval_response"; approved: boolean; remember?: "always" } | { type: "tool_result"; result: unknown },
|
|
1071
|
+
) => {
|
|
1072
|
+
if (isActiveConversation(conversationId) && runStatus() === "stopping") return false;
|
|
1073
|
+
try {
|
|
1074
|
+
await request(
|
|
1075
|
+
`/conversations/${conversationId}/turns/${turnId}/actions/${callId}`,
|
|
1076
|
+
{ method: "POST", body: JSON.stringify(action) },
|
|
1077
|
+
"Failed to continue AI turn",
|
|
1078
|
+
);
|
|
1079
|
+
return true;
|
|
1080
|
+
} catch (actionError) {
|
|
1081
|
+
setConversationError(conversationId, actionError instanceof Error ? actionError.message : "Failed to continue AI turn");
|
|
1082
|
+
return false;
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
const submitTurnAction = (
|
|
1087
|
+
turnId: string,
|
|
1088
|
+
callId: string,
|
|
1089
|
+
action: { type: "approval_response"; approved: boolean; remember?: "always" } | { type: "tool_result"; result: unknown },
|
|
1090
|
+
) => {
|
|
1091
|
+
const conversationId = activeConversationId();
|
|
1092
|
+
if (!conversationId) return Promise.resolve(false);
|
|
1093
|
+
clearErrors();
|
|
1094
|
+
return submitTurnActionForConversation(conversationId, turnId, callId, action);
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
const respondToApproval = async (request: { turnId: string; callId: string }, input: { approved: boolean; remember?: "always" }) => {
|
|
1098
|
+
const conversationId = activeConversationId();
|
|
1099
|
+
if (!conversationId) return false;
|
|
1100
|
+
const submitted = await submitTurnAction(request.turnId, request.callId, {
|
|
1101
|
+
type: "approval_response",
|
|
1102
|
+
approved: input.approved,
|
|
1103
|
+
remember: input.remember,
|
|
1104
|
+
});
|
|
1105
|
+
if (!submitted || !isActiveConversation(conversationId) || state.activeTurn?.turnId !== request.turnId) return submitted;
|
|
1106
|
+
const action = { type: "approval_response" as const, callId: request.callId, approved: input.approved };
|
|
1107
|
+
setState("activeTurn", (current) => {
|
|
1108
|
+
if (!current || current.turnId !== request.turnId) return current;
|
|
1109
|
+
const pending = current.blocks.some(
|
|
1110
|
+
(block) => block.kind === "tool" && block.callId === request.callId && block.status === "awaiting_approval",
|
|
1111
|
+
);
|
|
1112
|
+
if (!pending) return current;
|
|
1113
|
+
return reconcileActiveTurnActions(current, [{ callId: request.callId, resolvedEvent: action }]);
|
|
1114
|
+
});
|
|
1115
|
+
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
1116
|
+
return true;
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
const submitFrontendToolResult = async (request: { turnId: string; callId: string }, result: unknown) => {
|
|
1120
|
+
const conversationId = activeConversationId();
|
|
1121
|
+
if (!conversationId) return false;
|
|
1122
|
+
clearErrors();
|
|
1123
|
+
const submitted = await submitTurnActionForConversation(conversationId, request.turnId, request.callId, {
|
|
1124
|
+
type: "tool_result",
|
|
1125
|
+
result,
|
|
1126
|
+
});
|
|
1127
|
+
if (!submitted || !isActiveConversation(conversationId) || state.activeTurn?.turnId !== request.turnId) return submitted;
|
|
1128
|
+
const action = { type: "tool_result" as const, callId: request.callId, result };
|
|
1129
|
+
setState("activeTurn", (current) => {
|
|
1130
|
+
if (!current || current.turnId !== request.turnId) return current;
|
|
1131
|
+
const pending = current.blocks.some(
|
|
1132
|
+
(block) => block.kind === "tool" && block.callId === request.callId && block.status === "awaiting_client",
|
|
1133
|
+
);
|
|
1134
|
+
if (!pending) return current;
|
|
1135
|
+
return reconcileActiveTurnActions(current, [{ callId: request.callId, resolvedEvent: action }]);
|
|
1136
|
+
});
|
|
1137
|
+
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
1138
|
+
return true;
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
if (options.initialConversationId) {
|
|
1142
|
+
if (options.initialDetail) setHasMore(options.initialConversationId, options.initialDetail.hasMoreMessages ?? false);
|
|
1143
|
+
openStream(options.initialConversationId);
|
|
1144
|
+
void markConversationViewed(options.initialConversationId);
|
|
1145
|
+
runFrontendTools();
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
onCleanup(() => closeStream());
|
|
1149
|
+
|
|
1150
|
+
const hasMoreHistory = () => {
|
|
1151
|
+
const conversationId = activeConversationId();
|
|
1152
|
+
return conversationId ? (hasMoreByConversation()[conversationId] ?? false) : false;
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
return {
|
|
1156
|
+
activeConversationId,
|
|
1157
|
+
conversation: () => state.conversation,
|
|
1158
|
+
setActiveConversationId,
|
|
1159
|
+
messages,
|
|
1160
|
+
activeTurn,
|
|
1161
|
+
hasMoreHistory,
|
|
1162
|
+
loadingOlder: () => isActiveConversationLoading(activeConversationId(), loadingOlderConversationId()),
|
|
1163
|
+
loadingConversation: () => isActiveConversationLoading(activeConversationId(), loadingConversationId()),
|
|
1164
|
+
loadOlderMessages,
|
|
1165
|
+
loadHistoryThroughSeq,
|
|
1166
|
+
timeline,
|
|
1167
|
+
timelineLoading: () => isActiveConversationLoading(activeConversationId(), loadingTimelineConversationId()),
|
|
1168
|
+
refreshTimeline,
|
|
1169
|
+
runStatus,
|
|
1170
|
+
running,
|
|
1171
|
+
streamStatus,
|
|
1172
|
+
error,
|
|
1173
|
+
setError: setActiveError,
|
|
1174
|
+
openConversation,
|
|
1175
|
+
refreshActiveConversation,
|
|
1176
|
+
createConversation,
|
|
1177
|
+
saveDraft,
|
|
1178
|
+
send,
|
|
1179
|
+
steer,
|
|
1180
|
+
retrySteer,
|
|
1181
|
+
abort,
|
|
1182
|
+
compactConversation,
|
|
1183
|
+
retryUserMessage,
|
|
1184
|
+
forkMessage,
|
|
1185
|
+
setMessageFeedback,
|
|
1186
|
+
clearMessageFeedback,
|
|
1187
|
+
submitTurnAction,
|
|
1188
|
+
respondToApproval,
|
|
1189
|
+
submitFrontendToolResult,
|
|
1190
|
+
fileContentUrl,
|
|
1191
|
+
};
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
export type AiChatController = ReturnType<typeof createAiChatController>;
|
|
1195
|
+
|
|
1196
|
+
const isComposerDraftSendable = (input: ComposerDraftInput): boolean =>
|
|
1197
|
+
Boolean(input.message?.trim() || input.content?.length || input.files?.length || input.resources?.length || input.storedFiles?.length);
|
|
1198
|
+
|
|
1199
|
+
export const __aiControllerTest = {
|
|
1200
|
+
claimFrontendCall,
|
|
1201
|
+
completeFrontendToolBlock,
|
|
1202
|
+
conversationRunError,
|
|
1203
|
+
failSteerBlock,
|
|
1204
|
+
projectionForConversationOpen,
|
|
1205
|
+
reconcileSteerBlocks,
|
|
1206
|
+
runErrorFromEvent,
|
|
1207
|
+
isCurrentStreamSession,
|
|
1208
|
+
isActiveConversationLoading,
|
|
1209
|
+
settleFrontendCall,
|
|
1210
|
+
isComposerDraftSendable,
|
|
1211
|
+
};
|