@k2b/cloud 0.6.0 → 0.8.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 +2 -2
- package/package.json +22 -11
- package/scripts/README.md +10 -0
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +20 -35
- package/scripts/preload.ts +16 -12
- package/scripts/sync-recovery-smoke.ts +22 -6
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/build-metadata.ts +8 -1
- package/src/_internal/capabilities.ts +131 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +80 -37
- package/src/_internal/heartbeat.ts +4 -0
- package/src/_internal/help-catalog.ts +27 -172
- package/src/_internal/help.ts +13 -76
- package/src/_internal/page-responses.ts +2 -0
- package/src/_internal/postgres-application-name.ts +30 -0
- package/src/_internal/process-identity.ts +15 -0
- package/src/_internal/process-sync.ts +1 -1
- package/src/_internal/readiness.ts +4 -1
- package/src/_internal/registry-validation.ts +29 -25
- package/src/_internal/registry.ts +2 -53
- package/src/_internal/runtime-context.ts +5 -16
- package/src/_internal/server-timing.ts +13 -0
- package/src/_internal/status-preserving-ssr.ts +18 -6
- package/src/_internal/web-vitals-asset.ts +10 -0
- package/src/access/PermissionEditor.tsx +18 -151
- package/src/access/PrincipalPicker.tsx +110 -0
- package/src/access/ResourceApiKeys.tsx +1 -1
- package/src/access/ui.ts +1 -0
- package/src/ai/admin.ts +14 -13
- package/src/ai/assistant-models.ts +12 -1
- package/src/ai/audio-format.ts +34 -0
- package/src/ai/audio-tool.ts +90 -0
- package/src/ai/browser-code-contracts.ts +201 -0
- package/src/ai/browser.ts +4 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +229 -90
- package/src/ai/chat/builtin-tools.tsx +2 -4
- package/src/ai/chat/capability-result.ts +15 -0
- package/src/ai/chat/capability-table.tsx +2 -10
- package/src/ai/chat/composer-adapter.ts +83 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +25 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +31 -47
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +21 -2
- package/src/ai/chat/tool-groups.ts +58 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +14 -10
- package/src/ai/chat-tasks.ts +297 -43
- package/src/ai/client/controller.ts +165 -39
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +29 -11
- package/src/ai/client/transport.ts +6 -0
- package/src/ai/code-capability-routes.ts +105 -0
- package/src/ai/code-capability-transport.ts +39 -0
- package/src/ai/code-execution.ts +53 -0
- package/src/ai/code-mode-skill.ts +118 -0
- package/src/ai/code-runtime-tools.ts +179 -0
- package/src/ai/code-source-contracts.ts +270 -0
- package/src/ai/code-source-tools.ts +188 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +99 -3
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +49 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +189 -33
- package/src/ai/fetch-file-tool.ts +2 -2
- package/src/ai/file-content-version.ts +27 -0
- package/src/ai/file-context.ts +2 -1
- package/src/ai/file-media-type.ts +12 -0
- package/src/ai/file-reference-contracts.ts +15 -0
- package/src/ai/file-tools.ts +8 -1
- package/src/ai/files-store.ts +212 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +42 -10
- package/src/ai/http.ts +26 -8
- package/src/ai/index.ts +26 -3
- package/src/ai/inference-calls.ts +197 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/live.ts +1 -1
- package/src/ai/markdown-pdf-tool.ts +2 -2
- package/src/ai/memories.ts +30 -27
- package/src/ai/memory-learning-runs.ts +1 -1
- package/src/ai/memory-learning.ts +64 -32
- package/src/ai/memory-tool.ts +1 -5
- package/src/ai/memory-workflow-evidence.ts +9 -4
- package/src/ai/message-queue.ts +178 -0
- package/src/ai/migrate.ts +189 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +75 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +18 -43
- package/src/ai/projects.ts +33 -14
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +192 -0
- package/src/ai/quota-report.ts +152 -0
- package/src/ai/quotas-migrate.ts +45 -0
- package/src/ai/quotas.ts +168 -0
- package/src/ai/routes.ts +241 -11
- package/src/ai/run-timeout.ts +12 -0
- package/src/ai/runtime-tools.ts +7 -5
- package/src/ai/runtime.ts +74 -17
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +4 -5
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +58 -0
- package/src/ai/skill-seeds.ts +114 -20
- package/src/ai/skill-tool.ts +21 -5
- package/src/ai/skills-routes.ts +49 -4
- package/src/ai/skills.ts +358 -163
- package/src/ai/store.ts +416 -201
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +12 -1
- package/src/ai/system-prompt.ts +14 -2
- package/src/ai/task-contracts.ts +84 -0
- package/src/ai/task-execution.ts +92 -0
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +32 -0
- package/src/ai/todo-tool.ts +19 -0
- package/src/ai/tool-audit.ts +30 -40
- package/src/ai/tools.ts +6 -0
- package/src/ai/transcription.ts +202 -0
- package/src/ai/turn-timing.ts +114 -0
- package/src/ai/types.ts +58 -10
- package/src/ai/ui.tsx +3 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +60 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +80 -0
- package/src/api/admin-ai-skills.ts +48 -5
- package/src/api/admin-ai-usage.ts +2 -2
- package/src/api/admin-core-settings.ts +25 -0
- package/src/api/admin-identity.ts +91 -51
- package/src/api/admin-linux-identities.ts +10 -4
- package/src/api/admin-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/app-approval.ts +2 -2
- package/src/api/auth.ts +1 -1
- package/src/api/capabilities.ts +324 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +8 -2
- package/src/api/mcp.ts +45 -34
- package/src/api/me-web-vitals.ts +35 -0
- package/src/api/me.ts +2 -0
- package/src/api/search/schemas.ts +26 -15
- package/src/api/search.bench.ts +1 -1
- package/src/api/search.ts +54 -17
- package/src/browser/CloudResourceSearch.tsx +640 -702
- package/src/browser/app-approval-vault.ts +19 -7
- package/src/browser/command-bridge.ts +109 -0
- package/src/browser/command-shortcuts.ts +76 -0
- package/src/browser/commands.ts +67 -0
- package/src/browser/navigation-search.ts +48 -0
- package/src/browser/notifications.ts +1 -1
- package/src/browser/resource-picker.tsx +5 -18
- package/src/browser/resource-search-dialog.ts +9 -0
- package/src/browser/resource-search-input.ts +46 -0
- package/src/browser/resource-search-messages.ts +76 -40
- package/src/browser/resource-search.ts +7 -0
- package/src/browser/search-bridge.ts +102 -0
- package/src/browser/search-commands.ts +59 -0
- package/src/browser/search.ts +13 -0
- package/src/browser/spotlight-position.ts +208 -0
- package/src/browser/testing.ts +6 -0
- package/src/browser/web-vitals.ts +25 -0
- package/src/capabilities/claims.ts +179 -0
- package/src/capabilities/client.ts +34 -0
- package/src/capabilities/command-link.ts +16 -0
- package/src/capabilities/executions.ts +298 -0
- package/src/capabilities/migrate.ts +81 -0
- package/src/capabilities/server.ts +116 -35
- package/src/capabilities/stream-body.ts +16 -0
- package/src/capabilities/streams.ts +28 -0
- package/src/cli/admin/ai-quotas.ts +161 -0
- package/src/cli/admin/ai-skills.ts +64 -0
- package/src/cli/admin/ai-usage.ts +53 -30
- package/src/cli/admin/app-credentials.ts +77 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +10 -1
- package/src/cli/admin/instance.ts +2 -2
- package/src/cli/admin/linux.ts +4 -2
- package/src/cli/admin/sync.ts +1 -1
- package/src/cli/capabilities.ts +42 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/define-env.ts +97 -0
- package/src/config/env.ts +147 -51
- package/src/config/index.ts +3 -1
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +75 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +5 -2
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +5 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +18 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/index.ts +1 -0
- package/src/desktop/solid.tsx +1 -1
- package/src/index.ts +8 -6
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +3 -2
- package/src/server/middleware/route-template.ts +9 -3
- package/src/server/middleware/runtime.ts +2 -1
- package/src/server/middleware/settings.ts +17 -11
- package/src/server/ratelimit.ts +1 -1
- package/src/server/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +3 -1
- package/src/server/services/index.ts +1 -0
- package/src/services/account-category-policy.ts +9 -0
- package/src/services/account-lifecycle/audit.ts +1 -1
- package/src/services/accounts/app.ts +12 -2
- package/src/services/accounts/email-write.ts +23 -0
- package/src/services/accounts/groups.ts +15 -4
- package/src/services/accounts/identities.ts +249 -0
- package/src/services/accounts/identity-reconciliation.ts +87 -0
- package/src/services/accounts/ipa-data.ts +2 -2
- package/src/services/accounts/local-groups.ts +2 -2
- package/src/services/accounts/notification-sender.ts +12 -2
- package/src/services/accounts/posix.ts +45 -29
- package/src/services/accounts/users.ts +2 -2
- package/src/services/announcements/index.ts +69 -8
- package/src/services/app-approval.ts +11 -10
- package/src/services/audit/index.ts +1 -1
- package/src/services/cache-fill.ts +35 -0
- package/src/services/help/index.ts +109 -0
- package/src/services/help/maintenance.ts +25 -0
- package/src/services/help/store.ts +180 -0
- package/src/services/help/types.ts +46 -0
- package/src/services/identity/invocation-actor.ts +2 -2
- package/src/services/identity/key-config.ts +8 -5
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/identity/runtime-config.ts +6 -5
- package/src/services/index.ts +15 -0
- package/src/services/ipa/posix.ts +1 -1
- package/src/services/ipa/sync.ts +1 -1
- package/src/services/ipa/users.ts +268 -221
- package/src/services/legal-consent.ts +1 -1
- package/src/services/logging/index.ts +15 -13
- package/src/services/logging/trace.ts +2 -1
- package/src/services/logging/web-vitals.ts +81 -0
- package/src/services/mandates/index.ts +21 -4
- package/src/services/mandates/policy.ts +165 -2
- package/src/services/notifications/batches.ts +125 -57
- package/src/services/notifications/browser.ts +12 -0
- package/src/services/notifications/index.ts +0 -2
- package/src/services/notifications/platform.ts +0 -4
- package/src/services/oauth-tokens.ts +3 -3
- package/src/services/pdf/gotenberg.ts +131 -72
- package/src/services/pdf/index.ts +11 -8
- package/src/services/providers/local/users.ts +25 -16
- package/src/services/public-http.ts +101 -0
- package/src/services/rail-shortcuts.ts +95 -0
- package/src/services/rail-snapshot.ts +53 -0
- package/src/services/request-cache-redis.ts +29 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/app.ts +5 -1
- package/src/services/settings/core-settings.ts +26 -35
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/index.ts +3 -3
- package/src/services/settings/store.ts +58 -84
- package/src/services/webauthn.ts +1 -1
- package/src/shared/ai-costs.ts +42 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-quotas.ts +136 -0
- package/src/shared/ai-usage.ts +6 -16
- package/src/shared/app-presentation.ts +21 -15
- package/src/shared/capability-messages.ts +14 -0
- package/src/shared/help.ts +6 -42
- package/src/shared/index.ts +3 -0
- package/src/shared/markdown/extensions/info-blocks.ts +1 -1
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +30 -16
- package/src/ssr/AppLaunchpad.island.tsx +33 -136
- package/src/ssr/AppLaunchpadPanel.tsx +181 -0
- package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
- package/src/ssr/GlobalSearchDialog.tsx +167 -20
- package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
- package/src/ssr/Layout.tsx +17 -6
- package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +46 -14
- package/src/ssr/LayoutHelp.tsx +22 -23
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- package/src/ssr/MinimalLayout.tsx +1 -1
- package/src/ssr/MobileNavigation.tsx +71 -0
- package/src/ssr/MobileProfileActions.tsx +49 -0
- package/src/ssr/ProfilePreferences.island.tsx +3 -36
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/RailEditor.tsx +17 -2
- package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
- package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
- package/src/ssr/admin-navigation.ts +11 -1
- package/src/ssr/help.ts +29 -0
- package/src/ssr/islands/SearchBar.tsx +67 -0
- package/src/ssr/islands/index.ts +3 -1
- package/src/ssr/layout-context.ts +2 -2
- package/src/ssr/layout-help-search.ts +15 -0
- package/src/ssr/mobile-menu-history.ts +70 -0
- package/src/ssr/platform-messages.ts +16 -8
- package/src/ssr/profile-actions.ts +42 -0
- package/src/ssr/rail-context.ts +8 -4
- package/src/ssr/rail-messages.ts +2 -0
- package/src/ssr/rail-navigation.ts +3 -3
- package/src/ssr/workspace-navigation.ts +75 -0
- package/src/styles/effects.css +6 -6
- package/src/styles/global.css +54 -0
- package/src/styles/input.css +43 -41
- package/src/styles/resource-search.css +505 -0
- package/src/styles/utilities-feedback.css +13 -0
- package/src/styles/utilities-layout.css +1 -5
- package/src/styles/utilities-navigation.css +3 -39
- package/src/types/ambient.d.ts +5 -0
- package/src/workflows/ai/index.ts +2 -2
- package/src/workflows/ai/runtime.ts +19 -90
- package/src/workflows/ai/store.ts +2 -0
- package/src/workflows/ai/types.ts +6 -82
- package/src/workflows/ai-actions.ts +16 -13
- package/src/workflows/definition.ts +10 -5
- package/src/workflows/store/actions.ts +44 -20
- package/src/workflows/store/index.ts +4 -2
- package/src/workflows/store/runs.ts +36 -0
- package/src/workflows/store/worker-pool.ts +55 -0
- package/src/workflows/store/worker-runtime.ts +90 -0
- package/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/server/services/freeipa/test-certificates.ts +0 -44
- package/src/services/notifications/live.ts +0 -50
- package/src/services/session/test-fixture.ts +0 -18
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/islands/SearchBar.island.tsx +0 -80
|
@@ -4,6 +4,7 @@ import { type AiAttachmentRef, aiAttachmentMarker } from "../attachments";
|
|
|
4
4
|
import { type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
|
|
5
5
|
import { type AiResourceMarker, aiResourceMarker } from "../resource-markers";
|
|
6
6
|
import type {
|
|
7
|
+
AiClientToolId,
|
|
7
8
|
AiConversation,
|
|
8
9
|
AiConversationTimelineEntry,
|
|
9
10
|
AiDraftContentPart,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
activeTurnFromSnapshot,
|
|
19
20
|
emptyProjection,
|
|
20
21
|
mergeActiveTurn,
|
|
22
|
+
messagesWithPendingSend,
|
|
21
23
|
reconcileActiveTurnActions,
|
|
22
24
|
reduceProjection,
|
|
23
25
|
visibleMessages,
|
|
@@ -25,6 +27,10 @@ import {
|
|
|
25
27
|
import { type AiConversationStreamTransport, type AiStreamHandle, aiSseConversationStreamTransport } from "./transport";
|
|
26
28
|
|
|
27
29
|
type ComposerDraftInput = {
|
|
30
|
+
draftContent?: AiDraftContentPart[];
|
|
31
|
+
/** Bind local content to the server draft it was actually based on. */
|
|
32
|
+
conversationId?: string;
|
|
33
|
+
expectedDraftRevision?: number;
|
|
28
34
|
message?: string;
|
|
29
35
|
content?: AiUserContentPart[];
|
|
30
36
|
files?: File[];
|
|
@@ -36,6 +42,7 @@ export type AiChatRunStatus = "idle" | "streaming" | "waiting_for_action" | "sto
|
|
|
36
42
|
export type AiStreamStatus = "idle" | "connecting" | "open" | "reconnecting";
|
|
37
43
|
|
|
38
44
|
export type AiFrontendToolHandler = (request: {
|
|
45
|
+
conversationId: string;
|
|
39
46
|
name: string;
|
|
40
47
|
callId: string;
|
|
41
48
|
args: unknown;
|
|
@@ -99,6 +106,8 @@ export type CreateAiChatControllerOptions = {
|
|
|
99
106
|
/** Persist owner read state for conversation-list unread indicators. */
|
|
100
107
|
trackViewedState?: boolean;
|
|
101
108
|
frontendTools?: Record<string, AiFrontendToolHandler>;
|
|
109
|
+
/** Explicitly advertise only client tools with a connected handler. */
|
|
110
|
+
clientToolIds?: AiClientToolId[];
|
|
102
111
|
/** Overrides the default SSE transport, for example with a shared WebSocket channel. */
|
|
103
112
|
streamTransport?: AiConversationStreamTransport;
|
|
104
113
|
};
|
|
@@ -141,6 +150,14 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
141
150
|
const [streamStatus, setStreamStatus] = createSignal<AiStreamStatus>("idle");
|
|
142
151
|
const initialProjection = options.initialDetail ? detailToProjection(options.initialDetail) : emptyProjection();
|
|
143
152
|
const [state, setState] = createStore<AiChatProjection>(initialProjection);
|
|
153
|
+
const completedDuringSend = new Map<string, string>();
|
|
154
|
+
const [pendingSends, setPendingSends] = createSignal<Record<string, AiStoredMessage | undefined>>({});
|
|
155
|
+
const clearPendingSend = (conversationId: string) =>
|
|
156
|
+
setPendingSends((current) => {
|
|
157
|
+
const next = { ...current };
|
|
158
|
+
delete next[conversationId];
|
|
159
|
+
return next;
|
|
160
|
+
});
|
|
144
161
|
|
|
145
162
|
// Cache of projections for conversations opened this session (fast switching).
|
|
146
163
|
const cache = new Map<string, AiChatProjection>();
|
|
@@ -207,7 +224,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
207
224
|
const error = () => globalError() ?? runError();
|
|
208
225
|
|
|
209
226
|
const activeTurn = () => state.activeTurn;
|
|
210
|
-
const messages = createMemo(() => visibleMessages(state));
|
|
227
|
+
const messages = createMemo(() => messagesWithPendingSend(visibleMessages(state), pendingSends()[activeConversationId() ?? ""]));
|
|
211
228
|
const runStatus = (): AiChatRunStatus => {
|
|
212
229
|
const override = runStatusRaw();
|
|
213
230
|
if (override) return override;
|
|
@@ -226,6 +243,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
226
243
|
const next = reduceProjection({ conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn }, event);
|
|
227
244
|
setState(reconcile(next, { key: "id", merge: true }));
|
|
228
245
|
cache.set(conversationId, next);
|
|
246
|
+
if (event.type === "turn_finished" && pendingSends()[conversationId]?.loopId === event.turnId) clearPendingSend(conversationId);
|
|
229
247
|
};
|
|
230
248
|
|
|
231
249
|
/**
|
|
@@ -241,6 +259,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
241
259
|
setProjection(detailToProjection(detail), session.conversationId);
|
|
242
260
|
setHasMore(session.conversationId, detail.hasMoreMessages ?? false);
|
|
243
261
|
if (detail.timeline) setTimeline(session.conversationId, detail.timeline);
|
|
262
|
+
if (event.type === "turn_finished" && pendingSends()[session.conversationId]?.loopId === event.turnId)
|
|
263
|
+
clearPendingSend(session.conversationId);
|
|
244
264
|
} else reduceEvent(session.conversationId, event);
|
|
245
265
|
setRunStatusRaw(null);
|
|
246
266
|
void markConversationViewed(session.conversationId);
|
|
@@ -249,6 +269,9 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
249
269
|
const applyEvent = (session: AiStreamSession, event: AiStreamEvent) => {
|
|
250
270
|
if (!isCurrentStreamSession(streamSession, session)) return;
|
|
251
271
|
const conversationId = session.conversationId;
|
|
272
|
+
if (event.type === "turn_finished" && pendingSends()[conversationId]) {
|
|
273
|
+
completedDuringSend.set(conversationId, event.turnId);
|
|
274
|
+
}
|
|
252
275
|
|
|
253
276
|
if (event.type === "state") setHasMore(conversationId, event.hasMoreMessages ?? false);
|
|
254
277
|
const nextRunError = runErrorFromEvent(event, state.activeTurn?.turnId);
|
|
@@ -265,7 +288,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
265
288
|
|
|
266
289
|
reduceEvent(conversationId, event);
|
|
267
290
|
if (event.type === "state" && loadingConversationId() === conversationId) setLoadingConversationId(null);
|
|
268
|
-
if (runStatusRaw() && runStatusRaw() !== "stopping") setRunStatusRaw(null);
|
|
291
|
+
if (runStatusRaw() && (runStatusRaw() !== "stopping" || !state.activeTurn)) setRunStatusRaw(null);
|
|
269
292
|
|
|
270
293
|
if (event.type === "turn_finished") {
|
|
271
294
|
setRunStatusRaw(null);
|
|
@@ -338,7 +361,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
338
361
|
return false;
|
|
339
362
|
}
|
|
340
363
|
try {
|
|
341
|
-
const result = await handler({ name, callId, args, turnId });
|
|
364
|
+
const result = await handler({ name, callId, args, turnId, conversationId });
|
|
342
365
|
return submitTurnActionForConversation(conversationId, turnId, callId, { type: "tool_result", result });
|
|
343
366
|
} catch (toolError) {
|
|
344
367
|
return submitTurnActionForConversation(conversationId, turnId, callId, {
|
|
@@ -394,6 +417,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
394
417
|
};
|
|
395
418
|
cache.set(conversationId, next);
|
|
396
419
|
setState(reconcile(next, { key: "id", merge: true }));
|
|
420
|
+
if (!next.activeTurn && runStatusRaw() === "stopping") setRunStatusRaw(null);
|
|
397
421
|
};
|
|
398
422
|
|
|
399
423
|
const openConversation = async (conversationId: string) => {
|
|
@@ -436,15 +460,25 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
436
460
|
return generation === conversationOpenGeneration ? ("failed" as const) : ("stale" as const);
|
|
437
461
|
};
|
|
438
462
|
|
|
463
|
+
let conversationRefreshGeneration = 0;
|
|
439
464
|
const refreshActiveConversation = async (): Promise<void> => {
|
|
440
465
|
const conversationId = activeConversationId();
|
|
441
466
|
if (!conversationId) return;
|
|
467
|
+
const refreshGeneration = ++conversationRefreshGeneration;
|
|
468
|
+
const openGeneration = conversationOpenGeneration;
|
|
442
469
|
const detail = await request<AiConversationDetail>(
|
|
443
470
|
`/conversations/${conversationId}`,
|
|
444
471
|
{ method: "GET" },
|
|
445
472
|
"Failed to refresh conversation",
|
|
446
473
|
);
|
|
447
|
-
if (
|
|
474
|
+
if (
|
|
475
|
+
!isActiveConversation(conversationId) ||
|
|
476
|
+
refreshGeneration !== conversationRefreshGeneration ||
|
|
477
|
+
openGeneration !== conversationOpenGeneration
|
|
478
|
+
)
|
|
479
|
+
return;
|
|
480
|
+
const currentDraft = state.conversation?.draft;
|
|
481
|
+
if (currentDraft && currentDraft.revision > detail.conversation.draft.revision) detail.conversation.draft = currentDraft;
|
|
448
482
|
const windowOldest = detail.messages[0]?.seq;
|
|
449
483
|
const preservedOlder = windowOldest === undefined ? [] : state.messages.filter((message) => message.seq < windowOldest);
|
|
450
484
|
setProjection({ ...detailToProjection(detail), messages: [...preservedOlder, ...detail.messages] }, conversationId);
|
|
@@ -653,7 +687,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
653
687
|
}
|
|
654
688
|
}
|
|
655
689
|
const content: AiDraftContentPart[] = [
|
|
656
|
-
...textParts,
|
|
690
|
+
...(input.draftContent ?? textParts),
|
|
657
691
|
...(input.resources ?? []).map((resource) => ({ type: "resource" as const, ...resource })),
|
|
658
692
|
...(input.storedFiles ?? []).map((file) => ({ type: "file" as const, ...file })),
|
|
659
693
|
...uploaded.map((file) => ({ type: "file" as const, ...file })),
|
|
@@ -661,10 +695,14 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
661
695
|
try {
|
|
662
696
|
const draft = await request<AiConversation["draft"]>(
|
|
663
697
|
`/conversations/${conversationId}/draft`,
|
|
664
|
-
{ method: "PUT", body: JSON.stringify({ expectedRevision: conversation.draft.revision, content }) },
|
|
698
|
+
{ method: "PUT", body: JSON.stringify({ expectedRevision: input.expectedDraftRevision ?? conversation.draft.revision, content }) },
|
|
665
699
|
"Failed to save conversation draft",
|
|
666
700
|
);
|
|
667
|
-
const
|
|
701
|
+
const current = isActiveConversation(conversationId) ? state.conversation : cache.get(conversationId)?.conversation;
|
|
702
|
+
const nextConversation = {
|
|
703
|
+
...(current ?? conversation),
|
|
704
|
+
draft: current && current.draft.revision > draft.revision ? current.draft : draft,
|
|
705
|
+
};
|
|
668
706
|
const projection = isActiveConversation(conversationId)
|
|
669
707
|
? { conversation: nextConversation, messages: state.messages, activeTurn: state.activeTurn }
|
|
670
708
|
: { ...(cache.get(conversationId) ?? emptyProjection(nextConversation)), conversation: nextConversation };
|
|
@@ -687,36 +725,79 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
687
725
|
};
|
|
688
726
|
|
|
689
727
|
const saveDraft = (input: ComposerDraftInput) => {
|
|
690
|
-
const targetConversation = ensureConversation();
|
|
728
|
+
const targetConversation = input.conversationId ? Promise.resolve(input.conversationId) : ensureConversation();
|
|
691
729
|
return queueDraftOperation(async () => persistComposerDraft(input, await targetConversation));
|
|
692
730
|
};
|
|
693
731
|
|
|
732
|
+
const queuedAttempts = new Map<string, { key: string; files?: File[]; id: string; saved: { revision: number } }>();
|
|
733
|
+
const queueMessage = (input: ComposerDraftInput & { modelProfileId?: string }): Promise<boolean> => {
|
|
734
|
+
if (!input.conversationId || !isComposerDraftSendable(input)) return Promise.resolve(false);
|
|
735
|
+
const conversationId = input.conversationId;
|
|
736
|
+
const key = JSON.stringify({
|
|
737
|
+
message: input.message,
|
|
738
|
+
content: input.content,
|
|
739
|
+
draftContent: input.draftContent,
|
|
740
|
+
resources: input.resources,
|
|
741
|
+
storedFiles: input.storedFiles,
|
|
742
|
+
model: input.modelProfileId,
|
|
743
|
+
});
|
|
744
|
+
return queueDraftOperation(async () => {
|
|
745
|
+
try {
|
|
746
|
+
const previous = queuedAttempts.get(conversationId);
|
|
747
|
+
const same =
|
|
748
|
+
previous?.key === key &&
|
|
749
|
+
(previous.files ?? []).length === (input.files ?? []).length &&
|
|
750
|
+
(previous.files ?? []).every((file, index) => file === input.files?.[index]);
|
|
751
|
+
const saved = same ? previous.saved : await persistComposerDraft(input, conversationId);
|
|
752
|
+
if (!saved) return false;
|
|
753
|
+
const queueId = same ? previous.id : crypto.randomUUID();
|
|
754
|
+
queuedAttempts.set(conversationId, { key, files: input.files, id: queueId, saved });
|
|
755
|
+
await request(
|
|
756
|
+
`/conversations/${conversationId}/turns`,
|
|
757
|
+
{
|
|
758
|
+
method: "POST",
|
|
759
|
+
body: JSON.stringify({
|
|
760
|
+
draftRevision: saved.revision,
|
|
761
|
+
queueId,
|
|
762
|
+
modelProfileId: input.modelProfileId,
|
|
763
|
+
clientToolIds: options.clientToolIds?.filter((id) => options.frontendTools?.[id]),
|
|
764
|
+
}),
|
|
765
|
+
},
|
|
766
|
+
"Failed to queue message",
|
|
767
|
+
);
|
|
768
|
+
if (isActiveConversation(conversationId)) {
|
|
769
|
+
if ((state.conversation?.draft.revision ?? 0) <= saved.revision)
|
|
770
|
+
setState("conversation", "draft", { content: [], revision: saved.revision + 1, updatedAt: new Date().toISOString() });
|
|
771
|
+
} else invalidateInactiveCache(conversationId);
|
|
772
|
+
queuedAttempts.delete(conversationId);
|
|
773
|
+
if (isActiveConversation(conversationId)) setGlobalError(null);
|
|
774
|
+
return true;
|
|
775
|
+
} catch (error) {
|
|
776
|
+
setConversationError(conversationId, error instanceof Error ? error.message : "Failed to queue message");
|
|
777
|
+
return false;
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
};
|
|
781
|
+
|
|
694
782
|
const send = (input: ComposerDraftInput & { modelProfileId?: string }): Promise<boolean> => {
|
|
695
783
|
if (!isComposerDraftSendable(input)) return Promise.resolve(false);
|
|
696
|
-
const targetConversation = ensureConversation();
|
|
784
|
+
const targetConversation = input.conversationId ? Promise.resolve(input.conversationId) : ensureConversation();
|
|
697
785
|
return queueDraftOperation(async () => {
|
|
698
|
-
const
|
|
699
|
-
if (!
|
|
700
|
-
const conversationId = savedDraft.conversationId;
|
|
701
|
-
if (isActiveConversation(conversationId) && running()) return false;
|
|
702
|
-
|
|
786
|
+
const conversationId = await targetConversation;
|
|
787
|
+
if (!conversationId || (isActiveConversation(conversationId) && running())) return false;
|
|
703
788
|
if (isActiveConversation(conversationId)) clearErrors();
|
|
704
789
|
const baseProjection = isActiveConversation(conversationId)
|
|
705
790
|
? { conversation: state.conversation, messages: [...state.messages], activeTurn: state.activeTurn }
|
|
706
791
|
: (cache.get(conversationId) ?? emptyProjection());
|
|
707
|
-
|
|
708
|
-
// Optimistic view renders attachments through the same marker format the server persists.
|
|
709
792
|
const optimisticContent: AiUserContentPart[] = [
|
|
710
|
-
...
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
];
|
|
719
|
-
}),
|
|
793
|
+
...(input.content ?? []),
|
|
794
|
+
...(!input.content?.length && input.message ? [{ type: "text" as const, text: input.message }] : []),
|
|
795
|
+
...(input.resources ?? []).map((ref) => ({ type: "text" as const, text: aiResourceMarker(ref) })),
|
|
796
|
+
...(input.storedFiles ?? []).map((file) => ({ type: "text" as const, text: aiAttachmentMarker(file) })),
|
|
797
|
+
...(input.files ?? []).map((file) => ({
|
|
798
|
+
type: "text" as const,
|
|
799
|
+
text: aiAttachmentMarker({ path: `/${file.name}`, mediaType: file.type, size: file.size }),
|
|
800
|
+
})),
|
|
720
801
|
];
|
|
721
802
|
|
|
722
803
|
// Optimistic: show the user message immediately.
|
|
@@ -738,39 +819,72 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
738
819
|
meta: null,
|
|
739
820
|
createdAt: new Date().toISOString(),
|
|
740
821
|
};
|
|
741
|
-
|
|
742
|
-
cache.set(conversationId, optimisticProjection);
|
|
822
|
+
setPendingSends((current) => ({ ...current, [conversationId]: optimistic }));
|
|
743
823
|
if (isActiveConversation(conversationId)) {
|
|
744
|
-
setState("messages", (prev) => [...prev, optimistic]);
|
|
745
824
|
setRunStatusRaw("streaming");
|
|
746
825
|
}
|
|
747
826
|
|
|
748
827
|
try {
|
|
828
|
+
const savedDraft = await persistComposerDraft(input, conversationId);
|
|
829
|
+
if (!savedDraft) {
|
|
830
|
+
clearPendingSend(conversationId);
|
|
831
|
+
if (isActiveConversation(conversationId)) setRunStatusRaw("failed");
|
|
832
|
+
return false;
|
|
833
|
+
}
|
|
834
|
+
setPendingSends((current) => ({
|
|
835
|
+
...current,
|
|
836
|
+
[conversationId]: {
|
|
837
|
+
...optimistic,
|
|
838
|
+
meta: { submittedDraftRevision: savedDraft.revision },
|
|
839
|
+
message: {
|
|
840
|
+
role: "user",
|
|
841
|
+
content: savedDraft.content.map((part) => ({
|
|
842
|
+
type: "text",
|
|
843
|
+
text:
|
|
844
|
+
part.type === "text"
|
|
845
|
+
? part.text
|
|
846
|
+
: part.type === "file"
|
|
847
|
+
? aiAttachmentMarker(part)
|
|
848
|
+
: part.type === "project-file"
|
|
849
|
+
? `Project file: ${part.path}`
|
|
850
|
+
: aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }),
|
|
851
|
+
})),
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
}));
|
|
749
855
|
const result = await request<SubmitTurnResult>(
|
|
750
856
|
`/conversations/${conversationId}/turns`,
|
|
751
857
|
{
|
|
752
858
|
method: "POST",
|
|
753
859
|
body: JSON.stringify({
|
|
754
860
|
draftRevision: savedDraft.revision,
|
|
861
|
+
clientToolIds: options.clientToolIds?.filter((id) => options.frontendTools?.[id]),
|
|
755
862
|
modelProfileId: input.modelProfileId,
|
|
756
863
|
}),
|
|
757
864
|
},
|
|
758
865
|
"AI request failed",
|
|
759
866
|
);
|
|
867
|
+
const alreadyFinished = completedDuringSend.get(conversationId) === result.turn.id;
|
|
868
|
+
completedDuringSend.delete(conversationId);
|
|
869
|
+
if (alreadyFinished) clearPendingSend(conversationId);
|
|
870
|
+
else setPendingSends((current) => ({ ...current, [conversationId]: result.message }));
|
|
760
871
|
if (invalidateInactiveCache(conversationId)) return true;
|
|
761
872
|
// Replace the optimistic message with the persisted one.
|
|
762
|
-
setState("messages", (prev) => prev.
|
|
873
|
+
setState("messages", (prev) => [...prev.filter((message) => message.id !== result.message.id), result.message]);
|
|
763
874
|
setState(
|
|
764
875
|
"activeTurn",
|
|
765
876
|
(current) =>
|
|
766
|
-
current ??
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
877
|
+
current ??
|
|
878
|
+
(alreadyFinished
|
|
879
|
+
? null
|
|
880
|
+
: {
|
|
881
|
+
turnId: result.turn.id,
|
|
882
|
+
attempt: 0,
|
|
883
|
+
seq: 0,
|
|
884
|
+
status: "running",
|
|
885
|
+
blocks: [],
|
|
886
|
+
modelProfileId: result.turn.modelProfileId,
|
|
887
|
+
}),
|
|
774
888
|
);
|
|
775
889
|
if (state.conversation) {
|
|
776
890
|
setState("conversation", "draft", {
|
|
@@ -787,6 +901,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
787
901
|
}
|
|
788
902
|
return true;
|
|
789
903
|
} catch (sendError) {
|
|
904
|
+
completedDuringSend.delete(conversationId);
|
|
905
|
+
clearPendingSend(conversationId);
|
|
790
906
|
if (invalidateInactiveCache(conversationId)) return false;
|
|
791
907
|
setState("messages", (prev) => prev.filter((message) => message.id !== optimistic.id));
|
|
792
908
|
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
@@ -1157,6 +1273,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
1157
1273
|
conversation: () => state.conversation,
|
|
1158
1274
|
setActiveConversationId,
|
|
1159
1275
|
messages,
|
|
1276
|
+
/** Includes persisted model rounds still represented by live blocks in the timeline. */
|
|
1277
|
+
usageMessages: () => state.messages,
|
|
1160
1278
|
activeTurn,
|
|
1161
1279
|
hasMoreHistory,
|
|
1162
1280
|
loadingOlder: () => isActiveConversationLoading(activeConversationId(), loadingOlderConversationId()),
|
|
@@ -1175,6 +1293,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
1175
1293
|
refreshActiveConversation,
|
|
1176
1294
|
createConversation,
|
|
1177
1295
|
saveDraft,
|
|
1296
|
+
queueMessage,
|
|
1178
1297
|
send,
|
|
1179
1298
|
steer,
|
|
1180
1299
|
retrySteer,
|
|
@@ -1194,7 +1313,14 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
1194
1313
|
export type AiChatController = ReturnType<typeof createAiChatController>;
|
|
1195
1314
|
|
|
1196
1315
|
const isComposerDraftSendable = (input: ComposerDraftInput): boolean =>
|
|
1197
|
-
Boolean(
|
|
1316
|
+
Boolean(
|
|
1317
|
+
input.draftContent?.length ||
|
|
1318
|
+
input.message?.trim() ||
|
|
1319
|
+
input.content?.length ||
|
|
1320
|
+
input.files?.length ||
|
|
1321
|
+
input.resources?.length ||
|
|
1322
|
+
input.storedFiles?.length,
|
|
1323
|
+
);
|
|
1198
1324
|
|
|
1199
1325
|
export const __aiControllerTest = {
|
|
1200
1326
|
claimFrontendCall,
|
|
@@ -30,7 +30,7 @@ const bytesToBase64 = (bytes: Uint8Array): string => {
|
|
|
30
30
|
return btoa(binary);
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
export const conversationFileSource = (baseUrl: string, conversationId: string): FileSource => {
|
|
33
|
+
export const conversationFileSource = (baseUrl: string, conversationId: string): FileSource & { listFiles(): Promise<AiFileStat[]> } => {
|
|
34
34
|
const filesUrl = (suffix = "", params?: Record<string, string>) => {
|
|
35
35
|
const query = params ? `?${new URLSearchParams(params).toString()}` : "";
|
|
36
36
|
return `${baseUrl}/conversations/${conversationId}/files${suffix}${query}`;
|
|
@@ -45,15 +45,19 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
|
|
|
45
45
|
return (await response.json()) as T;
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
+
const listFiles = async (): Promise<AiFileStat[]> =>
|
|
49
|
+
(await request<{ files: AiFileStat[] }>(filesUrl(), { method: "GET" }, "Failed to load files")).files;
|
|
50
|
+
|
|
48
51
|
return {
|
|
52
|
+
previewPreferencesKey: "assistant.csv-preview",
|
|
53
|
+
listFiles,
|
|
49
54
|
async list(): Promise<FileTreeEntry[]> {
|
|
50
|
-
|
|
51
|
-
return result.files.map((file) => ({
|
|
55
|
+
return (await listFiles()).map((file) => ({
|
|
52
56
|
path: file.path,
|
|
53
57
|
size: file.size,
|
|
54
58
|
mediaType: file.mediaType,
|
|
55
59
|
updatedAt: file.updatedAt,
|
|
56
|
-
badge: file.origin === "user" ? "upload" : undefined,
|
|
60
|
+
badge: file.dictationRecordedAt ? undefined : file.origin === "user" ? "upload" : undefined,
|
|
57
61
|
}));
|
|
58
62
|
},
|
|
59
63
|
|
|
@@ -62,7 +66,8 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
|
|
|
62
66
|
if (!response.ok) throw new Error(await readError(response, "Failed to load file"));
|
|
63
67
|
const mediaType = response.headers.get("Content-Type")?.split(";")[0]?.trim() || "application/octet-stream";
|
|
64
68
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
65
|
-
|
|
69
|
+
const delimited = /\.(csv|tsv)$/i.test(path) || mediaType === "text/csv" || mediaType === "text/tab-separated-values";
|
|
70
|
+
return isTextMediaType(mediaType) && !delimited
|
|
66
71
|
? { encoding: "utf8", content: new TextDecoder().decode(bytes), mediaType }
|
|
67
72
|
: { encoding: "base64", content: bytesToBase64(bytes), mediaType };
|
|
68
73
|
},
|
|
@@ -79,10 +84,11 @@ export const conversationFileSource = (baseUrl: string, conversationId: string):
|
|
|
79
84
|
await request(filesUrl("/rename"), { method: "POST", body: JSON.stringify({ from, to }) }, "Failed to rename file");
|
|
80
85
|
},
|
|
81
86
|
|
|
82
|
-
async upload(
|
|
87
|
+
async upload(dirPath, files) {
|
|
83
88
|
for (const file of files) {
|
|
84
89
|
const form = new FormData();
|
|
85
90
|
form.append("file", file);
|
|
91
|
+
form.append("directory", dirPath);
|
|
86
92
|
const response = await fetch(filesUrl(), { method: "POST", body: form });
|
|
87
93
|
if (!response.ok) throw new Error(await readError(response, `Failed to upload ${file.name}`));
|
|
88
94
|
}
|
|
@@ -63,6 +63,7 @@ export const createAiLiveConnection = (options: CreateAiLiveConnectionOptions):
|
|
|
63
63
|
|
|
64
64
|
socket = createLiveWebSocket<AiLiveServerMessage>({
|
|
65
65
|
url: options.url ?? "/api/ai/live",
|
|
66
|
+
activity: "always",
|
|
66
67
|
initialCursor: options.initialCursor,
|
|
67
68
|
subscribe: (cursor) => ({
|
|
68
69
|
type: AI_LIVE_WS_TYPE.subscribe,
|
|
@@ -23,6 +23,18 @@ export const emptyProjection = (conversation: AiConversation | null = null): AiC
|
|
|
23
23
|
activeTurn: null,
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
+
/** A local send is an overlay, never part of a replaceable server snapshot. */
|
|
27
|
+
export const messagesWithPendingSend = (messages: AiStoredMessage[], pending?: AiStoredMessage): AiStoredMessage[] => {
|
|
28
|
+
if (!pending) return messages;
|
|
29
|
+
const revision = pending.meta?.submittedDraftRevision;
|
|
30
|
+
const confirmed = messages.some(
|
|
31
|
+
(message) =>
|
|
32
|
+
message.id === pending.id ||
|
|
33
|
+
(revision !== undefined && message.message.role === "user" && message.meta?.submittedDraftRevision === revision),
|
|
34
|
+
);
|
|
35
|
+
return confirmed ? messages : [...messages, pending];
|
|
36
|
+
};
|
|
37
|
+
|
|
26
38
|
const mergeMessages = (existing: AiStoredMessage[], incoming: AiStoredMessage[]): AiStoredMessage[] => {
|
|
27
39
|
if (incoming.length === 0) return existing;
|
|
28
40
|
const byId = new Map(existing.map((message) => [message.id, message]));
|
|
@@ -69,19 +81,16 @@ const normalizeCustomApprovalBlocks = (blocks: AiTurnBlock[]): AiTurnBlock[] =>
|
|
|
69
81
|
return normalized;
|
|
70
82
|
};
|
|
71
83
|
|
|
72
|
-
/**
|
|
84
|
+
/** A complete snapshot replaces its baseline; stale snapshots cannot resurrect old block IDs. */
|
|
73
85
|
export const mergeActiveTurn = (previous: AiActiveTurn | null, incoming: AiActiveTurn | null): AiActiveTurn | null => {
|
|
74
86
|
if (!previous || !incoming || previous.turnId !== incoming.turnId) return incoming;
|
|
75
87
|
if (previous.attempt !== incoming.attempt) return incoming.attempt > previous.attempt ? incoming : previous;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
blocks,
|
|
83
|
-
status: deriveStatus(blocks),
|
|
84
|
-
};
|
|
88
|
+
if (incoming.seq < previous.seq) return previous;
|
|
89
|
+
// Locally submitted steering may not have reached the snapshot yet.
|
|
90
|
+
const pendingSteers = previous.blocks.filter((block) => block.kind === "steer_message" && block.status !== "consumed");
|
|
91
|
+
const known = new Set(incoming.blocks.map((block) => block.id));
|
|
92
|
+
const blocks = [...incoming.blocks, ...pendingSteers.filter((block) => !known.has(block.id))];
|
|
93
|
+
return { ...incoming, blocks, status: deriveStatus(blocks) };
|
|
85
94
|
};
|
|
86
95
|
|
|
87
96
|
export const reconcileActiveTurnActions = (
|
|
@@ -141,7 +150,7 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
|
|
|
141
150
|
const active = state.activeTurn;
|
|
142
151
|
|
|
143
152
|
if (event.type === "turn_started") {
|
|
144
|
-
if (active && active.turnId === event.turnId && event
|
|
153
|
+
if (active && active.turnId === event.turnId && !isNewerWireEvent(event, active)) return state;
|
|
145
154
|
const pendingSteers =
|
|
146
155
|
active?.turnId === event.turnId ? active.blocks.filter((block) => block.kind === "steer_message" && block.status !== "consumed") : [];
|
|
147
156
|
const blocks = event.blocks ? overlayBlocks(normalizeCustomApprovalBlocks(event.blocks), pendingSteers) : pendingSteers;
|
|
@@ -163,6 +172,15 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
|
|
|
163
172
|
return { ...state, messages: mergeMessages(state.messages, event.messages ?? []), activeTurn: null };
|
|
164
173
|
}
|
|
165
174
|
|
|
175
|
+
if (event.type === "message_saved") {
|
|
176
|
+
if (!active || active.turnId !== event.turnId || !isNewerWireEvent(event, active)) return state;
|
|
177
|
+
return {
|
|
178
|
+
...state,
|
|
179
|
+
messages: mergeMessages(state.messages, [event.message]),
|
|
180
|
+
activeTurn: { ...active, seq: event.seq, attempt: event.attempt },
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
166
184
|
// block_set / block_delta
|
|
167
185
|
if (!active || active.turnId !== event.turnId) return state;
|
|
168
186
|
if (!isNewerWireEvent(event, active)) return state;
|
|
@@ -21,6 +21,11 @@ const CONNECT_TIMEOUT_MS = 10_000;
|
|
|
21
21
|
export async function* parseAiSse(response: Response, signal: AbortSignal): AsyncGenerator<AiStreamEvent> {
|
|
22
22
|
const reader = response.body?.getReader();
|
|
23
23
|
if (!reader) return;
|
|
24
|
+
const cancel = () => {
|
|
25
|
+
void reader.cancel().catch(() => undefined);
|
|
26
|
+
};
|
|
27
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
28
|
+
if (signal.aborted) cancel();
|
|
24
29
|
const decoder = new TextDecoder();
|
|
25
30
|
let buffer = "";
|
|
26
31
|
try {
|
|
@@ -40,6 +45,7 @@ export async function* parseAiSse(response: Response, signal: AbortSignal): Asyn
|
|
|
40
45
|
}
|
|
41
46
|
}
|
|
42
47
|
} finally {
|
|
48
|
+
signal.removeEventListener("abort", cancel);
|
|
43
49
|
await reader.cancel().catch(() => undefined);
|
|
44
50
|
}
|
|
45
51
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { type Context, Hono } from "hono";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readBoundedJson } from "../_internal/bounded-json";
|
|
4
|
+
import { dispatchCapability } from "../api/capabilities";
|
|
5
|
+
import { dispatchCapabilityStream } from "../api/capability-streams";
|
|
6
|
+
import { CAPABILITY_MAX_REQUEST_BYTES } from "../contracts/capabilities";
|
|
7
|
+
import { type AuthContext, getLocale, type RequestAuthority } from "../server";
|
|
8
|
+
import { requireInvocation } from "../server/middleware/invocation";
|
|
9
|
+
import { getMandate } from "../services/mandates";
|
|
10
|
+
import { codeCapabilityOperation } from "./code-capability-transport";
|
|
11
|
+
import { authorizeCodeExecution } from "./code-execution";
|
|
12
|
+
import { aiConversations } from "./store";
|
|
13
|
+
|
|
14
|
+
const Input = z
|
|
15
|
+
.object({ input: z.unknown() })
|
|
16
|
+
.strict()
|
|
17
|
+
.refine((value) => Object.hasOwn(value, "input"));
|
|
18
|
+
const denied = () => Response.json({ code: "ACCESS_DENIED", message: "Code execution is no longer authorized" }, { status: 403 });
|
|
19
|
+
|
|
20
|
+
/** Core-only continuation of a code turn, not a general invocation exchange. */
|
|
21
|
+
export function createCodeCapabilityRoutes(
|
|
22
|
+
dependencies: {
|
|
23
|
+
invocation?: Parameters<typeof requireInvocation>[1];
|
|
24
|
+
store?: Pick<typeof aiConversations, "getConversation" | "getTurn" | "getActiveTurn" | "getTurnRunConfig">;
|
|
25
|
+
getMandate?: typeof getMandate;
|
|
26
|
+
dispatch?: typeof dispatchCapability;
|
|
27
|
+
stream?: typeof dispatchCapabilityStream;
|
|
28
|
+
} = {},
|
|
29
|
+
) {
|
|
30
|
+
const store = dependencies.store ?? aiConversations;
|
|
31
|
+
// This Core-only exchange resumes the verified turn; background calls also pass
|
|
32
|
+
// their persisted mandate to the dispatcher. Ordinary
|
|
33
|
+
// app invocations remain non-exchangeable; this is not an interactive cookie.
|
|
34
|
+
const turnAuthority = (c: Context<AuthContext>): RequestAuthority => ({
|
|
35
|
+
actor: c.get("actor"),
|
|
36
|
+
accessSubject: c.get("accessSubject"),
|
|
37
|
+
credentialKind: "session",
|
|
38
|
+
scopes: [],
|
|
39
|
+
});
|
|
40
|
+
const invoke = async (c: Context<AuthContext>, review = false) => {
|
|
41
|
+
const body = await readBoundedJson(c.req.raw, CAPABILITY_MAX_REQUEST_BYTES);
|
|
42
|
+
const input = body.ok ? Input.safeParse(body.data) : null;
|
|
43
|
+
if (!input?.success) return Response.json({ code: "VALIDATION_FAILED", message: "Invalid capability input" }, { status: 400 });
|
|
44
|
+
const conversation = await store.getConversation({ conversationId: c.req.param("conversationId")! });
|
|
45
|
+
const appId = c.req.param("appId")!;
|
|
46
|
+
const capabilityId = c.req.param("capabilityId")!;
|
|
47
|
+
if (conversation?.allowedTools && !conversation.allowedTools.includes(`${appId}.${capabilityId}`)) return denied();
|
|
48
|
+
const config = await store.getTurnRunConfig({ conversationId: c.req.param("conversationId")!, turnId: c.req.param("turnId")! });
|
|
49
|
+
if (!config || config.kind === "compact") return denied();
|
|
50
|
+
return (dependencies.dispatch ?? dispatchCapability)({
|
|
51
|
+
request: c.req.raw,
|
|
52
|
+
authority: turnAuthority(c),
|
|
53
|
+
origin: "assistant",
|
|
54
|
+
continuation: codeCapabilityOperation(c.req.param("conversationId")!, c.req.param("turnId")!),
|
|
55
|
+
mandate: config.mandate ? { mandateId: config.mandate.id, mandateRevision: config.mandate.revision, ownerAppId: "core" } : undefined,
|
|
56
|
+
review,
|
|
57
|
+
kind: review || c.req.param("kind") === "actions" ? "actions" : "queries",
|
|
58
|
+
appId,
|
|
59
|
+
capabilityId,
|
|
60
|
+
input: input.data.input,
|
|
61
|
+
locale: getLocale(c),
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
return new Hono<AuthContext>()
|
|
65
|
+
.use("/:conversationId/:turnId/*", async (c, next) => {
|
|
66
|
+
await next();
|
|
67
|
+
c.header("Cache-Control", "no-store");
|
|
68
|
+
})
|
|
69
|
+
.use(
|
|
70
|
+
"/:conversationId/:turnId/*",
|
|
71
|
+
requireInvocation((c) => {
|
|
72
|
+
const conversationId = z.uuid().safeParse(c.req.param("conversationId"));
|
|
73
|
+
const turnId = z.uuid().safeParse(c.req.param("turnId"));
|
|
74
|
+
return conversationId.success && turnId.success
|
|
75
|
+
? { targetAppId: "core", operation: codeCapabilityOperation(conversationId.data, turnId.data), schemaHash: null }
|
|
76
|
+
: null;
|
|
77
|
+
}, dependencies.invocation),
|
|
78
|
+
)
|
|
79
|
+
.use("/:conversationId/:turnId/*", async (c, next) => {
|
|
80
|
+
const actor = c.get("actor");
|
|
81
|
+
if (actor.kind !== "user" || actor.delegation?.callingAppId !== "assistant" || actor.delegation.credentialKind !== "session")
|
|
82
|
+
return denied();
|
|
83
|
+
const conversationId = c.req.param("conversationId")!;
|
|
84
|
+
const turnId = c.req.param("turnId")!;
|
|
85
|
+
try {
|
|
86
|
+
await authorizeCodeExecution(conversationId, turnId, actor.user.id, { store, getMandate: dependencies.getMandate ?? getMandate });
|
|
87
|
+
} catch {
|
|
88
|
+
return denied();
|
|
89
|
+
}
|
|
90
|
+
await next();
|
|
91
|
+
})
|
|
92
|
+
.post("/:conversationId/:turnId/capabilities/v1/actions/:appId/:capabilityId/review", (c) => invoke(c, true))
|
|
93
|
+
.post("/:conversationId/:turnId/capabilities/v1/:kind{queries|actions}/:appId/:capabilityId", (c) => invoke(c))
|
|
94
|
+
.post("/:conversationId/:turnId/capabilities/v1/streams/:verb", async (c) => {
|
|
95
|
+
const config = await store.getTurnRunConfig({ conversationId: c.req.param("conversationId")!, turnId: c.req.param("turnId")! });
|
|
96
|
+
if (!config || config.kind === "compact" || config.mandate) return denied();
|
|
97
|
+
return (dependencies.stream ?? dispatchCapabilityStream)(c.req.raw, turnAuthority(c), c.req.param("verb"), {
|
|
98
|
+
continuation: codeCapabilityOperation(c.req.param("conversationId")!, c.req.param("turnId")!),
|
|
99
|
+
allow: async ({ appId, capabilityId }) => {
|
|
100
|
+
const conversation = await store.getConversation({ conversationId: c.req.param("conversationId")! });
|
|
101
|
+
return !!conversation && (!conversation.allowedTools || conversation.allowedTools.includes(`${appId}.${capabilityId}`));
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|