@k2b/cloud 0.23.0 → 0.25.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 +3 -2
- package/src/_internal/capabilities.ts +230 -75
- package/src/_internal/define-app.ts +12 -3
- package/src/_internal/fixtures/filesv2-manifest-cloud-v0.29.0.json +887 -0
- package/src/_internal/page-responses.ts +1 -1
- package/src/_internal/registry.ts +78 -3
- package/src/access/PermissionEditor.tsx +32 -11
- package/src/access/managers.ts +32 -0
- package/src/access/messages.ts +6 -0
- package/src/ai/chat/blocks.tsx +2 -1
- package/src/ai/chat/messages.ts +6 -0
- package/src/ai/client/controller.ts +65 -20
- package/src/ai/client/transport.ts +5 -5
- 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/memory-learning.ts +7 -12
- package/src/ai/memory-workflow-evidence.ts +10 -2
- package/src/ai/migrate.ts +80 -18
- package/src/ai/model-access.ts +4 -2
- package/src/ai/prefs.ts +19 -5
- package/src/ai/projects.ts +5 -2
- package/src/ai/runtime.ts +1 -6
- package/src/ai/skills.ts +5 -2
- package/src/ai/solid.ts +0 -5
- package/src/ai/store.ts +14 -0
- package/src/api/help.ts +2 -2
- package/src/api/pwa-phone.ts +10 -3
- 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/FileChooser.tsx +600 -0
- package/src/browser/choose-files.browser-harness.tsx +32 -0
- package/src/browser/choose-files.tsx +70 -0
- package/src/browser/file-chooser-messages.ts +86 -0
- package/src/browser/file-providers.ts +217 -0
- package/src/browser/files.tsx +23 -0
- package/src/browser/resource-search-messages.ts +15 -2
- package/src/browser/search-stream.ts +151 -0
- package/src/cli/admin/account-administration.ts +2 -2
- 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 +1 -0
- package/src/events/live-engine.ts +11 -7
- package/src/events/live.ts +32 -32
- package/src/server/help.ts +3 -3
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +44 -11
- package/src/server/services/index.ts +1 -0
- package/src/services/index.ts +1 -1
- package/src/services/outbox.ts +29 -115
- package/src/services/pdf/markdown.ts +22 -2
- package/src/services/pwa-devices.ts +74 -8
- package/src/shared/markdown/formula.ts +122 -26
- package/src/shared/markdown/index.ts +27 -14
- package/src/ssr/PwaLayout.tsx +2 -1
- package/src/ssr/platform-messages.ts +2 -2
- package/src/styles/file-chooser.css +169 -0
- package/src/styles/global.css +1 -0
- package/src/styles/resource-search.css +19 -0
- 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 -79
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createLiveWebSocket,
|
|
3
|
-
type LiveWebSocket,
|
|
4
|
-
type LiveWebSocketClose,
|
|
5
|
-
type LiveWebSocketError,
|
|
6
|
-
type LiveWebSocketStatus,
|
|
7
|
-
} from "../../browser/live-websocket";
|
|
8
|
-
import { AI_LIVE_WS_TYPE, type AiLiveServerMessage, parseAiLiveServerMessage } from "../live-events";
|
|
9
|
-
import type { AiConversationStreamTransport, AiStreamConnectionStatus } from "./transport";
|
|
10
|
-
|
|
11
|
-
type TurnSubscription = {
|
|
12
|
-
conversationId: string;
|
|
13
|
-
generation: number;
|
|
14
|
-
onEvent: Parameters<AiConversationStreamTransport["subscribe"]>[0]["onEvent"];
|
|
15
|
-
onStatus?: Parameters<AiConversationStreamTransport["subscribe"]>[0]["onStatus"];
|
|
16
|
-
onError?: Parameters<AiConversationStreamTransport["subscribe"]>[0]["onError"];
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
export type AiLiveConnection = {
|
|
20
|
-
connect: () => void;
|
|
21
|
-
markApplied: (cursor: string | null | undefined) => void;
|
|
22
|
-
streamTransport: AiConversationStreamTransport;
|
|
23
|
-
dispose: () => void;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
export type CreateAiLiveConnectionOptions = {
|
|
27
|
-
url?: string;
|
|
28
|
-
initialCursor: string;
|
|
29
|
-
onLiveMessage: (message: AiLiveServerMessage) => void;
|
|
30
|
-
onLiveStatus?: (status: LiveWebSocketStatus) => void;
|
|
31
|
-
onFatal?: (error: LiveWebSocketError) => void;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const reconnectableAiClose = (close: LiveWebSocketClose): LiveWebSocketError | null =>
|
|
35
|
-
close.code === 1008 ? { code: close.reason || "access_denied", message: "Live access changed or expired." } : null;
|
|
36
|
-
|
|
37
|
-
const streamStatus = (status: LiveWebSocketStatus): AiStreamConnectionStatus | null => {
|
|
38
|
-
if (status === "connecting" || status === "reconnecting") return status;
|
|
39
|
-
if (status === "paused") return "reconnecting";
|
|
40
|
-
return null;
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
/** One browser connection for AI invalidations and the currently visible turn. */
|
|
44
|
-
export const createAiLiveConnection = (options: CreateAiLiveConnectionOptions): AiLiveConnection => {
|
|
45
|
-
let socket: LiveWebSocket;
|
|
46
|
-
let turn: TurnSubscription | null = null;
|
|
47
|
-
let turnGeneration = 0;
|
|
48
|
-
let connected = false;
|
|
49
|
-
let disposed = false;
|
|
50
|
-
let liveSubscribeCount = 0;
|
|
51
|
-
|
|
52
|
-
const sendTurnSubscription = () => {
|
|
53
|
-
if (!connected || !turn) return;
|
|
54
|
-
if (
|
|
55
|
-
!socket.send({
|
|
56
|
-
type: AI_LIVE_WS_TYPE.turnSubscribe,
|
|
57
|
-
payload: { conversationId: turn.conversationId },
|
|
58
|
-
})
|
|
59
|
-
) {
|
|
60
|
-
turn.onStatus?.("reconnecting");
|
|
61
|
-
}
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
socket = createLiveWebSocket<AiLiveServerMessage>({
|
|
65
|
-
url: options.url ?? "/api/ai/live",
|
|
66
|
-
activity: "always",
|
|
67
|
-
initialCursor: options.initialCursor,
|
|
68
|
-
subscribe: (cursor) => ({
|
|
69
|
-
type: AI_LIVE_WS_TYPE.subscribe,
|
|
70
|
-
payload: { fromCursor: cursor, recover: liveSubscribeCount++ > 0 },
|
|
71
|
-
}),
|
|
72
|
-
parse: parseAiLiveServerMessage,
|
|
73
|
-
classifyClose: reconnectableAiClose,
|
|
74
|
-
onOpen: sendTurnSubscription,
|
|
75
|
-
onStatus: (status) => {
|
|
76
|
-
connected = status === "open";
|
|
77
|
-
options.onLiveStatus?.(status);
|
|
78
|
-
const next = streamStatus(status);
|
|
79
|
-
if (next) turn?.onStatus?.(next);
|
|
80
|
-
},
|
|
81
|
-
onMessage: (message) => {
|
|
82
|
-
if (message.type === AI_LIVE_WS_TYPE.turnEvent) {
|
|
83
|
-
if (!turn || turn.conversationId !== message.payload.conversationId) return;
|
|
84
|
-
if (message.payload.event.type === "state") turn.onStatus?.("open");
|
|
85
|
-
turn.onEvent(message.payload.event);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
if (message.type === AI_LIVE_WS_TYPE.turnError) {
|
|
89
|
-
if (!turn || turn.conversationId !== message.payload.conversationId) return;
|
|
90
|
-
const current = turn;
|
|
91
|
-
turn = null;
|
|
92
|
-
current.onError?.(new Error(message.payload.message));
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
options.onLiveMessage(message);
|
|
96
|
-
},
|
|
97
|
-
onFatal: (error) => {
|
|
98
|
-
connected = false;
|
|
99
|
-
const current = turn;
|
|
100
|
-
turn = null;
|
|
101
|
-
current?.onError?.(new Error(error.message));
|
|
102
|
-
options.onFatal?.(error);
|
|
103
|
-
},
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
const streamTransport: AiConversationStreamTransport = {
|
|
107
|
-
subscribe: ({ conversationId, onEvent, onStatus, onError }) => {
|
|
108
|
-
const generation = ++turnGeneration;
|
|
109
|
-
const subscription: TurnSubscription = { conversationId, generation, onEvent, onStatus, onError };
|
|
110
|
-
turn = subscription;
|
|
111
|
-
onStatus?.("connecting");
|
|
112
|
-
sendTurnSubscription();
|
|
113
|
-
return {
|
|
114
|
-
close: () => {
|
|
115
|
-
if (turn?.generation !== generation) return;
|
|
116
|
-
turn = null;
|
|
117
|
-
if (connected) {
|
|
118
|
-
socket.send({
|
|
119
|
-
type: AI_LIVE_WS_TYPE.turnUnsubscribe,
|
|
120
|
-
payload: { conversationId },
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
};
|
|
125
|
-
},
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
return {
|
|
129
|
-
connect: () => {
|
|
130
|
-
if (!disposed) socket.connect();
|
|
131
|
-
},
|
|
132
|
-
markApplied: (cursor) => socket.markApplied(cursor),
|
|
133
|
-
streamTransport,
|
|
134
|
-
dispose: () => {
|
|
135
|
-
if (disposed) return;
|
|
136
|
-
disposed = true;
|
|
137
|
-
turn = null;
|
|
138
|
-
socket.dispose();
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
};
|
package/src/ai/live-messages.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { i18n } from "@k2b/stdlib";
|
|
2
|
-
|
|
3
|
-
const catalog = i18n.define({
|
|
4
|
-
baseLocale: "en",
|
|
5
|
-
messages: {
|
|
6
|
-
en: {
|
|
7
|
-
loginRequired: "Login required",
|
|
8
|
-
conversationAccessChanged: "Conversation access changed or expired",
|
|
9
|
-
capacityExceeded: "AI updates exceeded the connection capacity",
|
|
10
|
-
authorizationRefreshFailed: "AI live authorization refresh failed",
|
|
11
|
-
invalidStreamData: "AI event stream contains invalid data",
|
|
12
|
-
eventStreamEnded: "AI event stream ended",
|
|
13
|
-
eventStreamFailed: "AI event stream failed",
|
|
14
|
-
subscriptionFailed: "AI live subscription failed",
|
|
15
|
-
conversationStreamEnded: "AI conversation stream ended",
|
|
16
|
-
conversationStreamFailed: "AI conversation stream failed",
|
|
17
|
-
liveSubscriptionRequired: "AI live subscription required before a turn subscription",
|
|
18
|
-
conversationNotFound: "Conversation not found",
|
|
19
|
-
invalidJson: "Invalid JSON payload",
|
|
20
|
-
invalidSubscription: "Invalid AI live subscription",
|
|
21
|
-
tooManyMessages: "Too many pending AI live messages",
|
|
22
|
-
},
|
|
23
|
-
de: {
|
|
24
|
-
loginRequired: "Anmeldung erforderlich",
|
|
25
|
-
conversationAccessChanged: "Der Zugriff auf die Unterhaltung wurde geändert oder ist abgelaufen",
|
|
26
|
-
capacityExceeded: "Die AI-Updates haben die Kapazität der Verbindung überschritten",
|
|
27
|
-
authorizationRefreshFailed: "Die Zugriffsprüfung für AI Live ist fehlgeschlagen",
|
|
28
|
-
invalidStreamData: "Der AI-Ereignisstrom enthält ungültige Daten",
|
|
29
|
-
eventStreamEnded: "Der AI-Ereignisstrom wurde beendet",
|
|
30
|
-
eventStreamFailed: "Der AI-Ereignisstrom ist fehlgeschlagen",
|
|
31
|
-
subscriptionFailed: "Die Anmeldung für AI Live ist fehlgeschlagen",
|
|
32
|
-
conversationStreamEnded: "Der AI-Unterhaltungsstrom wurde beendet",
|
|
33
|
-
conversationStreamFailed: "Der AI-Unterhaltungsstrom ist fehlgeschlagen",
|
|
34
|
-
liveSubscriptionRequired: "Vor dem Abonnieren eines Durchlaufs ist eine Anmeldung für AI Live erforderlich",
|
|
35
|
-
conversationNotFound: "Die Unterhaltung wurde nicht gefunden",
|
|
36
|
-
invalidJson: "Die JSON-Daten sind ungültig",
|
|
37
|
-
invalidSubscription: "Die Anmeldung für AI Live ist ungültig",
|
|
38
|
-
tooManyMessages: "Zu viele ausstehende Nachrichten für AI Live",
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
export type AiLiveMessages = ReturnType<typeof catalog.resolve>["t"];
|
|
44
|
-
export const aiLiveMessages = (locale?: string | null) => catalog.resolve(locale ? [locale] : []).t;
|
|
45
|
-
export const checkAiLiveMessages = () => catalog.check();
|
package/src/ai/live-outbox.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import type { sql } from "bun";
|
|
2
|
-
import { lazySync } from "../_internal/process-sync";
|
|
3
|
-
import { createPgOutbox } from "../services/outbox";
|
|
4
|
-
import { toPgTextArray } from "../services/postgres";
|
|
5
|
-
import { latestTopicCursor } from "../services/topic-cursor";
|
|
6
|
-
import type { AiInvalidation, AiInvalidationDomain } from "./live-events";
|
|
7
|
-
|
|
8
|
-
const RETENTION_MS = 24 * 60 * 60 * 1_000;
|
|
9
|
-
const RECONCILE_INTERVAL_MS = 1_000;
|
|
10
|
-
const MAX_ATTEMPTS = 20;
|
|
11
|
-
|
|
12
|
-
type SqlClient = typeof sql;
|
|
13
|
-
|
|
14
|
-
export type AiLiveOutboxRow = {
|
|
15
|
-
id: string;
|
|
16
|
-
change_id: string;
|
|
17
|
-
audience_user_id: string;
|
|
18
|
-
conversation_short_id: string | null;
|
|
19
|
-
project_short_id: string | null;
|
|
20
|
-
domains: AiInvalidationDomain[];
|
|
21
|
-
attempts: number;
|
|
22
|
-
created_at: Date | string;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
const invalidationTopic = lazySync((sync) =>
|
|
26
|
-
sync.topic<AiInvalidation>({
|
|
27
|
-
id: "cloud-ai-invalidations",
|
|
28
|
-
owner: "cloud",
|
|
29
|
-
retention: { maxAgeMs: RETENTION_MS, maxBytes: 64 * 1024 * 1024 },
|
|
30
|
-
maxPayloadBytes: 9_024,
|
|
31
|
-
}),
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
const tenantId = (userId: string): string => userId;
|
|
35
|
-
|
|
36
|
-
export const enqueueAiInvalidation = async (
|
|
37
|
-
db: SqlClient,
|
|
38
|
-
input: {
|
|
39
|
-
audienceUserId: string;
|
|
40
|
-
domains: AiInvalidationDomain[];
|
|
41
|
-
conversationId?: string | null;
|
|
42
|
-
projectId?: string | null;
|
|
43
|
-
changeId?: string;
|
|
44
|
-
},
|
|
45
|
-
): Promise<string> => {
|
|
46
|
-
const [row] = await db<{ id: string }[]>`
|
|
47
|
-
INSERT INTO ai.live_invalidation_outbox (
|
|
48
|
-
change_id, audience_user_id, conversation_short_id, project_short_id, domains
|
|
49
|
-
)
|
|
50
|
-
VALUES (
|
|
51
|
-
${input.changeId ?? crypto.randomUUID()}::uuid,
|
|
52
|
-
${input.audienceUserId}::uuid,
|
|
53
|
-
${input.conversationId ?? null},
|
|
54
|
-
${input.projectId ?? null},
|
|
55
|
-
${toPgTextArray([...new Set(input.domains)])}::text[]
|
|
56
|
-
)
|
|
57
|
-
RETURNING id::text
|
|
58
|
-
`;
|
|
59
|
-
if (!row) throw new Error("AI live invalidation insert returned no id");
|
|
60
|
-
return row.id;
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const publishAiInvalidation = (row: AiLiveOutboxRow): Promise<unknown> => {
|
|
64
|
-
const event: AiInvalidation = {
|
|
65
|
-
type: "ai.invalidated",
|
|
66
|
-
changeId: row.change_id,
|
|
67
|
-
conversationId: row.conversation_short_id,
|
|
68
|
-
projectId: row.project_short_id,
|
|
69
|
-
domains: row.domains,
|
|
70
|
-
at: new Date(row.created_at).toISOString(),
|
|
71
|
-
};
|
|
72
|
-
return invalidationTopic().publish({
|
|
73
|
-
tenantId: tenantId(row.audience_user_id),
|
|
74
|
-
orderingKey: row.conversation_short_id ?? row.project_short_id ?? row.audience_user_id,
|
|
75
|
-
idempotencyKey: row.change_id,
|
|
76
|
-
data: event,
|
|
77
|
-
});
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
const outbox = createPgOutbox<AiLiveOutboxRow>({
|
|
81
|
-
table: "ai.live_invalidation_outbox",
|
|
82
|
-
name: "ai:live-outbox",
|
|
83
|
-
publish: publishAiInvalidation,
|
|
84
|
-
reconcileIntervalMs: RECONCILE_INTERVAL_MS,
|
|
85
|
-
orderBy: "audience_user_id",
|
|
86
|
-
maxAttempts: MAX_ATTEMPTS,
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
export const claimAiInvalidationBatch = outbox.claim;
|
|
90
|
-
export const dispatchAiInvalidation = outbox.dispatch;
|
|
91
|
-
export const reconcileAiInvalidations = outbox.reconcile;
|
|
92
|
-
export const notifyAiInvalidations = outbox.notify;
|
|
93
|
-
export const startAiInvalidationRuntime = outbox.start;
|
|
94
|
-
export const stopAiInvalidationRuntime = outbox.stop;
|
|
95
|
-
|
|
96
|
-
export const liveAiInvalidations = (input: { userId: string; after?: string | null; signal?: AbortSignal }) =>
|
|
97
|
-
invalidationTopic()
|
|
98
|
-
.hub({ tenantId: tenantId(input.userId) })
|
|
99
|
-
.subscribe({ after: input.after ?? undefined, signal: input.signal });
|
|
100
|
-
|
|
101
|
-
export const latestAiInvalidationCursor = (userId: string): Promise<string> =>
|
|
102
|
-
latestTopicCursor({ topic: invalidationTopic(), resourceId: "cloud-ai-invalidations", tenantId: tenantId(userId) });
|
package/src/ai/live-routes.ts
DELETED
|
@@ -1,412 +0,0 @@
|
|
|
1
|
-
import type { ServerWebSocket } from "bun";
|
|
2
|
-
import { Hono } from "hono";
|
|
3
|
-
import { upgradeWebSocket } from "hono/bun";
|
|
4
|
-
import { type AuthContext, auth, getLocale } from "../server";
|
|
5
|
-
import { logger } from "../services/logging";
|
|
6
|
-
import {
|
|
7
|
-
AI_LIVE_WS_TYPE,
|
|
8
|
-
type AiInvalidation,
|
|
9
|
-
AiInvalidationSchema,
|
|
10
|
-
AiLiveClientMessageSchema,
|
|
11
|
-
AiLiveCursorSchema,
|
|
12
|
-
type AiLiveErrorCode,
|
|
13
|
-
type AiLiveServerMessage,
|
|
14
|
-
aiTurnEventMessage,
|
|
15
|
-
} from "./live-events";
|
|
16
|
-
import { type AiLiveMessages, aiLiveMessages } from "./live-messages";
|
|
17
|
-
import { latestAiInvalidationCursor, liveAiInvalidations } from "./live-outbox";
|
|
18
|
-
import type { AiStreamEvent } from "./protocol";
|
|
19
|
-
import { aiConversations } from "./store";
|
|
20
|
-
import { streamAiConversationEvents } from "./stream";
|
|
21
|
-
import type { AiConversation } from "./types";
|
|
22
|
-
|
|
23
|
-
const log = logger("ai:live-routes");
|
|
24
|
-
const AUTH_REFRESH_INTERVAL_MS = 5_000;
|
|
25
|
-
const MAX_CLIENT_MESSAGE_LENGTH = 8_000;
|
|
26
|
-
const MAX_PENDING_MESSAGES = 8;
|
|
27
|
-
const MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
|
|
28
|
-
|
|
29
|
-
export const isAiLiveClientMessageFrame = (data: unknown): data is string =>
|
|
30
|
-
typeof data === "string" && data.length <= MAX_CLIENT_MESSAGE_LENGTH;
|
|
31
|
-
|
|
32
|
-
type LiveUser = { id: string };
|
|
33
|
-
type WsPhase = "open" | "subscribed" | "closing";
|
|
34
|
-
|
|
35
|
-
type WsContext = {
|
|
36
|
-
socket: ServerWebSocket<unknown>;
|
|
37
|
-
sessionToken: string | null;
|
|
38
|
-
messages: AiLiveMessages;
|
|
39
|
-
phase: WsPhase;
|
|
40
|
-
userId: string | null;
|
|
41
|
-
invalidationAbort: AbortController | null;
|
|
42
|
-
turnAbort: AbortController | null;
|
|
43
|
-
turnConversationId: string | null;
|
|
44
|
-
authRefreshTimer: ReturnType<typeof setTimeout> | null;
|
|
45
|
-
scopeVersion: string | null;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
export type AiLiveRoutesConfig = {
|
|
49
|
-
resolveLiveUser?: (sessionToken: string | null) => Promise<LiveUser | null>;
|
|
50
|
-
resolveScopeVersion?: (userId: string) => Promise<string>;
|
|
51
|
-
resolveConversation?: (conversationId: string, userId: string) => Promise<AiConversation | null>;
|
|
52
|
-
streamConversation?: (input: { conversation: AiConversation; signal: AbortSignal }) => AsyncIterable<AiStreamEvent>;
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
export const resolveAiLiveSessionUser = async (
|
|
56
|
-
sessionToken: string | null,
|
|
57
|
-
authenticate: typeof auth.session.authenticate = auth.session.authenticate,
|
|
58
|
-
): Promise<LiveUser | null> => {
|
|
59
|
-
if (!sessionToken) return null;
|
|
60
|
-
const authenticated = await authenticate(sessionToken);
|
|
61
|
-
return authenticated ? { id: authenticated.user.id } : null;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const isClosing = (ctx: WsContext): boolean => ctx.phase === "closing";
|
|
65
|
-
|
|
66
|
-
export const isAiLiveSubscriptionCurrent = (state: Pick<WsContext, "phase" | "userId">, userId: string, signal: AbortSignal): boolean =>
|
|
67
|
-
!signal.aborted && state.phase === "subscribed" && state.userId === userId;
|
|
68
|
-
|
|
69
|
-
export const isAiTurnSubscriptionCurrent = (
|
|
70
|
-
state: Pick<WsContext, "phase" | "turnConversationId">,
|
|
71
|
-
conversationId: string,
|
|
72
|
-
signal: AbortSignal,
|
|
73
|
-
): boolean => !signal.aborted && state.phase === "subscribed" && state.turnConversationId === conversationId;
|
|
74
|
-
|
|
75
|
-
export const sendAiLiveMessage = (socket: ServerWebSocket<unknown>, message: AiLiveServerMessage): boolean => {
|
|
76
|
-
try {
|
|
77
|
-
const payload = JSON.stringify(message);
|
|
78
|
-
socket.send(payload);
|
|
79
|
-
// Bun 1.3 can report 0 after a larger frame already reached the peer.
|
|
80
|
-
// The bounded socket queue is the reliable overload signal here.
|
|
81
|
-
return socket.getBufferedAmount() <= MAX_BUFFERED_BYTES;
|
|
82
|
-
} catch {
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const stopInvalidationSubscription = (ctx: WsContext) => {
|
|
88
|
-
ctx.invalidationAbort?.abort();
|
|
89
|
-
ctx.invalidationAbort = null;
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
const stopTurnSubscription = (ctx: WsContext) => {
|
|
93
|
-
ctx.turnAbort?.abort();
|
|
94
|
-
ctx.turnAbort = null;
|
|
95
|
-
ctx.turnConversationId = null;
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const stopSubscriptions = (ctx: WsContext) => {
|
|
99
|
-
stopInvalidationSubscription(ctx);
|
|
100
|
-
stopTurnSubscription(ctx);
|
|
101
|
-
if (ctx.authRefreshTimer) clearTimeout(ctx.authRefreshTimer);
|
|
102
|
-
ctx.authRefreshTimer = null;
|
|
103
|
-
ctx.userId = null;
|
|
104
|
-
ctx.scopeVersion = null;
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
const closeWithError = (ctx: WsContext, code: AiLiveErrorCode, message: string, closeCode: number) => {
|
|
108
|
-
if (isClosing(ctx)) return;
|
|
109
|
-
ctx.phase = "closing";
|
|
110
|
-
stopSubscriptions(ctx);
|
|
111
|
-
sendAiLiveMessage(ctx.socket, { type: AI_LIVE_WS_TYPE.error, payload: { code, message } });
|
|
112
|
-
ctx.socket.close(closeCode, code);
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
const revoke = (ctx: WsContext, code: "login_required" | "access_denied", message: string) => {
|
|
116
|
-
if (isClosing(ctx)) return;
|
|
117
|
-
ctx.phase = "closing";
|
|
118
|
-
stopSubscriptions(ctx);
|
|
119
|
-
sendAiLiveMessage(ctx.socket, { type: AI_LIVE_WS_TYPE.revoked, payload: { code, message } });
|
|
120
|
-
ctx.socket.close(1008, code);
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
export const resolveAiLiveCursor = async (
|
|
124
|
-
userId: string,
|
|
125
|
-
fromCursor: string | null,
|
|
126
|
-
recover: boolean,
|
|
127
|
-
latest: (userId: string) => Promise<string> = latestAiInvalidationCursor,
|
|
128
|
-
): Promise<string> => AiLiveCursorSchema.parse(recover || !fromCursor ? await latest(userId) : fromCursor);
|
|
129
|
-
|
|
130
|
-
export const parseAiLiveReplayEvent = (item: { cursor: unknown; data: unknown }): { cursor: string; event: AiInvalidation } | null => {
|
|
131
|
-
const cursor = AiLiveCursorSchema.safeParse(item.cursor);
|
|
132
|
-
const event = AiInvalidationSchema.safeParse(item.data);
|
|
133
|
-
return cursor.success && event.success ? { cursor: cursor.data, event: event.data } : null;
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
const buildAiLiveRoutes = (config: AiLiveRoutesConfig = {}) => {
|
|
137
|
-
const resolveLiveUser = config.resolveLiveUser ?? resolveAiLiveSessionUser;
|
|
138
|
-
const resolveConversation =
|
|
139
|
-
config.resolveConversation ??
|
|
140
|
-
((conversationId: string, userId: string) =>
|
|
141
|
-
aiConversations.getConversationByShortId({ shortId: conversationId, ownerUserId: userId }));
|
|
142
|
-
const streamConversation = config.streamConversation ?? streamAiConversationEvents;
|
|
143
|
-
|
|
144
|
-
const currentUser = async (ctx: WsContext): Promise<LiveUser | null> => {
|
|
145
|
-
const user = await resolveLiveUser(ctx.sessionToken);
|
|
146
|
-
return user && (!ctx.userId || user.id === ctx.userId) ? user : null;
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const startAuthRefresh = (ctx: WsContext, userId: string) => {
|
|
150
|
-
if (ctx.authRefreshTimer) clearTimeout(ctx.authRefreshTimer);
|
|
151
|
-
ctx.authRefreshTimer = setTimeout(async () => {
|
|
152
|
-
if (ctx.phase !== "subscribed" || ctx.userId !== userId) return;
|
|
153
|
-
try {
|
|
154
|
-
if (!(await currentUser(ctx))) {
|
|
155
|
-
revoke(ctx, "login_required", ctx.messages.loginRequired);
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
const activeConversationId = ctx.turnConversationId;
|
|
159
|
-
if (activeConversationId && !(await resolveConversation(activeConversationId, userId))) {
|
|
160
|
-
if (ctx.turnConversationId !== activeConversationId) {
|
|
161
|
-
startAuthRefresh(ctx, userId);
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
const conversationId = activeConversationId;
|
|
165
|
-
stopTurnSubscription(ctx);
|
|
166
|
-
if (
|
|
167
|
-
!sendAiLiveMessage(ctx.socket, {
|
|
168
|
-
type: AI_LIVE_WS_TYPE.turnError,
|
|
169
|
-
payload: { conversationId, code: "access_denied", message: ctx.messages.conversationAccessChanged },
|
|
170
|
-
})
|
|
171
|
-
) {
|
|
172
|
-
closeWithError(ctx, "backpressure", ctx.messages.capacityExceeded, 1013);
|
|
173
|
-
return;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
if (config.resolveScopeVersion) {
|
|
177
|
-
const version = await config.resolveScopeVersion(userId);
|
|
178
|
-
if (ctx.phase !== "subscribed" || ctx.userId !== userId) return;
|
|
179
|
-
if (ctx.scopeVersion !== null && version !== ctx.scopeVersion) {
|
|
180
|
-
if (!sendAiLiveMessage(ctx.socket, { type: AI_LIVE_WS_TYPE.scopeChanged, payload: { at: new Date().toISOString() } })) {
|
|
181
|
-
closeWithError(ctx, "backpressure", ctx.messages.capacityExceeded, 1013);
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
ctx.scopeVersion = version;
|
|
186
|
-
}
|
|
187
|
-
startAuthRefresh(ctx, userId);
|
|
188
|
-
} catch (error) {
|
|
189
|
-
log.error("AI live authorization refresh failed", {
|
|
190
|
-
userId,
|
|
191
|
-
error: error instanceof Error ? error.message : String(error),
|
|
192
|
-
});
|
|
193
|
-
closeWithError(ctx, "internal_error", ctx.messages.authorizationRefreshFailed, 1011);
|
|
194
|
-
}
|
|
195
|
-
}, AUTH_REFRESH_INTERVAL_MS);
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
const startInvalidationStream = (ctx: WsContext, userId: string, after: string) => {
|
|
199
|
-
ctx.invalidationAbort?.abort();
|
|
200
|
-
const abort = new AbortController();
|
|
201
|
-
ctx.invalidationAbort = abort;
|
|
202
|
-
void (async () => {
|
|
203
|
-
try {
|
|
204
|
-
for await (const item of liveAiInvalidations({ userId, after, signal: abort.signal })) {
|
|
205
|
-
if (!isAiLiveSubscriptionCurrent(ctx, userId, abort.signal)) break;
|
|
206
|
-
if (!(await currentUser(ctx))) {
|
|
207
|
-
revoke(ctx, "login_required", ctx.messages.loginRequired);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
if (!isAiLiveSubscriptionCurrent(ctx, userId, abort.signal)) break;
|
|
211
|
-
const replay = parseAiLiveReplayEvent(item);
|
|
212
|
-
if (!replay) {
|
|
213
|
-
closeWithError(ctx, "stream_failed", ctx.messages.invalidStreamData, 1011);
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
if (!sendAiLiveMessage(ctx.socket, { type: AI_LIVE_WS_TYPE.event, payload: replay })) {
|
|
217
|
-
closeWithError(ctx, "backpressure", ctx.messages.capacityExceeded, 1013);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
if (isAiLiveSubscriptionCurrent(ctx, userId, abort.signal))
|
|
222
|
-
closeWithError(ctx, "stream_failed", ctx.messages.eventStreamEnded, 1012);
|
|
223
|
-
} catch (error) {
|
|
224
|
-
if (abort.signal.aborted || isClosing(ctx)) return;
|
|
225
|
-
log.error("AI live event stream failed", {
|
|
226
|
-
userId,
|
|
227
|
-
error: error instanceof Error ? error.message : String(error),
|
|
228
|
-
});
|
|
229
|
-
closeWithError(ctx, "stream_failed", ctx.messages.eventStreamFailed, 1012);
|
|
230
|
-
} finally {
|
|
231
|
-
if (ctx.invalidationAbort === abort) ctx.invalidationAbort = null;
|
|
232
|
-
}
|
|
233
|
-
})();
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
const handleLiveSubscribe = async (ctx: WsContext, fromCursor: string | null, recover: boolean) => {
|
|
237
|
-
if (isClosing(ctx)) return;
|
|
238
|
-
const user = await currentUser(ctx);
|
|
239
|
-
if (isClosing(ctx)) return;
|
|
240
|
-
if (!user) {
|
|
241
|
-
revoke(ctx, "login_required", ctx.messages.loginRequired);
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
let cursor: string;
|
|
245
|
-
let scopeVersion: string | null;
|
|
246
|
-
try {
|
|
247
|
-
cursor = await resolveAiLiveCursor(user.id, fromCursor, recover);
|
|
248
|
-
scopeVersion = config.resolveScopeVersion ? await config.resolveScopeVersion(user.id) : null;
|
|
249
|
-
} catch (error) {
|
|
250
|
-
log.error("AI live subscription setup failed", {
|
|
251
|
-
userId: user.id,
|
|
252
|
-
error: error instanceof Error ? error.message : String(error),
|
|
253
|
-
});
|
|
254
|
-
closeWithError(ctx, "stream_failed", ctx.messages.subscriptionFailed, 1012);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
if (isClosing(ctx)) return;
|
|
258
|
-
stopInvalidationSubscription(ctx);
|
|
259
|
-
ctx.phase = "subscribed";
|
|
260
|
-
ctx.userId = user.id;
|
|
261
|
-
ctx.scopeVersion = scopeVersion;
|
|
262
|
-
if (!sendAiLiveMessage(ctx.socket, { type: AI_LIVE_WS_TYPE.ready, payload: { cursor, recovered: recover } })) {
|
|
263
|
-
closeWithError(ctx, "backpressure", ctx.messages.capacityExceeded, 1013);
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
startInvalidationStream(ctx, user.id, cursor);
|
|
267
|
-
startAuthRefresh(ctx, user.id);
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
const startTurnStream = (ctx: WsContext, conversation: AiConversation) => {
|
|
271
|
-
stopTurnSubscription(ctx);
|
|
272
|
-
const abort = new AbortController();
|
|
273
|
-
const conversationId = conversation.shortId;
|
|
274
|
-
ctx.turnAbort = abort;
|
|
275
|
-
ctx.turnConversationId = conversationId;
|
|
276
|
-
void (async () => {
|
|
277
|
-
try {
|
|
278
|
-
for await (const event of streamConversation({ conversation, signal: abort.signal })) {
|
|
279
|
-
if (!isAiTurnSubscriptionCurrent(ctx, conversationId, abort.signal)) return;
|
|
280
|
-
if (!sendAiLiveMessage(ctx.socket, aiTurnEventMessage(conversationId, event))) {
|
|
281
|
-
closeWithError(ctx, "backpressure", ctx.messages.capacityExceeded, 1013);
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
if (isAiTurnSubscriptionCurrent(ctx, conversationId, abort.signal)) {
|
|
286
|
-
closeWithError(ctx, "stream_failed", ctx.messages.conversationStreamEnded, 1012);
|
|
287
|
-
}
|
|
288
|
-
} catch (error) {
|
|
289
|
-
if (abort.signal.aborted || isClosing(ctx)) return;
|
|
290
|
-
log.error("AI conversation stream failed", {
|
|
291
|
-
conversationId: conversation.id,
|
|
292
|
-
error: error instanceof Error ? error.message : String(error),
|
|
293
|
-
});
|
|
294
|
-
closeWithError(ctx, "stream_failed", ctx.messages.conversationStreamFailed, 1012);
|
|
295
|
-
} finally {
|
|
296
|
-
if (ctx.turnAbort === abort) {
|
|
297
|
-
ctx.turnAbort = null;
|
|
298
|
-
ctx.turnConversationId = null;
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
})();
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
const handleTurnSubscribe = async (ctx: WsContext, conversationId: string) => {
|
|
305
|
-
if (ctx.phase !== "subscribed" || !ctx.userId) {
|
|
306
|
-
closeWithError(ctx, "invalid_message", ctx.messages.liveSubscriptionRequired, 1008);
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
const conversation = await resolveConversation(conversationId, ctx.userId);
|
|
310
|
-
if (isClosing(ctx)) return;
|
|
311
|
-
if (!conversation) {
|
|
312
|
-
stopTurnSubscription(ctx);
|
|
313
|
-
if (
|
|
314
|
-
!sendAiLiveMessage(ctx.socket, {
|
|
315
|
-
type: AI_LIVE_WS_TYPE.turnError,
|
|
316
|
-
payload: { conversationId, code: "not_found", message: ctx.messages.conversationNotFound },
|
|
317
|
-
})
|
|
318
|
-
) {
|
|
319
|
-
closeWithError(ctx, "backpressure", ctx.messages.capacityExceeded, 1013);
|
|
320
|
-
}
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
startTurnStream(ctx, conversation);
|
|
324
|
-
};
|
|
325
|
-
|
|
326
|
-
const handleTurnUnsubscribe = (ctx: WsContext, conversationId: string) => {
|
|
327
|
-
if (ctx.turnConversationId === conversationId) stopTurnSubscription(ctx);
|
|
328
|
-
};
|
|
329
|
-
|
|
330
|
-
const handleMessage = async (ctx: WsContext, raw: string) => {
|
|
331
|
-
let parsed: unknown;
|
|
332
|
-
try {
|
|
333
|
-
parsed = JSON.parse(raw);
|
|
334
|
-
} catch {
|
|
335
|
-
closeWithError(ctx, "invalid_json", ctx.messages.invalidJson, 1008);
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
const message = AiLiveClientMessageSchema.safeParse(parsed);
|
|
339
|
-
if (!message.success) {
|
|
340
|
-
closeWithError(ctx, "invalid_message", ctx.messages.invalidSubscription, 1008);
|
|
341
|
-
return;
|
|
342
|
-
}
|
|
343
|
-
if (message.data.type === AI_LIVE_WS_TYPE.subscribe) {
|
|
344
|
-
await handleLiveSubscribe(ctx, message.data.payload.fromCursor, message.data.payload.recover);
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
if (message.data.type === AI_LIVE_WS_TYPE.turnSubscribe) {
|
|
348
|
-
await handleTurnSubscribe(ctx, message.data.payload.conversationId);
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
handleTurnUnsubscribe(ctx, message.data.payload.conversationId);
|
|
352
|
-
};
|
|
353
|
-
|
|
354
|
-
return new Hono<AuthContext>().get(
|
|
355
|
-
"/",
|
|
356
|
-
upgradeWebSocket(async (c) => {
|
|
357
|
-
const sessionToken = await auth.session.resolveToken(c);
|
|
358
|
-
const messages = aiLiveMessages(getLocale(c));
|
|
359
|
-
let ctx: WsContext | null = null;
|
|
360
|
-
let processing: Promise<void> = Promise.resolve();
|
|
361
|
-
let pendingMessages = 0;
|
|
362
|
-
return {
|
|
363
|
-
onOpen(_, ws) {
|
|
364
|
-
ctx = {
|
|
365
|
-
socket: ws.raw as ServerWebSocket<unknown>,
|
|
366
|
-
sessionToken,
|
|
367
|
-
messages,
|
|
368
|
-
phase: "open",
|
|
369
|
-
userId: null,
|
|
370
|
-
invalidationAbort: null,
|
|
371
|
-
turnAbort: null,
|
|
372
|
-
turnConversationId: null,
|
|
373
|
-
authRefreshTimer: null,
|
|
374
|
-
scopeVersion: null,
|
|
375
|
-
};
|
|
376
|
-
},
|
|
377
|
-
onMessage(event) {
|
|
378
|
-
if (!ctx || isClosing(ctx)) return;
|
|
379
|
-
if (!isAiLiveClientMessageFrame(event.data)) {
|
|
380
|
-
closeWithError(ctx, "invalid_message", ctx.messages.invalidSubscription, 1008);
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
if (pendingMessages >= MAX_PENDING_MESSAGES) {
|
|
384
|
-
closeWithError(ctx, "backpressure", ctx.messages.tooManyMessages, 1013);
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
const current = ctx;
|
|
388
|
-
const raw = event.data;
|
|
389
|
-
pendingMessages++;
|
|
390
|
-
processing = processing
|
|
391
|
-
.then(() => handleMessage(current, raw))
|
|
392
|
-
.catch((error) => {
|
|
393
|
-
log.error("AI live message handling failed", { error: error instanceof Error ? error.message : String(error) });
|
|
394
|
-
closeWithError(current, "internal_error", current.messages.subscriptionFailed, 1011);
|
|
395
|
-
})
|
|
396
|
-
.finally(() => {
|
|
397
|
-
pendingMessages = Math.max(0, pendingMessages - 1);
|
|
398
|
-
});
|
|
399
|
-
},
|
|
400
|
-
async onClose() {
|
|
401
|
-
if (!ctx) return;
|
|
402
|
-
ctx.phase = "closing";
|
|
403
|
-
stopSubscriptions(ctx);
|
|
404
|
-
await processing.catch(() => undefined);
|
|
405
|
-
},
|
|
406
|
-
};
|
|
407
|
-
}),
|
|
408
|
-
);
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
export const aiLiveRoutes = buildAiLiveRoutes();
|
|
412
|
-
export type AiLiveRoutes = typeof aiLiveRoutes;
|