@k2b/cloud 0.22.0 → 0.24.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/package.json +4 -3
- package/scripts/README.md +1 -1
- package/scripts/build-canvas-workers.ts +58 -0
- package/scripts/build.ts +4 -5
- package/src/_internal/canvas-worker.ts +24 -0
- package/src/_internal/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +90 -7
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +20 -4
- package/src/_internal/registry-validation.ts +6 -0
- package/src/_internal/registry.ts +78 -3
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/static-assets.ts +18 -3
- package/src/access/ResourceApiKeys.tsx +30 -30
- package/src/ai/capability-execution.ts +5 -0
- package/src/ai/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +80 -21
- package/src/ai/client/transport.ts +47 -3
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/index.ts +0 -7
- package/src/ai/live-events.ts +1 -262
- package/src/ai/live.ts +31 -2
- package/src/ai/migrate.ts +35 -16
- package/src/ai/pdf-render.ts +2 -22
- package/src/ai/routes.ts +5 -4
- package/src/ai/runtime.ts +1 -6
- package/src/ai/solid.ts +0 -5
- package/src/ai/stream.ts +89 -65
- package/src/api/app-approval.ts +5 -1
- package/src/api/help.ts +2 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +19 -5
- package/src/api/pwa-phone.ts +168 -0
- package/src/api/pwa.ts +179 -0
- package/src/api/search/schemas.ts +34 -0
- package/src/api/search.ts +118 -50
- package/src/browser/CloudResourceSearch.browser-harness.tsx +11 -0
- package/src/browser/CloudResourceSearch.tsx +208 -80
- package/src/browser/app-session.ts +69 -0
- package/src/browser/live-connection.ts +211 -0
- package/src/browser/live-websocket.ts +31 -11
- package/src/browser/live.ts +2 -0
- package/src/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/capabilities.ts +89 -24
- package/src/contracts/capability-compatibility.ts +28 -14
- package/src/contracts/file-provider.ts +158 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/pwa-paths.ts +6 -0
- package/src/contracts/pwa.ts +156 -0
- package/src/contracts/registry.ts +7 -0
- package/src/contracts/shared.ts +7 -1
- package/src/events/index.ts +2 -0
- package/src/events/live-engine.ts +830 -0
- package/src/events/live-protocol.ts +31 -0
- package/src/events/live.ts +298 -0
- package/src/server/actor.ts +7 -0
- package/src/server/api-client.ts +23 -2
- package/src/server/help.ts +3 -3
- package/src/server/index.ts +3 -1
- package/src/server/middleware/auth.ts +80 -8
- package/src/server/middleware/openapi.ts +2 -1
- package/src/server/services/access.ts +49 -0
- package/src/server/services/index.ts +1 -0
- package/src/services/app-approval.ts +6 -25
- package/src/services/audit/index.ts +2 -0
- package/src/services/branding/app-icon-source.ts +40 -0
- package/src/services/branding/app-icons.ts +112 -0
- package/src/services/branding/icon-render-worker.ts +69 -0
- package/src/services/branding/icon-svg.ts +22 -0
- package/src/services/identity/invocation-actor.ts +8 -5
- package/src/services/identity/invocation-authority.ts +1 -0
- package/src/services/identity/invocation-token.ts +6 -0
- package/src/services/index.ts +1 -0
- package/src/services/outbox.ts +114 -80
- package/src/services/pairing-secret.ts +16 -0
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +640 -0
- package/src/services/session/index.ts +212 -57
- package/src/services/session/recent.ts +42 -0
- package/src/services/session/user.ts +15 -5
- package/src/shared/index.ts +1 -0
- package/src/{browser → shared}/locale-preference.ts +1 -1
- package/src/shared/markdown/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -23
- package/src/ssr/AppLaunchpad.island.tsx +8 -1
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/Layout.tsx +10 -12
- package/src/ssr/LayoutHeader.tsx +14 -2
- package/src/ssr/LayoutHelp.tsx +2 -4
- package/src/ssr/LayoutRail.tsx +13 -4
- package/src/ssr/MinimalLayoutPreferences.island.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +19 -13
- package/src/ssr/PageError.tsx +23 -1
- package/src/ssr/ProfilePreferences.island.tsx +9 -1
- package/src/ssr/PwaLayout.tsx +70 -0
- package/src/ssr/PwaRuntime.island.tsx +105 -0
- package/src/ssr/TimezoneCookie.island.tsx +7 -1
- package/src/ssr/app-navigation.ts +25 -10
- package/src/ssr/index.ts +8 -1
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/preference-controller.ts +2 -2
- package/src/ssr/profile-actions.ts +3 -1
- package/src/ssr/profile-preferences-messages.ts +2 -0
- package/src/ssr/pwa-messages.ts +24 -0
- package/src/styles/global.css +6 -0
- package/src/styles/resource-search.css +19 -0
- package/src/styles/utilities-feedback.css +2 -2
- package/scripts/build-pdf-renderer.ts +0 -39
- package/src/ai/client/live-connection.ts +0 -141
- package/src/ai/live-messages.ts +0 -45
- package/src/ai/live-outbox.ts +0 -102
- package/src/ai/live-routes.ts +0 -412
- package/src/shared/markdown/extensions/info-blocks.ts +0 -108
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { HtmlFn } from "@k2b/ssr";
|
|
2
2
|
import type { Context } from "hono";
|
|
3
3
|
import type { ClientErrorStatusCode, ServerErrorStatusCode } from "hono/utils/http-status";
|
|
4
|
+
import { PWA_AUTH_PATH, PWA_SCOPE } from "../contracts/pwa";
|
|
4
5
|
import { getLocale } from "../server/locale";
|
|
5
6
|
import { preloadLayoutAnnouncements } from "../server/middleware/settings";
|
|
6
7
|
import { createLoginRedirectUrl } from "../shared/redirect";
|
|
@@ -10,8 +11,8 @@ export type PageErrorOptions = {
|
|
|
10
11
|
title?: string;
|
|
11
12
|
description?: string;
|
|
12
13
|
action?: { label: string; href: string; icon?: string };
|
|
13
|
-
/** Keep standalone/public pages outside the Cloud navigation shell. */
|
|
14
|
-
layout?: "cloud" | "minimal";
|
|
14
|
+
/** Keep standalone/public pages outside the Cloud navigation shell; `pwa` renders a page of the mobile app. */
|
|
15
|
+
layout?: "cloud" | "minimal" | "pwa";
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export type PageErrorStatus = ClientErrorStatusCode | ServerErrorStatusCode;
|
|
@@ -22,7 +23,7 @@ export const createPageResponses = (html: HtmlFn<PageOptions>) => {
|
|
|
22
23
|
// Auth middleware may reject before ssr() initializes page metadata.
|
|
23
24
|
c.set("page", { lang: getLocale(c) });
|
|
24
25
|
c.header("Cache-Control", "private, no-store");
|
|
25
|
-
await preloadLayoutAnnouncements(c);
|
|
26
|
+
if (options.layout !== "pwa") await preloadLayoutAnnouncements(c);
|
|
26
27
|
// Load JSX only after the application's SSR plugin has been installed.
|
|
27
28
|
const { renderPageError } = await import("../ssr/PageError");
|
|
28
29
|
const response = await html(renderPageError(c, status, options), c.get("page"));
|
|
@@ -38,5 +39,20 @@ export const createPageResponses = (html: HtmlFn<PageOptions>) => {
|
|
|
38
39
|
},
|
|
39
40
|
};
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Route policy for pages of the mobile app (preview), below `/pwa/`. Only an app session reaches them. Without
|
|
44
|
+
* one, Core renews the app session with the phone's device key and returns to the same page; `pwa_launch` marks
|
|
45
|
+
* that return, so a second miss stops at the shell's "unavailable" state instead of bouncing again.
|
|
46
|
+
*/
|
|
47
|
+
const pwaAccess = {
|
|
48
|
+
onReject: (c: Context, reason: "unauthenticated" | "forbidden") => {
|
|
49
|
+
c.header("Cache-Control", "private, no-store");
|
|
50
|
+
if (reason === "forbidden" || c.get("actor")) return error(c, 403, { layout: "pwa" });
|
|
51
|
+
const url = new URL(c.req.url);
|
|
52
|
+
if (url.searchParams.has("pwa_launch")) return `${PWA_SCOPE}?pwa=unavailable`;
|
|
53
|
+
return `${PWA_AUTH_PATH}/session/launch?to=${encodeURIComponent(url.pathname + url.search)}`;
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
return { access, pwaAccess, error };
|
|
42
58
|
};
|
|
@@ -104,6 +104,12 @@ export const validateAppRegistryEntry = (value: unknown): string | null => {
|
|
|
104
104
|
)
|
|
105
105
|
return invalid("searchLinks", "an array of same-origin navigation links");
|
|
106
106
|
}
|
|
107
|
+
if (value.pwa !== undefined) {
|
|
108
|
+
if (!isRecord(value.pwa) || value.pwa.href !== `/pwa/${value.id as string}`) return invalid("pwa", "a mobile app part at /pwa/<id>");
|
|
109
|
+
if (value.pwa.requiresRoles !== undefined && !isStringArray(value.pwa.requiresRoles)) {
|
|
110
|
+
return invalid("pwa.requiresRoles", "an array of strings");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
107
113
|
if (value.widgets !== undefined) {
|
|
108
114
|
if (
|
|
109
115
|
!Array.isArray(value.widgets) ||
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { EphemeralConfig } from "@k2b/sync";
|
|
2
2
|
import type { AppAppearanceColor } from "../contracts/app";
|
|
3
3
|
import type { CapabilityManifest, CapabilityPresentationCatalog } from "../contracts/capabilities";
|
|
4
|
+
import { fileProviderIssues } from "../contracts/file-provider";
|
|
4
5
|
import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
|
|
5
6
|
import type { DashboardWidgetPresentation } from "../contracts/widgets";
|
|
6
7
|
import { resolveAppPresentations } from "../shared/app-presentation";
|
|
@@ -174,24 +175,98 @@ const capabilityEndpoint = (baseUrl: string): string | null => {
|
|
|
174
175
|
const appAccent = (value: string | undefined): AppAppearanceColor | undefined =>
|
|
175
176
|
/^#[0-9a-f]{6}$/i.test(value ?? "") ? (value as AppAppearanceColor) : undefined;
|
|
176
177
|
|
|
178
|
+
/** Last manifest per app whose left-out entries were checked, so each registered manifest logs once. */
|
|
179
|
+
const checkedLeftOutEntries = new Map<string, string>();
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Logs the entries of a registered manifest that this release left out, typically because the app runs a
|
|
183
|
+
* newer Cloud release. Core answers those operations as not found, so the operator needs their names.
|
|
184
|
+
* A left-out file provider is named too, because the app then quietly stops offering files. `manifest` is
|
|
185
|
+
* the manifest as read, before the file-provider contract check, which logs its own rejections.
|
|
186
|
+
*/
|
|
187
|
+
const reportLeftOutEntries = (sent: unknown, manifest: CapabilityManifest): void => {
|
|
188
|
+
if (checkedLeftOutEntries.get(manifest.appId) === manifest.manifestHash) return;
|
|
189
|
+
checkedLeftOutEntries.set(manifest.appId, manifest.manifestHash);
|
|
190
|
+
const kept = new Set([...manifest.types, ...manifest.queries, ...manifest.actions, ...manifest.commands].map((entry) => entry.localId));
|
|
191
|
+
const groups = sent as Record<string, unknown>;
|
|
192
|
+
const leftOut = (["types", "queries", "actions", "commands"] as const).flatMap((group) => {
|
|
193
|
+
const entries = groups[group];
|
|
194
|
+
if (!Array.isArray(entries)) return [];
|
|
195
|
+
return entries.flatMap((entry: unknown) => {
|
|
196
|
+
const localId = entry && typeof entry === "object" ? (entry as { localId?: unknown }).localId : undefined;
|
|
197
|
+
return typeof localId === "string" && !kept.has(localId) ? [`${group}/${localId}`] : [];
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
if (groups.fileProvider !== undefined && !manifest.fileProvider) leftOut.push("fileProvider");
|
|
201
|
+
if (leftOut.length === 0) return;
|
|
202
|
+
console.warn(
|
|
203
|
+
JSON.stringify({
|
|
204
|
+
level: "warn",
|
|
205
|
+
source: "capability-registry",
|
|
206
|
+
message: "Left out capability entries this Cloud release cannot read",
|
|
207
|
+
appId: manifest.appId,
|
|
208
|
+
manifestHash: manifest.manifestHash,
|
|
209
|
+
leftOutCount: leftOut.length,
|
|
210
|
+
leftOut: leftOut.slice(0, 20),
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** Last checked manifest per app: its hash and whether its file provider passed the contract check. */
|
|
216
|
+
const checkedFileProviders = new Map<string, { manifestHash: string; valid: boolean }>();
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Ignores a file provider that does not match the contract, logging once per manifest; the app's other
|
|
220
|
+
* capabilities stay available. The delivered `manifestHash` stays, because it identifies what the app registered.
|
|
221
|
+
*/
|
|
222
|
+
const withValidFileProvider = (manifest: CapabilityManifest): CapabilityManifest => {
|
|
223
|
+
if (!manifest.fileProvider) return manifest;
|
|
224
|
+
const checked = checkedFileProviders.get(manifest.appId);
|
|
225
|
+
let valid = checked?.manifestHash === manifest.manifestHash ? checked.valid : undefined;
|
|
226
|
+
if (valid === undefined) {
|
|
227
|
+
const issues = fileProviderIssues(manifest);
|
|
228
|
+
valid = issues.length === 0;
|
|
229
|
+
checkedFileProviders.set(manifest.appId, { manifestHash: manifest.manifestHash, valid });
|
|
230
|
+
if (!valid) {
|
|
231
|
+
console.error(
|
|
232
|
+
JSON.stringify({
|
|
233
|
+
level: "error",
|
|
234
|
+
source: "capability-registry",
|
|
235
|
+
message: "Ignored a file provider that does not match the file-provider contract",
|
|
236
|
+
appId: manifest.appId,
|
|
237
|
+
manifestHash: manifest.manifestHash,
|
|
238
|
+
issues: issues
|
|
239
|
+
.slice(0, 10)
|
|
240
|
+
.map(({ function: name, localId, code, path, message }) => ({ function: name, localId, code, path, message })),
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (valid) return manifest;
|
|
246
|
+
const { fileProvider: _ignored, ...rest } = manifest;
|
|
247
|
+
return rest;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/** Ignores record fields from a newer release; the endpoint always comes from the live app registry. */
|
|
177
251
|
export const resolveLiveCapabilityRegistryEntry = (
|
|
178
252
|
key: string,
|
|
179
253
|
value: unknown,
|
|
180
254
|
app: AppRegistryEntry | undefined,
|
|
181
255
|
): CapabilityRegistryEntry | null => {
|
|
182
256
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
183
|
-
if (Object.keys(value).some((field) => field !== "appId" && field !== "manifest" && field !== "presentation")) return null;
|
|
184
257
|
const record = value as Partial<CapabilityRegistryRecord>;
|
|
185
258
|
if (!app || typeof record.appId !== "string" || key !== `capabilities/${record.appId}` || record.appId !== app.id) return null;
|
|
186
259
|
if (!app.capabilities) return null;
|
|
187
260
|
const endpoint = capabilityEndpoint(app.baseUrl);
|
|
188
261
|
if (!endpoint) return null;
|
|
189
262
|
try {
|
|
190
|
-
const
|
|
191
|
-
const
|
|
263
|
+
const read = parseCapabilityManifest(record.manifest, app.id);
|
|
264
|
+
const manifest = withValidFileProvider(read);
|
|
265
|
+
const presentation = compileCapabilityPresentation(manifest, record.presentation, "reader");
|
|
192
266
|
if (app.capabilities.protocolVersion !== manifest.protocolVersion || app.capabilities.manifestHash !== manifest.manifestHash) {
|
|
193
267
|
return null;
|
|
194
268
|
}
|
|
269
|
+
reportLeftOutEntries(record.manifest, read);
|
|
195
270
|
return {
|
|
196
271
|
appId: app.id,
|
|
197
272
|
appName: app.name,
|
|
@@ -67,6 +67,7 @@ export const buildRuntimeFromRegistry = (entries: AppRegistryEntry[], capabiliti
|
|
|
67
67
|
),
|
|
68
68
|
legalLinks: e.legalLinks ? e.legalLinks.map((l) => ({ ...l })) : undefined,
|
|
69
69
|
searchLinks: e.searchLinks?.map((link) => ({ ...link, keywords: link.keywords ? [...link.keywords] : undefined })),
|
|
70
|
+
pwa: e.pwa ? { href: e.pwa.href, requiresRoles: e.pwa.requiresRoles ? ([...e.pwa.requiresRoles] as Role[]) : undefined } : undefined,
|
|
70
71
|
openapi: e.openapi,
|
|
71
72
|
};
|
|
72
73
|
}),
|
|
@@ -33,6 +33,15 @@ function acceptsEncoding(header: string | null, encoding: "br" | "gzip"): boolea
|
|
|
33
33
|
});
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** `If-None-Match` names the current file: a list of tags, possibly weak, or `*`. */
|
|
37
|
+
function matchesEtag(header: string | null, etag: string): boolean {
|
|
38
|
+
if (!header) return false;
|
|
39
|
+
return header.split(",").some((part) => {
|
|
40
|
+
const tag = part.trim();
|
|
41
|
+
return tag === "*" || tag === etag || tag === `W/${etag}`;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
36
45
|
async function encodedFile(path: string, encoding: "br" | "gzip"): Promise<Bun.BunFile | null> {
|
|
37
46
|
const suffix = encoding === "br" ? ".br" : ".gz";
|
|
38
47
|
const file = Bun.file(`${path}${suffix}`);
|
|
@@ -72,13 +81,19 @@ export function servePublicAsset(isDevelopment: boolean) {
|
|
|
72
81
|
}
|
|
73
82
|
}
|
|
74
83
|
|
|
84
|
+
// Development rebuilds files in place under the same address, so the browser asks again on every use; an
|
|
85
|
+
// unchanged file then costs one round trip instead of its whole size on every page.
|
|
86
|
+
const etag = `"${selectedEncoding ?? "identity"}-${selected.size.toString(36)}-${selected.lastModified.toString(36)}"`;
|
|
75
87
|
const headers = new Headers({
|
|
76
|
-
"Cache-Control": isDevelopment ? "no-
|
|
77
|
-
|
|
78
|
-
"Content-Type": sourceFile.type || "application/octet-stream",
|
|
88
|
+
"Cache-Control": isDevelopment ? "no-cache" : "public, max-age=31536000, immutable",
|
|
89
|
+
ETag: etag,
|
|
79
90
|
Vary: "Accept-Encoding",
|
|
80
91
|
});
|
|
81
92
|
|
|
93
|
+
if (matchesEtag(c.req.header("If-None-Match") ?? null, etag)) return new Response(null, { status: 304, headers });
|
|
94
|
+
|
|
95
|
+
headers.set("Content-Length", String(selected.size));
|
|
96
|
+
headers.set("Content-Type", sourceFile.type || "application/octet-stream");
|
|
82
97
|
if (selectedEncoding) headers.set("Content-Encoding", selectedEncoding);
|
|
83
98
|
|
|
84
99
|
return new Response(c.req.method === "HEAD" ? null : selected, { headers });
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { dates } from "@k2b/stdlib";
|
|
2
2
|
import { mutation as mutations } from "@k2b/stdlib/solid";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
Button,
|
|
5
|
+
CopyButton,
|
|
6
|
+
DateTimePicker,
|
|
7
|
+
DetailPanel,
|
|
8
|
+
NoticeCard,
|
|
9
|
+
Placeholder,
|
|
10
|
+
prompts,
|
|
11
|
+
Select,
|
|
12
|
+
Tag,
|
|
13
|
+
TextInput,
|
|
14
|
+
useLocale,
|
|
15
|
+
} from "@k2b/ui";
|
|
4
16
|
import { createEffect, createSignal, For, Show } from "solid-js";
|
|
5
17
|
import type { PermissionLevel, ServiceAccountCredential } from "../contracts/shared";
|
|
6
18
|
import { accessMessages } from "./messages";
|
|
@@ -56,9 +68,7 @@ function TokenDialog(props: { token: string }) {
|
|
|
56
68
|
const t = () => accessMessages.resolve([locale()]).t;
|
|
57
69
|
return (
|
|
58
70
|
<div class="flex flex-col gap-4">
|
|
59
|
-
<NoticeCard tone="warning"
|
|
60
|
-
{t().copyOnce}
|
|
61
|
-
</NoticeCard>
|
|
71
|
+
<NoticeCard tone="warning">{t().copyOnce}</NoticeCard>
|
|
62
72
|
<div class="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-900">
|
|
63
73
|
<code class="block break-all font-mono text-xs text-primary">{props.token}</code>
|
|
64
74
|
</div>
|
|
@@ -203,39 +213,29 @@ export default function ResourceApiKeys(props: ResourceApiKeysProps) {
|
|
|
203
213
|
if (confirmed) await revokeMutation.mutate({ id: key.id, name: key.name });
|
|
204
214
|
};
|
|
205
215
|
|
|
216
|
+
// The heading is a detail panel section heading: in a detail panel the keys line up with the sections around them,
|
|
217
|
+
// and below a settings group title they read as one level down.
|
|
206
218
|
return (
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
<p class="mt-1 text-xs text-dimmed">{props.description ?? t().apiKeysDescription}</p>
|
|
215
|
-
</div>
|
|
216
|
-
<Button type="button" variant="secondary" size="sm" class="shrink-0" onClick={openCreate} disabled={createMutation.loading()}>
|
|
217
|
-
<i class="ti ti-plus" />
|
|
219
|
+
<DetailPanel.Section
|
|
220
|
+
title={props.title ?? t().apiKeys}
|
|
221
|
+
icon="ti ti-key"
|
|
222
|
+
description={props.description ?? t().apiKeysDescription}
|
|
223
|
+
actions={
|
|
224
|
+
<Button type="button" variant="secondary" size="sm" onClick={openCreate} disabled={createMutation.loading()}>
|
|
225
|
+
<i class="ti ti-plus" aria-hidden="true" />
|
|
218
226
|
{t().add}
|
|
219
227
|
</Button>
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
<Show
|
|
223
|
-
when={keys().length > 0}
|
|
224
|
-
fallback={
|
|
225
|
-
<Placeholder
|
|
226
|
-
icon="ti ti-key"
|
|
227
|
-
class="rounded-lg border border-dashed border-zinc-200 dark:border-zinc-800"
|
|
228
|
-
description={t().noApiKeys}
|
|
229
|
-
/>
|
|
230
|
-
}
|
|
231
|
-
>
|
|
228
|
+
}
|
|
229
|
+
>
|
|
230
|
+
<Show when={keys().length > 0} fallback={<Placeholder icon="ti ti-key" align="left" description={t().noApiKeys} />}>
|
|
232
231
|
<div class="flex flex-col gap-2">
|
|
233
232
|
<For each={keys()}>
|
|
234
233
|
{(key) => (
|
|
235
234
|
<div class="group/api-key flex items-center gap-3 rounded-lg bg-zinc-50/70 p-3 dark:bg-zinc-900/35">
|
|
236
235
|
<div class="min-w-0 flex-1">
|
|
237
|
-
|
|
238
|
-
|
|
236
|
+
{/* The name and its tags wrap instead of squeezing the name to a few letters in a narrow section. */}
|
|
237
|
+
<div class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
|
238
|
+
<span class="max-w-full truncate text-sm font-medium text-primary">{key.name}</span>
|
|
239
239
|
<Tag color={key.permission === "none" ? "#dc2626" : "#2563eb"}>
|
|
240
240
|
{permissionLabel(key.permission, options(), t().noAccess)}
|
|
241
241
|
</Tag>
|
|
@@ -266,6 +266,6 @@ export default function ResourceApiKeys(props: ResourceApiKeysProps) {
|
|
|
266
266
|
</For>
|
|
267
267
|
</div>
|
|
268
268
|
</Show>
|
|
269
|
-
</
|
|
269
|
+
</DetailPanel.Section>
|
|
270
270
|
);
|
|
271
271
|
}
|
|
@@ -8,6 +8,7 @@ import type { AccessSubject } from "../server/services/access";
|
|
|
8
8
|
import { isAccountCategoryAllowed } from "../services/account-category-policy";
|
|
9
9
|
import { isAccountExpired } from "../services/account-model";
|
|
10
10
|
import { accounts } from "../services/accounts";
|
|
11
|
+
import { withoutAdminRole } from "../services/session/user";
|
|
11
12
|
import { LOCALE_HEADER } from "../shared/locale";
|
|
12
13
|
import type { AiCapabilityCatalogEntry } from "./capabilities";
|
|
13
14
|
import { aiToolAudit } from "./tool-audit";
|
|
@@ -30,6 +31,10 @@ export const resolveAiCapabilityActor = async (input: {
|
|
|
30
31
|
const user = await (input.getUser ?? accounts.users.get)({ id: conversation.createdByUserId });
|
|
31
32
|
if (!user || isAccountExpired(user.accountExpires)) throw new Error("Cloud capability actor is no longer active.");
|
|
32
33
|
if (!(await isAccountCategoryAllowed(user))) throw new Error("Cloud capability actor category is disabled.");
|
|
34
|
+
// A turn started in the mobile app keeps its limits: no administrator role, and invocations say so.
|
|
35
|
+
if (input.persistedActor.sessionKind === "app") {
|
|
36
|
+
return { actor: { kind: "user", user: withoutAdminRole(user), sessionKind: "app" }, accessSubject: { type: "user", userId: user.id } };
|
|
37
|
+
}
|
|
33
38
|
return { actor: { kind: "user", user }, accessSubject: { type: "user", userId: user.id } };
|
|
34
39
|
};
|
|
35
40
|
|
package/src/ai/chat/blocks.tsx
CHANGED
|
@@ -705,11 +705,12 @@ function ToolBlockView(props: { turnId: string; block: ToolBlock; active?: boole
|
|
|
705
705
|
|
|
706
706
|
/** Render one unified turn block. Shared by persisted assistant groups and the live turn. */
|
|
707
707
|
export function AiTurnBlockView(props: { block: AiTurnBlock; turnId: string; streaming?: boolean; active?: boolean }) {
|
|
708
|
+
const locale = useLocale();
|
|
708
709
|
// The id/kind remain stable while the immutable block value changes during a turn.
|
|
709
710
|
return (
|
|
710
711
|
<Switch>
|
|
711
712
|
<Match when={props.block.kind === "text" && props.block}>
|
|
712
|
-
{(block) => <AssistantMarkdownBlock html={markdown.renderSync(block().text)} />}
|
|
713
|
+
{(block) => <AssistantMarkdownBlock html={markdown.renderSync(block().text, { locale: locale() })} />}
|
|
713
714
|
</Match>
|
|
714
715
|
<Match when={props.block.kind === "thinking" && props.block}>
|
|
715
716
|
{(block) => <ThinkingBlockView text={block().text} streaming={props.streaming} />}
|
package/src/ai/chat/messages.ts
CHANGED
|
@@ -22,6 +22,9 @@ const messages = i18n.define({
|
|
|
22
22
|
thinking: "Thinking",
|
|
23
23
|
showReasoning: "Show reasoning",
|
|
24
24
|
byteRange: ({ start, end }: { start: string; end: string }) => `Bytes ${start}–${end}`,
|
|
25
|
+
streamLoginRequired: "Your session has ended. Sign in again to continue this chat.",
|
|
26
|
+
streamAccessDenied: "You no longer have access to this chat.",
|
|
27
|
+
streamNotFound: "This chat is no longer available.",
|
|
25
28
|
},
|
|
26
29
|
de: {
|
|
27
30
|
backgroundRun: "Hintergrundlauf",
|
|
@@ -43,6 +46,9 @@ const messages = i18n.define({
|
|
|
43
46
|
thinking: "Denkt nach",
|
|
44
47
|
showReasoning: "Denkprozess anzeigen",
|
|
45
48
|
byteRange: ({ start, end }) => `Bytes ${start}–${end}`,
|
|
49
|
+
streamLoginRequired: "Deine Sitzung ist abgelaufen. Melde dich erneut an, um diesen Chat fortzusetzen.",
|
|
50
|
+
streamAccessDenied: "Du hast keinen Zugriff mehr auf diesen Chat.",
|
|
51
|
+
streamNotFound: "Dieser Chat ist nicht mehr verfügbar.",
|
|
46
52
|
},
|
|
47
53
|
},
|
|
48
54
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Accessor, createMemo, createSignal, onCleanup } from "solid-js";
|
|
2
2
|
import { createStore, reconcile } from "solid-js/store";
|
|
3
3
|
import { type AiAttachmentRef, aiAttachmentMarker } from "../attachments";
|
|
4
|
+
import { aiChatMessages } from "../chat/messages";
|
|
4
5
|
import { type AiStreamEvent, type AiTurnBlock, type AiTurnSnapshot, steerMessageBlockId } from "../protocol";
|
|
5
6
|
import { type AiResourceMarker, aiResourceMarker } from "../resource-markers";
|
|
6
7
|
import type {
|
|
@@ -24,7 +25,14 @@ import {
|
|
|
24
25
|
reduceProjection,
|
|
25
26
|
visibleMessages,
|
|
26
27
|
} from "./projection";
|
|
27
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
type AiConversationStreamTransport,
|
|
30
|
+
AiStreamError,
|
|
31
|
+
type AiStreamErrorCode,
|
|
32
|
+
type AiStreamHandle,
|
|
33
|
+
aiSseConversationStreamTransport,
|
|
34
|
+
terminalAiStreamErrorCode,
|
|
35
|
+
} from "./transport";
|
|
28
36
|
|
|
29
37
|
type ComposerDraftInput = {
|
|
30
38
|
draftContent?: AiDraftContentPart[];
|
|
@@ -108,7 +116,7 @@ export type CreateAiChatControllerOptions = {
|
|
|
108
116
|
frontendTools?: Record<string, AiFrontendToolHandler>;
|
|
109
117
|
/** Explicitly advertise only client tools with a connected handler. */
|
|
110
118
|
clientToolIds?: AiClientToolId[];
|
|
111
|
-
/** Overrides the default SSE transport
|
|
119
|
+
/** Overrides the default SSE transport of the conversation stream. */
|
|
112
120
|
streamTransport?: AiConversationStreamTransport;
|
|
113
121
|
};
|
|
114
122
|
|
|
@@ -142,6 +150,21 @@ const completeFrontendToolBlock = (blocks: AiTurnBlock[], callId: string, result
|
|
|
142
150
|
const isActiveConversationLoading = (activeConversationId: string | null, loadingConversationId: string | null): boolean =>
|
|
143
151
|
activeConversationId !== null && loadingConversationId === activeConversationId;
|
|
144
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Why the stream ended, in the request's locale: the server renders it into
|
|
155
|
+
* `<html lang>`, which is also what `useLocale()` of `@k2b/ui` resolves in an
|
|
156
|
+
* island. The controller stays headless and does not load the component library.
|
|
157
|
+
*/
|
|
158
|
+
const streamErrorText = (code: AiStreamErrorCode): string => {
|
|
159
|
+
const t = aiChatMessages(typeof document === "undefined" ? "en" : document.documentElement.lang || "en");
|
|
160
|
+
if (code === "login_required") return t.streamLoginRequired;
|
|
161
|
+
return code === "access_denied" ? t.streamAccessDenied : t.streamNotFound;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/** A chat that cannot continue says why in the page's language; any other error shows its own message. */
|
|
165
|
+
const errorText = (error: unknown, fallback: string): string =>
|
|
166
|
+
error instanceof AiStreamError ? streamErrorText(error.code) : error instanceof Error ? error.message : fallback;
|
|
167
|
+
|
|
145
168
|
export const createAiChatController = (options: CreateAiChatControllerOptions) => {
|
|
146
169
|
const [activeConversationId, setActiveConversationIdSignal] = createSignal<string | null>(options.initialConversationId ?? null);
|
|
147
170
|
const [globalError, setGlobalError] = createSignal<string | null>(options.initialError ?? null);
|
|
@@ -178,6 +201,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
178
201
|
const abortRequests = new Map<string, Promise<boolean>>();
|
|
179
202
|
let stream: AiStreamHandle | null = null;
|
|
180
203
|
let streamSession: AiStreamSession | null = null;
|
|
204
|
+
/** The chat error left by a stream that ended for good, until a stream opens again. */
|
|
205
|
+
let streamError: string | null = null;
|
|
181
206
|
let streamGeneration = 0;
|
|
182
207
|
let conversationOpenGeneration = 0;
|
|
183
208
|
let historyTargetSeq: number | null = null;
|
|
@@ -217,9 +242,12 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
217
242
|
if (activeConversationId()) setGlobalError(message);
|
|
218
243
|
else setGlobalError(message);
|
|
219
244
|
};
|
|
245
|
+
/** Starts the person's next action in the active chat clean, including a stream that ended with an error. */
|
|
220
246
|
const clearErrors = () => {
|
|
221
247
|
setGlobalError(null);
|
|
222
248
|
setRunError(null);
|
|
249
|
+
const conversationId = activeConversationId();
|
|
250
|
+
if (conversationId) openStream(conversationId);
|
|
223
251
|
};
|
|
224
252
|
const error = () => globalError() ?? runError();
|
|
225
253
|
|
|
@@ -314,17 +342,29 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
314
342
|
conversationId,
|
|
315
343
|
url: url(`/conversations/${conversationId}/stream`),
|
|
316
344
|
onStatus: (status) => {
|
|
317
|
-
if (isCurrentStreamSession(streamSession, session))
|
|
345
|
+
if (!isCurrentStreamSession(streamSession, session)) return;
|
|
346
|
+
setStreamStatus(status);
|
|
347
|
+
if (status !== "open" || streamError === null) return;
|
|
348
|
+
if (globalError() === streamError) setGlobalError(null);
|
|
349
|
+
streamError = null;
|
|
318
350
|
},
|
|
319
351
|
onEvent: (event) => applyEvent(session, event),
|
|
352
|
+
// The transport has stopped. Drop its handle so the next visit, refresh,
|
|
353
|
+
// or action in this chat can subscribe again.
|
|
320
354
|
onError: (error) => {
|
|
321
355
|
if (!isCurrentStreamSession(streamSession, session)) return;
|
|
322
|
-
|
|
323
|
-
setConversationError(conversationId, error.message);
|
|
356
|
+
endStream(conversationId, error);
|
|
324
357
|
},
|
|
325
358
|
});
|
|
326
359
|
};
|
|
327
360
|
|
|
361
|
+
/** Stops the chat's stream for good and shows why. */
|
|
362
|
+
const endStream = (conversationId: string, error: Error) => {
|
|
363
|
+
closeStream();
|
|
364
|
+
streamError = errorText(error, error.message);
|
|
365
|
+
setConversationError(conversationId, streamError);
|
|
366
|
+
};
|
|
367
|
+
|
|
328
368
|
// ---- frontend tools ---------------------------------------------------
|
|
329
369
|
|
|
330
370
|
const runFrontendTools = () => {
|
|
@@ -381,13 +421,20 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
381
421
|
} catch {}
|
|
382
422
|
};
|
|
383
423
|
|
|
424
|
+
/** Loads a chat. A status after which no retry can load it throws an `AiStreamError`, like the stream's. */
|
|
425
|
+
const fetchDetail = async (conversationId: string, fallback: string): Promise<AiConversationDetail> => {
|
|
426
|
+
const response = await fetch(url(`/conversations/${conversationId}`));
|
|
427
|
+
if (response.ok) return (await response.json()) as AiConversationDetail;
|
|
428
|
+
const message = await readError(response, fallback);
|
|
429
|
+
const ended = terminalAiStreamErrorCode(response.status);
|
|
430
|
+
throw ended ? new AiStreamError(ended, message) : new Error(message);
|
|
431
|
+
};
|
|
432
|
+
|
|
384
433
|
const loadDetail = async (conversationId: string, shouldReportError: () => boolean): Promise<AiConversationDetail | null> => {
|
|
385
434
|
try {
|
|
386
|
-
return await
|
|
435
|
+
return await fetchDetail(conversationId, "Failed to open conversation");
|
|
387
436
|
} catch (loadError) {
|
|
388
|
-
if (shouldReportError())
|
|
389
|
-
setConversationError(conversationId, loadError instanceof Error ? loadError.message : "Failed to open conversation");
|
|
390
|
-
}
|
|
437
|
+
if (shouldReportError()) setConversationError(conversationId, errorText(loadError, "Failed to open conversation"));
|
|
391
438
|
return null;
|
|
392
439
|
}
|
|
393
440
|
};
|
|
@@ -422,6 +469,7 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
422
469
|
|
|
423
470
|
const openConversation = async (conversationId: string) => {
|
|
424
471
|
if (activeConversationId() === conversationId && state.conversation?.id === conversationId) {
|
|
472
|
+
openStream(conversationId);
|
|
425
473
|
void markConversationViewed(conversationId);
|
|
426
474
|
return "current" as const;
|
|
427
475
|
}
|
|
@@ -461,22 +509,31 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
461
509
|
};
|
|
462
510
|
|
|
463
511
|
let conversationRefreshGeneration = 0;
|
|
464
|
-
|
|
512
|
+
/**
|
|
513
|
+
* Loads the active chat again. Resolves `false` when it no longer exists or
|
|
514
|
+
* is no longer readable: the chat then ends like its stream, with the reason
|
|
515
|
+
* as its error, because no retry can load it. A response that a newer
|
|
516
|
+
* refresh or a newer opening of a chat overtook changes nothing.
|
|
517
|
+
*/
|
|
518
|
+
const refreshActiveConversation = async (): Promise<boolean> => {
|
|
465
519
|
const conversationId = activeConversationId();
|
|
466
|
-
if (!conversationId) return;
|
|
520
|
+
if (!conversationId) return true;
|
|
467
521
|
const refreshGeneration = ++conversationRefreshGeneration;
|
|
468
522
|
const openGeneration = conversationOpenGeneration;
|
|
469
|
-
const
|
|
470
|
-
`/conversations/${conversationId}`,
|
|
471
|
-
{ method: "GET" },
|
|
472
|
-
"Failed to refresh conversation",
|
|
473
|
-
);
|
|
474
|
-
if (
|
|
523
|
+
const overtaken = () =>
|
|
475
524
|
!isActiveConversation(conversationId) ||
|
|
476
525
|
refreshGeneration !== conversationRefreshGeneration ||
|
|
477
|
-
openGeneration !== conversationOpenGeneration
|
|
478
|
-
|
|
479
|
-
|
|
526
|
+
openGeneration !== conversationOpenGeneration;
|
|
527
|
+
let detail: AiConversationDetail;
|
|
528
|
+
try {
|
|
529
|
+
detail = await fetchDetail(conversationId, "Failed to refresh conversation");
|
|
530
|
+
} catch (refreshError) {
|
|
531
|
+
if (!(refreshError instanceof AiStreamError)) throw refreshError;
|
|
532
|
+
if (overtaken()) return true;
|
|
533
|
+
endStream(conversationId, refreshError);
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
if (overtaken()) return true;
|
|
480
537
|
const currentDraft = state.conversation?.draft;
|
|
481
538
|
if (currentDraft && currentDraft.revision > detail.conversation.draft.revision) detail.conversation.draft = currentDraft;
|
|
482
539
|
const windowOldest = detail.messages[0]?.seq;
|
|
@@ -485,6 +542,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
485
542
|
if (preservedOlder.length === 0) setHasMore(conversationId, detail.hasMoreMessages ?? false);
|
|
486
543
|
if (detail.timeline) setTimeline(conversationId, detail.timeline);
|
|
487
544
|
setRunError(conversationRunError(detail.conversation));
|
|
545
|
+
openStream(conversationId);
|
|
546
|
+
return true;
|
|
488
547
|
};
|
|
489
548
|
|
|
490
549
|
const requestMessagesPage = async (conversationId: string, before: number, limit: number): Promise<AiMessagesPage> => {
|
|
@@ -573,8 +632,8 @@ export const createAiChatController = (options: CreateAiChatControllerOptions) =
|
|
|
573
632
|
if (conversationId) void openConversation(conversationId);
|
|
574
633
|
else {
|
|
575
634
|
conversationOpenGeneration += 1;
|
|
576
|
-
clearErrors();
|
|
577
635
|
setActiveConversationIdSignal(null);
|
|
636
|
+
clearErrors();
|
|
578
637
|
historyTargetSeq = null;
|
|
579
638
|
setLoadingConversationId(null);
|
|
580
639
|
closeStream();
|
|
@@ -13,6 +13,38 @@ export type AiConversationStreamTransport = {
|
|
|
13
13
|
}) => AiStreamHandle;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
/** Why a conversation stream ended for good. A client shows its own text for the code. */
|
|
17
|
+
export type AiStreamErrorCode = "login_required" | "access_denied" | "not_found";
|
|
18
|
+
|
|
19
|
+
/** A stream that will not recover by reconnecting; `message` comes from the server when it sent one. */
|
|
20
|
+
export class AiStreamError extends Error {
|
|
21
|
+
readonly code: AiStreamErrorCode;
|
|
22
|
+
|
|
23
|
+
constructor(code: AiStreamErrorCode, message: string) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "AiStreamError";
|
|
26
|
+
this.code = code;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TERMINAL_STATUS_CODES: Readonly<Record<number, AiStreamErrorCode>> = {
|
|
31
|
+
401: "login_required",
|
|
32
|
+
403: "access_denied",
|
|
33
|
+
404: "not_found",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The code of an HTTP status after which a conversation cannot continue, by retrying or reconnecting. */
|
|
37
|
+
export const terminalAiStreamErrorCode = (status: number): AiStreamErrorCode | undefined => TERMINAL_STATUS_CODES[status];
|
|
38
|
+
|
|
39
|
+
const terminalStreamError = async (response: Response, code: AiStreamErrorCode): Promise<AiStreamError> => {
|
|
40
|
+
const body: unknown = await response.json().catch(() => null);
|
|
41
|
+
const message =
|
|
42
|
+
body && typeof body === "object" && "message" in body && typeof body.message === "string" && body.message
|
|
43
|
+
? body.message
|
|
44
|
+
: `AI stream failed: ${response.status}`;
|
|
45
|
+
return new AiStreamError(code, message);
|
|
46
|
+
};
|
|
47
|
+
|
|
16
48
|
const RECONNECT_BASE_MS = 500;
|
|
17
49
|
const RECONNECT_MAX_MS = 5_000;
|
|
18
50
|
const CONNECT_TIMEOUT_MS = 10_000;
|
|
@@ -53,12 +85,15 @@ export async function* parseAiSse(response: Response, signal: AbortSignal): Asyn
|
|
|
53
85
|
/**
|
|
54
86
|
* Subscribe to a conversation's SSE stream with automatic reconnect. Each
|
|
55
87
|
* (re)connect starts with a fresh `state` event, so the projection self-heals on
|
|
56
|
-
* every reconnect without cursor bookkeeping.
|
|
88
|
+
* every reconnect without cursor bookkeeping. A 401, 403, or 404 cannot heal
|
|
89
|
+
* that way: the subscription stops and reports an `AiStreamError` once through
|
|
90
|
+
* `onError`, its only signal that the stream ended. Status stays where it was.
|
|
57
91
|
*/
|
|
58
92
|
export const subscribeAiStream = (input: {
|
|
59
93
|
url: string;
|
|
60
94
|
onEvent: (event: AiStreamEvent) => void;
|
|
61
95
|
onStatus?: (status: AiStreamConnectionStatus) => void;
|
|
96
|
+
onError?: (error: AiStreamError) => void;
|
|
62
97
|
fetch?: AiStreamFetch;
|
|
63
98
|
}): AiStreamHandle => {
|
|
64
99
|
const fetchStream: AiStreamFetch = input.fetch ?? fetch;
|
|
@@ -80,8 +115,17 @@ export const subscribeAiStream = (input: {
|
|
|
80
115
|
try {
|
|
81
116
|
input.onStatus?.(reconnectDelay === RECONNECT_BASE_MS ? "connecting" : "reconnecting");
|
|
82
117
|
const response = await fetchStream(input.url, { signal: attempt.signal, headers: { Accept: "text/event-stream" } });
|
|
83
|
-
clearConnectTimer();
|
|
84
118
|
if (stopped) return;
|
|
119
|
+
const terminalCode = terminalAiStreamErrorCode(response.status);
|
|
120
|
+
if (terminalCode) {
|
|
121
|
+
// The connect timeout still bounds reading the error body.
|
|
122
|
+
const error = await terminalStreamError(response, terminalCode);
|
|
123
|
+
if (stopped) return;
|
|
124
|
+
stopped = true;
|
|
125
|
+
input.onError?.(error);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
clearConnectTimer();
|
|
85
129
|
if (!response.ok || !response.body) throw new Error(`AI stream failed: ${response.status}`);
|
|
86
130
|
input.onStatus?.("open");
|
|
87
131
|
reconnectDelay = RECONNECT_BASE_MS;
|
|
@@ -113,5 +157,5 @@ export const subscribeAiStream = (input: {
|
|
|
113
157
|
};
|
|
114
158
|
|
|
115
159
|
export const aiSseConversationStreamTransport: AiConversationStreamTransport = {
|
|
116
|
-
subscribe: ({ url, onEvent, onStatus }) => subscribeAiStream({ url, onEvent, onStatus }),
|
|
160
|
+
subscribe: ({ url, onEvent, onStatus, onError }) => subscribeAiStream({ url, onEvent, onStatus, onError }),
|
|
117
161
|
};
|