@k2b/cloud 0.6.0-rc.0 → 0.7.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 +30 -17
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +15 -35
- package/scripts/preload.ts +9 -12
- package/scripts/sync-recovery-smoke.ts +2 -1
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/capabilities.ts +108 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +62 -36
- 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/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 +14 -153
- package/src/access/PrincipalPicker.tsx +104 -0
- package/src/access/ui.ts +2 -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 +79 -0
- package/src/ai/browser.ts +2 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +157 -95
- 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 +48 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +12 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +19 -48
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +17 -2
- package/src/ai/chat/tool-groups.ts +52 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +9 -1
- package/src/ai/chat-tasks.ts +276 -43
- package/src/ai/client/controller.ts +110 -33
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +22 -11
- package/src/ai/client/transport.ts +4 -0
- package/src/ai/code-capability-routes.ts +110 -0
- package/src/ai/code-capability-transport.ts +38 -0
- package/src/ai/code-mode-skill.ts +114 -0
- package/src/ai/code-runtime-tools.ts +166 -0
- package/src/ai/code-source-contracts.ts +202 -0
- package/src/ai/code-source-tools.ts +145 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +25 -2
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +34 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +191 -31
- package/src/ai/file-content-version.ts +22 -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 +2 -1
- package/src/ai/files-store.ts +174 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +40 -10
- package/src/ai/http.ts +15 -8
- package/src/ai/index.ts +26 -2
- package/src/ai/inference-calls.ts +198 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/memory-learning.ts +60 -28
- package/src/ai/message-queue.ts +167 -0
- package/src/ai/migrate.ts +187 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +74 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +3 -3
- package/src/ai/projects.ts +20 -6
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +191 -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 +215 -11
- package/src/ai/run-timeout.ts +10 -0
- package/src/ai/runtime-tools.ts +3 -0
- package/src/ai/runtime.ts +55 -16
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +2 -2
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +47 -0
- package/src/ai/skill-seeds.ts +105 -20
- package/src/ai/skill-tool.ts +16 -5
- package/src/ai/skills-routes.ts +32 -4
- package/src/ai/skills.ts +324 -155
- package/src/ai/store.ts +373 -191
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +8 -1
- package/src/ai/system-prompt.ts +12 -2
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +20 -0
- package/src/ai/todo-tool.ts +16 -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 +2 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +39 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +74 -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-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/capabilities.ts +292 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +6 -0
- package/src/api/mcp.ts +44 -26
- 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 +66 -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 +5 -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 +114 -34
- 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 +79 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +9 -0
- package/src/cli/capabilities.ts +29 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/env.ts +11 -8
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +68 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +6 -1
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +3 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +9 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/solid.tsx +3 -3
- package/src/index.ts +0 -4
- 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/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +1 -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/posix.ts +45 -29
- package/src/services/accounts/users.ts +2 -2
- package/src/services/announcements/index.ts +69 -8
- 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 +2 -1
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/index.ts +15 -0
- 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 +18 -4
- package/src/services/mandates/policy.ts +65 -2
- package/src/services/notifications/batches.ts +124 -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 +107 -84
- package/src/services/pdf/index.ts +3 -0
- 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 +28 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/core-settings.ts +21 -28
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/store.ts +58 -84
- 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 +6 -0
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +29 -15
- package/src/ssr/AppLaunchpad.island.tsx +24 -137
- 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 +23 -22
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- 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 +7 -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/runtime.ts +5 -1
- package/src/workflows/ai/store.ts +2 -0
- 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 +2 -0
- 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/scripts/app-favicon.test.ts +0 -49
- package/src/ai/chat/blocks.render.test.tsx +0 -1108
- package/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/CloudResourceSearch.behavior.test.tsx +0 -74
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/services/notifications/live.ts +0 -50
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/GlobalSearchDialog.behavior.test.tsx +0 -96
- package/src/ssr/Layout.render.test.tsx +0 -148
- 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,13 @@ 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) => setPendingSends(current => {
|
|
156
|
+
const next = { ...current };
|
|
157
|
+
delete next[conversationId];
|
|
158
|
+
return next;
|
|
159
|
+
});
|
|
144
160
|
|
|
145
161
|
// Cache of projections for conversations opened this session (fast switching).
|
|
146
162
|
const cache = new Map<string, AiChatProjection>();
|
|
@@ -207,7 +223,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
207
223
|
const error = () => globalError() ?? runError();
|
|
208
224
|
|
|
209
225
|
const activeTurn = () => state.activeTurn;
|
|
210
|
-
const messages = createMemo(() => visibleMessages(state));
|
|
226
|
+
const messages = createMemo(() => messagesWithPendingSend(visibleMessages(state), pendingSends()[activeConversationId() ?? ""]));
|
|
211
227
|
const runStatus = (): AiChatRunStatus => {
|
|
212
228
|
const override = runStatusRaw();
|
|
213
229
|
if (override) return override;
|
|
@@ -226,6 +242,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
226
242
|
const next = reduceProjection({ conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn }, event);
|
|
227
243
|
setState(reconcile(next, { key: "id", merge: true }));
|
|
228
244
|
cache.set(conversationId, next);
|
|
245
|
+
if (event.type === "turn_finished" && pendingSends()[conversationId]?.loopId === event.turnId) clearPendingSend(conversationId);
|
|
229
246
|
};
|
|
230
247
|
|
|
231
248
|
/**
|
|
@@ -241,6 +258,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
241
258
|
setProjection(detailToProjection(detail), session.conversationId);
|
|
242
259
|
setHasMore(session.conversationId, detail.hasMoreMessages ?? false);
|
|
243
260
|
if (detail.timeline) setTimeline(session.conversationId, detail.timeline);
|
|
261
|
+
if (event.type === "turn_finished" && pendingSends()[session.conversationId]?.loopId === event.turnId) clearPendingSend(session.conversationId);
|
|
244
262
|
} else reduceEvent(session.conversationId, event);
|
|
245
263
|
setRunStatusRaw(null);
|
|
246
264
|
void markConversationViewed(session.conversationId);
|
|
@@ -249,6 +267,9 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
249
267
|
const applyEvent = (session: AiStreamSession, event: AiStreamEvent) => {
|
|
250
268
|
if (!isCurrentStreamSession(streamSession, session)) return;
|
|
251
269
|
const conversationId = session.conversationId;
|
|
270
|
+
if (event.type === "turn_finished" && pendingSends()[conversationId]) {
|
|
271
|
+
completedDuringSend.set(conversationId, event.turnId);
|
|
272
|
+
}
|
|
252
273
|
|
|
253
274
|
if (event.type === "state") setHasMore(conversationId, event.hasMoreMessages ?? false);
|
|
254
275
|
const nextRunError = runErrorFromEvent(event, state.activeTurn?.turnId);
|
|
@@ -265,7 +286,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
265
286
|
|
|
266
287
|
reduceEvent(conversationId, event);
|
|
267
288
|
if (event.type === "state" && loadingConversationId() === conversationId) setLoadingConversationId(null);
|
|
268
|
-
if (runStatusRaw() && runStatusRaw() !== "stopping") setRunStatusRaw(null);
|
|
289
|
+
if (runStatusRaw() && (runStatusRaw() !== "stopping" || !state.activeTurn)) setRunStatusRaw(null);
|
|
269
290
|
|
|
270
291
|
if (event.type === "turn_finished") {
|
|
271
292
|
setRunStatusRaw(null);
|
|
@@ -338,7 +359,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
338
359
|
return false;
|
|
339
360
|
}
|
|
340
361
|
try {
|
|
341
|
-
const result = await handler({ name, callId, args, turnId });
|
|
362
|
+
const result = await handler({ name, callId, args, turnId, conversationId });
|
|
342
363
|
return submitTurnActionForConversation(conversationId, turnId, callId, { type: "tool_result", result });
|
|
343
364
|
} catch (toolError) {
|
|
344
365
|
return submitTurnActionForConversation(conversationId, turnId, callId, {
|
|
@@ -394,6 +415,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
394
415
|
};
|
|
395
416
|
cache.set(conversationId, next);
|
|
396
417
|
setState(reconcile(next, { key: "id", merge: true }));
|
|
418
|
+
if (!next.activeTurn && runStatusRaw() === "stopping") setRunStatusRaw(null);
|
|
397
419
|
};
|
|
398
420
|
|
|
399
421
|
const openConversation = async (conversationId: string) => {
|
|
@@ -436,15 +458,25 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
436
458
|
return generation === conversationOpenGeneration ? ("failed" as const) : ("stale" as const);
|
|
437
459
|
};
|
|
438
460
|
|
|
461
|
+
let conversationRefreshGeneration = 0;
|
|
439
462
|
const refreshActiveConversation = async (): Promise<void> => {
|
|
440
463
|
const conversationId = activeConversationId();
|
|
441
464
|
if (!conversationId) return;
|
|
465
|
+
const refreshGeneration = ++conversationRefreshGeneration;
|
|
466
|
+
const openGeneration = conversationOpenGeneration;
|
|
442
467
|
const detail = await request<AiConversationDetail>(
|
|
443
468
|
`/conversations/${conversationId}`,
|
|
444
469
|
{ method: "GET" },
|
|
445
470
|
"Failed to refresh conversation",
|
|
446
471
|
);
|
|
447
|
-
if (
|
|
472
|
+
if (
|
|
473
|
+
!isActiveConversation(conversationId) ||
|
|
474
|
+
refreshGeneration !== conversationRefreshGeneration ||
|
|
475
|
+
openGeneration !== conversationOpenGeneration
|
|
476
|
+
)
|
|
477
|
+
return;
|
|
478
|
+
const currentDraft = state.conversation?.draft;
|
|
479
|
+
if (currentDraft && currentDraft.revision > detail.conversation.draft.revision) detail.conversation.draft = currentDraft;
|
|
448
480
|
const windowOldest = detail.messages[0]?.seq;
|
|
449
481
|
const preservedOlder = windowOldest === undefined ? [] : state.messages.filter((message) => message.seq < windowOldest);
|
|
450
482
|
setProjection({ ...detailToProjection(detail), messages: [...preservedOlder, ...detail.messages] }, conversationId);
|
|
@@ -653,7 +685,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
653
685
|
}
|
|
654
686
|
}
|
|
655
687
|
const content: AiDraftContentPart[] = [
|
|
656
|
-
...textParts,
|
|
688
|
+
...(input.draftContent ?? textParts),
|
|
657
689
|
...(input.resources ?? []).map((resource) => ({ type: "resource" as const, ...resource })),
|
|
658
690
|
...(input.storedFiles ?? []).map((file) => ({ type: "file" as const, ...file })),
|
|
659
691
|
...uploaded.map((file) => ({ type: "file" as const, ...file })),
|
|
@@ -661,10 +693,14 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
661
693
|
try {
|
|
662
694
|
const draft = await request<AiConversation["draft"]>(
|
|
663
695
|
`/conversations/${conversationId}/draft`,
|
|
664
|
-
{ method: "PUT", body: JSON.stringify({ expectedRevision: conversation.draft.revision, content }) },
|
|
696
|
+
{ method: "PUT", body: JSON.stringify({ expectedRevision: input.expectedDraftRevision ?? conversation.draft.revision, content }) },
|
|
665
697
|
"Failed to save conversation draft",
|
|
666
698
|
);
|
|
667
|
-
const
|
|
699
|
+
const current = isActiveConversation(conversationId) ? state.conversation : cache.get(conversationId)?.conversation;
|
|
700
|
+
const nextConversation = {
|
|
701
|
+
...(current ?? conversation),
|
|
702
|
+
draft: current && current.draft.revision > draft.revision ? current.draft : draft,
|
|
703
|
+
};
|
|
668
704
|
const projection = isActiveConversation(conversationId)
|
|
669
705
|
? { conversation: nextConversation, messages: state.messages, activeTurn: state.activeTurn }
|
|
670
706
|
: { ...(cache.get(conversationId) ?? emptyProjection(nextConversation)), conversation: nextConversation };
|
|
@@ -687,36 +723,56 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
687
723
|
};
|
|
688
724
|
|
|
689
725
|
const saveDraft = (input: ComposerDraftInput) => {
|
|
690
|
-
const targetConversation = ensureConversation();
|
|
726
|
+
const targetConversation = input.conversationId ? Promise.resolve(input.conversationId) : ensureConversation();
|
|
691
727
|
return queueDraftOperation(async () => persistComposerDraft(input, await targetConversation));
|
|
692
728
|
};
|
|
693
729
|
|
|
730
|
+
const queuedAttempts = new Map<string, {key:string;files?:File[];id:string;saved:{revision:number}}>();
|
|
731
|
+
const queueMessage = (input: ComposerDraftInput & { modelProfileId?: string }): Promise<boolean> => {
|
|
732
|
+
if (!input.conversationId || !isComposerDraftSendable(input)) return Promise.resolve(false);
|
|
733
|
+
const conversationId = input.conversationId;
|
|
734
|
+
const key = JSON.stringify({message:input.message,content:input.content,draftContent:input.draftContent,resources:input.resources,storedFiles:input.storedFiles,model:input.modelProfileId});
|
|
735
|
+
return queueDraftOperation(async () => {
|
|
736
|
+
try {
|
|
737
|
+
const previous = queuedAttempts.get(conversationId);
|
|
738
|
+
const same = previous?.key === key && (previous.files ?? []).length === (input.files ?? []).length && (previous.files ?? []).every((file,index)=>file === input.files?.[index]);
|
|
739
|
+
const saved = same ? previous.saved : await persistComposerDraft(input, conversationId);
|
|
740
|
+
if (!saved) return false;
|
|
741
|
+
const queueId = same ? previous.id : crypto.randomUUID();
|
|
742
|
+
queuedAttempts.set(conversationId,{key,files:input.files,id:queueId,saved});
|
|
743
|
+
await request(`/conversations/${conversationId}/turns`, {method:"POST", body:JSON.stringify({
|
|
744
|
+
draftRevision:saved.revision, queueId, modelProfileId:input.modelProfileId,
|
|
745
|
+
clientToolIds:options.clientToolIds?.filter(id => options.frontendTools?.[id]),
|
|
746
|
+
})}, "Failed to queue message");
|
|
747
|
+
if (isActiveConversation(conversationId)) {
|
|
748
|
+
if ((state.conversation?.draft.revision ?? 0) <= saved.revision) setState("conversation", "draft", {content:[],revision:saved.revision+1,updatedAt:new Date().toISOString()});
|
|
749
|
+
} else invalidateInactiveCache(conversationId);
|
|
750
|
+
queuedAttempts.delete(conversationId);
|
|
751
|
+
if (isActiveConversation(conversationId)) setGlobalError(null);
|
|
752
|
+
return true;
|
|
753
|
+
} catch (error) {
|
|
754
|
+
setConversationError(conversationId,error instanceof Error ? error.message : "Failed to queue message");
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
});
|
|
758
|
+
};
|
|
759
|
+
|
|
694
760
|
const send = (input: ComposerDraftInput & { modelProfileId?: string }): Promise<boolean> => {
|
|
695
761
|
if (!isComposerDraftSendable(input)) return Promise.resolve(false);
|
|
696
|
-
const targetConversation = ensureConversation();
|
|
762
|
+
const targetConversation = input.conversationId ? Promise.resolve(input.conversationId) : ensureConversation();
|
|
697
763
|
return queueDraftOperation(async () => {
|
|
698
|
-
const
|
|
699
|
-
if (!
|
|
700
|
-
const conversationId = savedDraft.conversationId;
|
|
701
|
-
if (isActiveConversation(conversationId) && running()) return false;
|
|
702
|
-
|
|
764
|
+
const conversationId = await targetConversation;
|
|
765
|
+
if (!conversationId || (isActiveConversation(conversationId) && running())) return false;
|
|
703
766
|
if (isActiveConversation(conversationId)) clearErrors();
|
|
704
767
|
const baseProjection = isActiveConversation(conversationId)
|
|
705
768
|
? { conversation: state.conversation, messages: [...state.messages], activeTurn: state.activeTurn }
|
|
706
769
|
: (cache.get(conversationId) ?? emptyProjection());
|
|
707
|
-
|
|
708
|
-
// Optimistic view renders attachments through the same marker format the server persists.
|
|
709
770
|
const optimisticContent: AiUserContentPart[] = [
|
|
710
|
-
...
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
type: "text" as const,
|
|
716
|
-
text: aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }),
|
|
717
|
-
},
|
|
718
|
-
];
|
|
719
|
-
}),
|
|
771
|
+
...(input.content ?? []),
|
|
772
|
+
...(!input.content?.length && input.message ? [{ type: "text" as const, text: input.message }] : []),
|
|
773
|
+
...(input.resources ?? []).map(ref => ({ type: "text" as const, text: aiResourceMarker(ref) })),
|
|
774
|
+
...(input.storedFiles ?? []).map(file => ({ type: "text" as const, text: aiAttachmentMarker(file) })),
|
|
775
|
+
...(input.files ?? []).map(file => ({ type: "text" as const, text: aiAttachmentMarker({ path: `/${file.name}`, mediaType: file.type, size: file.size }) })),
|
|
720
776
|
];
|
|
721
777
|
|
|
722
778
|
// Optimistic: show the user message immediately.
|
|
@@ -738,39 +794,55 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
738
794
|
meta: null,
|
|
739
795
|
createdAt: new Date().toISOString(),
|
|
740
796
|
};
|
|
741
|
-
|
|
742
|
-
cache.set(conversationId, optimisticProjection);
|
|
797
|
+
setPendingSends(current => ({ ...current, [conversationId]: optimistic }));
|
|
743
798
|
if (isActiveConversation(conversationId)) {
|
|
744
|
-
setState("messages", (prev) => [...prev, optimistic]);
|
|
745
799
|
setRunStatusRaw("streaming");
|
|
746
800
|
}
|
|
747
801
|
|
|
748
802
|
try {
|
|
803
|
+
const savedDraft = await persistComposerDraft(input, conversationId);
|
|
804
|
+
if (!savedDraft) {
|
|
805
|
+
clearPendingSend(conversationId);
|
|
806
|
+
if (isActiveConversation(conversationId)) setRunStatusRaw("failed");
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
setPendingSends(current => ({ ...current, [conversationId]: {
|
|
810
|
+
...optimistic,
|
|
811
|
+
meta: { submittedDraftRevision: savedDraft.revision },
|
|
812
|
+
message: { role: "user", content: savedDraft.content.map(part => ({
|
|
813
|
+
type: "text", text: part.type === "text" ? part.text : part.type === "file" ? aiAttachmentMarker(part) : part.type === "project-file" ? `Project file: ${part.path}` : aiResourceMarker({ ref: part.ref, title: part.title, icon: part.icon, href: part.href }),
|
|
814
|
+
})) },
|
|
815
|
+
} }));
|
|
749
816
|
const result = await request<SubmitTurnResult>(
|
|
750
817
|
`/conversations/${conversationId}/turns`,
|
|
751
818
|
{
|
|
752
819
|
method: "POST",
|
|
753
820
|
body: JSON.stringify({
|
|
754
821
|
draftRevision: savedDraft.revision,
|
|
822
|
+
clientToolIds: options.clientToolIds?.filter((id) => options.frontendTools?.[id]),
|
|
755
823
|
modelProfileId: input.modelProfileId,
|
|
756
824
|
}),
|
|
757
825
|
},
|
|
758
826
|
"AI request failed",
|
|
759
827
|
);
|
|
828
|
+
const alreadyFinished = completedDuringSend.get(conversationId) === result.turn.id;
|
|
829
|
+
completedDuringSend.delete(conversationId);
|
|
830
|
+
if (alreadyFinished) clearPendingSend(conversationId);
|
|
831
|
+
else setPendingSends(current => ({ ...current, [conversationId]: result.message }));
|
|
760
832
|
if (invalidateInactiveCache(conversationId)) return true;
|
|
761
833
|
// Replace the optimistic message with the persisted one.
|
|
762
|
-
setState("messages", (prev) => prev.
|
|
834
|
+
setState("messages", (prev) => [...prev.filter(message => message.id !== result.message.id), result.message]);
|
|
763
835
|
setState(
|
|
764
836
|
"activeTurn",
|
|
765
837
|
(current) =>
|
|
766
|
-
current ?? {
|
|
838
|
+
current ?? (alreadyFinished ? null : {
|
|
767
839
|
turnId: result.turn.id,
|
|
768
840
|
attempt: 0,
|
|
769
841
|
seq: 0,
|
|
770
842
|
status: "running",
|
|
771
843
|
blocks: [],
|
|
772
844
|
modelProfileId: result.turn.modelProfileId,
|
|
773
|
-
},
|
|
845
|
+
}),
|
|
774
846
|
);
|
|
775
847
|
if (state.conversation) {
|
|
776
848
|
setState("conversation", "draft", {
|
|
@@ -787,6 +859,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
787
859
|
}
|
|
788
860
|
return true;
|
|
789
861
|
} catch (sendError) {
|
|
862
|
+
completedDuringSend.delete(conversationId);
|
|
863
|
+
clearPendingSend(conversationId);
|
|
790
864
|
if (invalidateInactiveCache(conversationId)) return false;
|
|
791
865
|
setState("messages", (prev) => prev.filter((message) => message.id !== optimistic.id));
|
|
792
866
|
cache.set(conversationId, { conversation: state.conversation, messages: state.messages, activeTurn: state.activeTurn });
|
|
@@ -1157,6 +1231,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
1157
1231
|
conversation: () => state.conversation,
|
|
1158
1232
|
setActiveConversationId,
|
|
1159
1233
|
messages,
|
|
1234
|
+
/** Includes persisted model rounds still represented by live blocks in the timeline. */
|
|
1235
|
+
usageMessages: () => state.messages,
|
|
1160
1236
|
activeTurn,
|
|
1161
1237
|
hasMoreHistory,
|
|
1162
1238
|
loadingOlder: () => isActiveConversationLoading(activeConversationId(), loadingOlderConversationId()),
|
|
@@ -1175,6 +1251,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
1175
1251
|
refreshActiveConversation,
|
|
1176
1252
|
createConversation,
|
|
1177
1253
|
saveDraft,
|
|
1254
|
+
queueMessage,
|
|
1178
1255
|
send,
|
|
1179
1256
|
steer,
|
|
1180
1257
|
retrySteer,
|
|
@@ -1194,7 +1271,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
1194
1271
|
export type AiChatController = ReturnType<typeof createAiChatController>;
|
|
1195
1272
|
|
|
1196
1273
|
const isComposerDraftSendable = (input: ComposerDraftInput): boolean =>
|
|
1197
|
-
Boolean(input.message?.trim() || input.content?.length || input.files?.length || input.resources?.length || input.storedFiles?.length);
|
|
1274
|
+
Boolean(input.draftContent?.length || input.message?.trim() || input.content?.length || input.files?.length || input.resources?.length || input.storedFiles?.length);
|
|
1198
1275
|
|
|
1199
1276
|
export const __aiControllerTest = {
|
|
1200
1277
|
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,15 @@ 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(message => message.id === pending.id ||
|
|
31
|
+
(revision !== undefined && message.message.role === "user" && message.meta?.submittedDraftRevision === revision));
|
|
32
|
+
return confirmed ? messages : [...messages, pending];
|
|
33
|
+
};
|
|
34
|
+
|
|
26
35
|
const mergeMessages = (existing: AiStoredMessage[], incoming: AiStoredMessage[]): AiStoredMessage[] => {
|
|
27
36
|
if (incoming.length === 0) return existing;
|
|
28
37
|
const byId = new Map(existing.map((message) => [message.id, message]));
|
|
@@ -69,19 +78,16 @@ const normalizeCustomApprovalBlocks = (blocks: AiTurnBlock[]): AiTurnBlock[] =>
|
|
|
69
78
|
return normalized;
|
|
70
79
|
};
|
|
71
80
|
|
|
72
|
-
/**
|
|
81
|
+
/** A complete snapshot replaces its baseline; stale snapshots cannot resurrect old block IDs. */
|
|
73
82
|
export const mergeActiveTurn = (previous: AiActiveTurn | null, incoming: AiActiveTurn | null): AiActiveTurn | null => {
|
|
74
83
|
if (!previous || !incoming || previous.turnId !== incoming.turnId) return incoming;
|
|
75
84
|
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
|
-
};
|
|
85
|
+
if (incoming.seq < previous.seq) return previous;
|
|
86
|
+
// Locally submitted steering may not have reached the snapshot yet.
|
|
87
|
+
const pendingSteers = previous.blocks.filter(block => block.kind === "steer_message" && block.status !== "consumed");
|
|
88
|
+
const known = new Set(incoming.blocks.map(block => block.id));
|
|
89
|
+
const blocks = [...incoming.blocks, ...pendingSteers.filter(block => !known.has(block.id))];
|
|
90
|
+
return { ...incoming, blocks, status: deriveStatus(blocks) };
|
|
85
91
|
};
|
|
86
92
|
|
|
87
93
|
export const reconcileActiveTurnActions = (
|
|
@@ -141,7 +147,7 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
|
|
|
141
147
|
const active = state.activeTurn;
|
|
142
148
|
|
|
143
149
|
if (event.type === "turn_started") {
|
|
144
|
-
if (active && active.turnId === event.turnId && event
|
|
150
|
+
if (active && active.turnId === event.turnId && !isNewerWireEvent(event, active)) return state;
|
|
145
151
|
const pendingSteers =
|
|
146
152
|
active?.turnId === event.turnId ? active.blocks.filter((block) => block.kind === "steer_message" && block.status !== "consumed") : [];
|
|
147
153
|
const blocks = event.blocks ? overlayBlocks(normalizeCustomApprovalBlocks(event.blocks), pendingSteers) : pendingSteers;
|
|
@@ -163,6 +169,11 @@ export const reduceWireEvent = (state: AiChatProjection, event: AiWireEvent): Ai
|
|
|
163
169
|
return { ...state, messages: mergeMessages(state.messages, event.messages ?? []), activeTurn: null };
|
|
164
170
|
}
|
|
165
171
|
|
|
172
|
+
if (event.type === "message_saved") {
|
|
173
|
+
if (!active || active.turnId !== event.turnId || !isNewerWireEvent(event, active)) return state;
|
|
174
|
+
return { ...state, messages: mergeMessages(state.messages, [event.message]), activeTurn: { ...active, seq: event.seq, attempt: event.attempt } };
|
|
175
|
+
}
|
|
176
|
+
|
|
166
177
|
// block_set / block_delta
|
|
167
178
|
if (!active || active.turnId !== event.turnId) return state;
|
|
168
179
|
if (!isNewerWireEvent(event, active)) return state;
|
|
@@ -21,6 +21,9 @@ 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 = () => { void reader.cancel().catch(() => undefined); };
|
|
25
|
+
signal.addEventListener("abort", cancel, {once:true});
|
|
26
|
+
if (signal.aborted) cancel();
|
|
24
27
|
const decoder = new TextDecoder();
|
|
25
28
|
let buffer = "";
|
|
26
29
|
try {
|
|
@@ -40,6 +43,7 @@ export async function* parseAiSse(response: Response, signal: AbortSignal): Asyn
|
|
|
40
43
|
}
|
|
41
44
|
}
|
|
42
45
|
} finally {
|
|
46
|
+
signal.removeEventListener("abort", cancel);
|
|
43
47
|
await reader.cancel().catch(() => undefined);
|
|
44
48
|
}
|
|
45
49
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
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 { codeCapabilityOperation } from "./code-capability-transport";
|
|
10
|
+
import { aiConversations } from "./store";
|
|
11
|
+
|
|
12
|
+
const Input = z
|
|
13
|
+
.object({ input: z.unknown() })
|
|
14
|
+
.strict()
|
|
15
|
+
.refine((value) => Object.hasOwn(value, "input"));
|
|
16
|
+
const denied = () => Response.json({ code: "ACCESS_DENIED", message: "Code execution is no longer authorized" }, { status: 403 });
|
|
17
|
+
|
|
18
|
+
/** Core-only continuation of a foreground code turn, not a general invocation exchange. */
|
|
19
|
+
export function createCodeCapabilityRoutes(
|
|
20
|
+
dependencies: {
|
|
21
|
+
invocation?: Parameters<typeof requireInvocation>[1];
|
|
22
|
+
store?: Pick<typeof aiConversations, "getConversation" | "getActiveTurn" | "getTurnRunConfig">;
|
|
23
|
+
dispatch?: typeof dispatchCapability;
|
|
24
|
+
stream?: typeof dispatchCapabilityStream;
|
|
25
|
+
} = {},
|
|
26
|
+
) {
|
|
27
|
+
const store = dependencies.store ?? aiConversations;
|
|
28
|
+
// This Core-only exchange resumes the verified foreground user turn. Ordinary
|
|
29
|
+
// app invocations remain non-exchangeable; this is not an interactive cookie.
|
|
30
|
+
const foregroundTurnAuthority = (c: Context<AuthContext>): RequestAuthority => ({
|
|
31
|
+
actor: c.get("actor"),
|
|
32
|
+
accessSubject: c.get("accessSubject"),
|
|
33
|
+
credentialKind: "session",
|
|
34
|
+
scopes: [],
|
|
35
|
+
});
|
|
36
|
+
const invoke = async (c: Context<AuthContext>, review = false) => {
|
|
37
|
+
const body = await readBoundedJson(c.req.raw, CAPABILITY_MAX_REQUEST_BYTES);
|
|
38
|
+
const input = body.ok ? Input.safeParse(body.data) : null;
|
|
39
|
+
if (!input?.success) return Response.json({ code: "VALIDATION_FAILED", message: "Invalid capability input" }, { status: 400 });
|
|
40
|
+
const conversation = await store.getConversation({ conversationId: c.req.param("conversationId")! });
|
|
41
|
+
const appId = c.req.param("appId")!;
|
|
42
|
+
const capabilityId = c.req.param("capabilityId")!;
|
|
43
|
+
if (conversation?.allowedTools && !conversation.allowedTools.includes(`${appId}.${capabilityId}`)) return denied();
|
|
44
|
+
return (dependencies.dispatch ?? dispatchCapability)({
|
|
45
|
+
request: c.req.raw,
|
|
46
|
+
authority: foregroundTurnAuthority(c),
|
|
47
|
+
origin: "assistant",
|
|
48
|
+
continuation: codeCapabilityOperation(c.req.param("conversationId")!, c.req.param("turnId")!),
|
|
49
|
+
review,
|
|
50
|
+
kind: review || c.req.param("kind") === "actions" ? "actions" : "queries",
|
|
51
|
+
appId,
|
|
52
|
+
capabilityId,
|
|
53
|
+
input: input.data.input,
|
|
54
|
+
locale: getLocale(c),
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
return new Hono<AuthContext>()
|
|
58
|
+
.use("/:conversationId/:turnId/*", async (c, next) => {
|
|
59
|
+
await next();
|
|
60
|
+
c.header("Cache-Control", "no-store");
|
|
61
|
+
})
|
|
62
|
+
.use(
|
|
63
|
+
"/:conversationId/:turnId/*",
|
|
64
|
+
requireInvocation((c) => {
|
|
65
|
+
const conversationId = z.uuid().safeParse(c.req.param("conversationId"));
|
|
66
|
+
const turnId = z.uuid().safeParse(c.req.param("turnId"));
|
|
67
|
+
return conversationId.success && turnId.success
|
|
68
|
+
? { targetAppId: "core", operation: codeCapabilityOperation(conversationId.data, turnId.data), schemaHash: null }
|
|
69
|
+
: null;
|
|
70
|
+
}, dependencies.invocation),
|
|
71
|
+
)
|
|
72
|
+
.use("/:conversationId/:turnId/*", async (c, next) => {
|
|
73
|
+
const actor = c.get("actor");
|
|
74
|
+
if (actor.kind !== "user" || actor.delegation?.callingAppId !== "assistant" || actor.delegation.credentialKind !== "session")
|
|
75
|
+
return denied();
|
|
76
|
+
const conversationId = c.req.param("conversationId")!;
|
|
77
|
+
const turnId = c.req.param("turnId")!;
|
|
78
|
+
const [conversation, active, config] = await Promise.all([
|
|
79
|
+
store.getConversation({ conversationId, ownerUserId: actor.user.id }),
|
|
80
|
+
store.getActiveTurn({ conversationId }),
|
|
81
|
+
store.getTurnRunConfig({ conversationId, turnId }),
|
|
82
|
+
]);
|
|
83
|
+
if (
|
|
84
|
+
!conversation ||
|
|
85
|
+
conversation.createdByUserId !== actor.user.id ||
|
|
86
|
+
conversation.archivedAt ||
|
|
87
|
+
!active ||
|
|
88
|
+
active.turn.id !== turnId ||
|
|
89
|
+
active.turn.cancelRequestedAt ||
|
|
90
|
+
!["running", "waiting_for_action"].includes(active.turn.status) ||
|
|
91
|
+
!config ||
|
|
92
|
+
config.kind === "compact" ||
|
|
93
|
+
config.background ||
|
|
94
|
+
config.mandate
|
|
95
|
+
)
|
|
96
|
+
return denied();
|
|
97
|
+
await next();
|
|
98
|
+
})
|
|
99
|
+
.post("/:conversationId/:turnId/capabilities/v1/actions/:appId/:capabilityId/review", (c) => invoke(c, true))
|
|
100
|
+
.post("/:conversationId/:turnId/capabilities/v1/:kind{queries|actions}/:appId/:capabilityId", (c) => invoke(c))
|
|
101
|
+
.post("/:conversationId/:turnId/capabilities/v1/streams/:verb", (c) =>
|
|
102
|
+
(dependencies.stream ?? dispatchCapabilityStream)(c.req.raw, foregroundTurnAuthority(c), c.req.param("verb"), {
|
|
103
|
+
continuation: codeCapabilityOperation(c.req.param("conversationId")!, c.req.param("turnId")!),
|
|
104
|
+
allow: async ({ appId, capabilityId }) => {
|
|
105
|
+
const conversation = await store.getConversation({ conversationId: c.req.param("conversationId")! });
|
|
106
|
+
return !!conversation && (!conversation.allowedTools || conversation.allowedTools.includes(`${appId}.${capabilityId}`));
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { CapabilityCaller } from "../capabilities/server";
|
|
2
|
+
|
|
3
|
+
export const CODE_CAPABILITY_TOKEN_HEADER = "x-cloud-code-capability-token";
|
|
4
|
+
export const codeCapabilityOperation = (conversationId: string, turnId: string) => `code-capabilities:${conversationId}:${turnId}`;
|
|
5
|
+
export const codeCapabilityPath = (conversationId: string, turnId: string) => `/api/_internal/ai/code/${conversationId}/${turnId}`;
|
|
6
|
+
|
|
7
|
+
/** Framework-owned callback; tokens stay in the host, never the sandbox or tool arguments. */
|
|
8
|
+
export function createCodeCapabilityTransport(
|
|
9
|
+
current: () => { conversationId: string; turnId: string; token: string },
|
|
10
|
+
): NonNullable<CapabilityCaller["transport"]> {
|
|
11
|
+
const configured = process.env.CLOUD_CORE_INTERNAL_ORIGIN;
|
|
12
|
+
if (!configured) throw new Error("CLOUD_CORE_INTERNAL_ORIGIN is required for the code host");
|
|
13
|
+
const origin = new URL(configured);
|
|
14
|
+
if (!["http:", "https:"].includes(origin.protocol)) throw new Error("Invalid Core origin");
|
|
15
|
+
return async (request) => {
|
|
16
|
+
const target = new URL(origin);
|
|
17
|
+
const context = current();
|
|
18
|
+
if (!context.token) throw new Error("Code capability authority is unavailable");
|
|
19
|
+
const source = new URL(request.url);
|
|
20
|
+
if (!source.pathname.startsWith("/api/capabilities/v1/")) throw new Error("Invalid code capability route");
|
|
21
|
+
target.pathname = codeCapabilityPath(context.conversationId, context.turnId) + source.pathname.slice("/api".length);
|
|
22
|
+
target.search = "";
|
|
23
|
+
const headers = new Headers(request.headers);
|
|
24
|
+
headers.delete("cookie");
|
|
25
|
+
headers.set("authorization", `Bearer ${context.token}`);
|
|
26
|
+
return fetch(
|
|
27
|
+
new Request(target, {
|
|
28
|
+
method: request.method,
|
|
29
|
+
headers,
|
|
30
|
+
body: request.body,
|
|
31
|
+
signal: request.signal,
|
|
32
|
+
redirect: "error",
|
|
33
|
+
// @ts-expect-error Streaming server request bodies require duplex.
|
|
34
|
+
duplex: request.body ? "half" : undefined,
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
}
|