@k2b/cloud 0.26.0 → 0.28.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 -3
- package/src/_internal/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/browser-code-contracts.ts +46 -64
- package/src/ai/browser.ts +9 -1
- package/src/ai/capabilities.ts +82 -22
- package/src/ai/chat/blocks.tsx +16 -7
- package/src/ai/chat/builtin-tools.tsx +31 -23
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +15 -5
- package/src/ai/chat/message-actions.tsx +123 -101
- package/src/ai/chat/message-utils.ts +30 -1
- package/src/ai/chat/messages.ts +206 -2
- package/src/ai/chat/presentation.tsx +94 -28
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-view.tsx +102 -16
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/client/controller.ts +83 -37
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +7 -2
- package/src/ai/code-mode-skill.ts +32 -36
- package/src/ai/code-runtime-tools.ts +14 -1
- package/src/ai/code-source-contracts.ts +56 -6
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +15 -16
- package/src/ai/executor.ts +281 -141
- package/src/ai/file-context.ts +14 -2
- package/src/ai/file-tools.ts +17 -3
- package/src/ai/files-store.ts +134 -11
- package/src/ai/grids-skill.ts +2 -2
- package/src/ai/index.ts +9 -0
- package/src/ai/memories.ts +14 -0
- package/src/ai/migrate.ts +125 -0
- package/src/ai/model-request-settings.ts +98 -0
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/protocol.ts +6 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +2 -2
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +40 -6
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +9 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +35 -7
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +155 -71
- package/src/ai/stream.ts +180 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +8 -0
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-failure.ts +100 -0
- package/src/ai/turn-policy.ts +248 -0
- package/src/ai/types.ts +52 -4
- package/src/api/admin-ai-quotas.ts +36 -1
- package/src/api/admin-core-settings.ts +16 -23
- package/src/api/admin-outgoing-mail.ts +242 -0
- package/src/api/index.ts +2 -0
- package/src/browser/FileChooser.tsx +11 -0
- package/src/browser/file-chooser-messages.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +8 -0
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +215 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +265 -0
- package/src/contracts/registry.ts +2 -0
- package/src/services/help/store.ts +2 -1
- package/src/services/index.ts +13 -0
- package/src/services/notifications/batches.ts +139 -79
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +57 -5
- package/src/services/notifications/email-frame.fixture.html +51 -0
- package/src/services/notifications/{email.ts → email-frame.ts} +12 -45
- package/src/services/notifications/email-mail.ts +101 -0
- package/src/services/notifications/index.ts +49 -36
- package/src/services/notifications/observability.ts +9 -1
- package/src/services/notifications/platform.ts +1 -1
- package/src/services/notifications/runtime.ts +9 -3
- package/src/services/outgoing-mail/admin.ts +84 -0
- package/src/services/outgoing-mail/attachments.ts +135 -0
- package/src/services/outgoing-mail/bulk.ts +11 -0
- package/src/services/outgoing-mail/dispatcher.ts +231 -0
- package/src/services/outgoing-mail/drain.ts +60 -0
- package/src/services/outgoing-mail/enqueue.ts +180 -0
- package/src/services/outgoing-mail/index.ts +112 -0
- package/src/services/outgoing-mail/message.ts +14 -0
- package/src/services/outgoing-mail/messages.ts +431 -0
- package/src/services/outgoing-mail/retention.ts +48 -0
- package/src/services/outgoing-mail/runtime.ts +72 -0
- package/src/services/outgoing-mail/send.ts +130 -0
- package/src/services/outgoing-mail/store.ts +397 -0
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +17 -0
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/core-settings.ts +19 -38
- package/src/services/settings/store.ts +5 -1
- package/src/shared/ai-model-request-settings.ts +21 -0
- package/src/shared/ai-platform-prompt.ts +49 -5
- package/src/shared/ai-request-options.ts +185 -0
- package/src/shared/markdown/extensions/links.ts +28 -20
- package/src/shared/markdown/index.ts +14 -5
- package/src/shared/markdown/shared.ts +0 -7
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +3 -1
- package/src/ssr/workspace-navigation.ts +7 -1
- package/src/styles/effects.css +15 -17
- package/src/styles/tokens.css +2 -0
- package/src/styles/utilities-markdown-editor.css +4 -39
- package/src/styles/utilities-markdown-table.css +14 -17
package/src/ai/solid.ts
CHANGED
|
@@ -6,7 +6,7 @@ export {
|
|
|
6
6
|
type CreateAiChatControllerOptions,
|
|
7
7
|
createAiChatController,
|
|
8
8
|
} from "./client/controller";
|
|
9
|
-
export { conversationFileSource } from "./client/file-source";
|
|
9
|
+
export { type ConversationFileSource, conversationFileSource } from "./client/file-source";
|
|
10
10
|
export {
|
|
11
11
|
type AiActiveTurn,
|
|
12
12
|
type AiChatProjection,
|
package/src/ai/store.ts
CHANGED
|
@@ -4,8 +4,10 @@ import { type SQL, sql } from "bun";
|
|
|
4
4
|
import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
|
|
5
5
|
import { logger } from "../services/logging";
|
|
6
6
|
import { toPgTextArray } from "../services/postgres";
|
|
7
|
+
import { aiTurnErrorText } from "./chat/turn-error";
|
|
7
8
|
import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
|
|
8
9
|
import type { AiTurnBlock } from "./protocol";
|
|
10
|
+
import { AiRunTimeout } from "./run-timeout";
|
|
9
11
|
import { withAiShortId, withAiShortIdForDb } from "./short-id";
|
|
10
12
|
import { parseAiTodoPlan } from "./todo-contracts";
|
|
11
13
|
import { activeTurnWaits } from "./turn-timing";
|
|
@@ -29,6 +31,7 @@ import type {
|
|
|
29
31
|
AiToolPresentation,
|
|
30
32
|
AiTurn,
|
|
31
33
|
AiTurnClaim,
|
|
34
|
+
AiTurnError,
|
|
32
35
|
AiTurnRunConfig,
|
|
33
36
|
AiTurnStatus,
|
|
34
37
|
AiTurnSteer,
|
|
@@ -112,6 +115,7 @@ type ConversationRow = {
|
|
|
112
115
|
latest_browser_pending?: boolean;
|
|
113
116
|
latest_turn_error?: string | null;
|
|
114
117
|
latest_turn_completed_at?: Date | string | null;
|
|
118
|
+
latest_turn_short_id?: string | null;
|
|
115
119
|
enrich_fail_count: number | null;
|
|
116
120
|
project_id: string | null;
|
|
117
121
|
draft_content: unknown;
|
|
@@ -304,10 +308,10 @@ const rowToConversationResourceRef = (row: ConversationResourceRefRow): AiConver
|
|
|
304
308
|
});
|
|
305
309
|
|
|
306
310
|
type ResourceCursor = { at: string; type: string; id: string };
|
|
307
|
-
const encodeResourceCursor = (row: ConversationResourceRefRow): string =>
|
|
308
|
-
encodeURIComponent(JSON.stringify({ at:
|
|
309
|
-
const encodeSourceCursor = (row: ConversationSourceRow): string =>
|
|
310
|
-
encodeURIComponent(JSON.stringify({ at:
|
|
311
|
+
const encodeResourceCursor = (row: ConversationResourceRefRow & { cursor_at: string }): string =>
|
|
312
|
+
encodeURIComponent(JSON.stringify({ at: row.cursor_at, type: row.resource_type, id: row.resource_id } satisfies ResourceCursor));
|
|
313
|
+
const encodeSourceCursor = (row: ConversationSourceRow & { cursor_at: string }): string =>
|
|
314
|
+
encodeURIComponent(JSON.stringify({ at: row.cursor_at, type: row.source_kind, id: row.source_key } satisfies ResourceCursor));
|
|
311
315
|
const decodeResourceCursor = (value: string | undefined): ResourceCursor | null => {
|
|
312
316
|
if (!value) return null;
|
|
313
317
|
try {
|
|
@@ -348,6 +352,7 @@ const rowToConversationSource = (row: ConversationSourceRow): AiConversationSour
|
|
|
348
352
|
lastSeenAt: iso(row.last_seen_at),
|
|
349
353
|
sourceTurnId: row.source_turn_id,
|
|
350
354
|
sourceCallId: row.source_call_id,
|
|
355
|
+
sourceMessageSeq: null,
|
|
351
356
|
});
|
|
352
357
|
|
|
353
358
|
const rowToInterChatMessage = (row: InterChatMessageRow): AiInterChatMessage => ({
|
|
@@ -389,10 +394,10 @@ const interChatMessageSelect = () => sql`
|
|
|
389
394
|
`;
|
|
390
395
|
|
|
391
396
|
type ResourceOccurrenceCursor = ResourceCursor & { chat: string };
|
|
392
|
-
const encodeResourceOccurrenceCursor = (row: ConversationResourceOccurrenceRow): string =>
|
|
397
|
+
const encodeResourceOccurrenceCursor = (row: ConversationResourceOccurrenceRow & { cursor_at: string }): string =>
|
|
393
398
|
encodeURIComponent(
|
|
394
399
|
JSON.stringify({
|
|
395
|
-
at:
|
|
400
|
+
at: row.cursor_at,
|
|
396
401
|
type: row.resource_type,
|
|
397
402
|
id: row.resource_id,
|
|
398
403
|
chat: row.conversation_short_id,
|
|
@@ -551,6 +556,7 @@ const rowToConversation = (row: ConversationRow): AiConversation => ({
|
|
|
551
556
|
lastUsedAt: iso(row.last_used_at),
|
|
552
557
|
runStatus: conversationRunStatus(row.latest_turn_status, row.latest_browser_pending),
|
|
553
558
|
runError: row.latest_turn_status === "failed" ? row.latest_turn_error?.trim() || "Assistant response failed." : null,
|
|
559
|
+
runTurnId: row.latest_turn_short_id ?? null,
|
|
554
560
|
unreadCompletion:
|
|
555
561
|
Boolean(
|
|
556
562
|
row.background_received_at &&
|
|
@@ -722,10 +728,10 @@ const loadConversationSummary = async (input: {
|
|
|
722
728
|
latest.status AS latest_turn_status,
|
|
723
729
|
${browserWorkPending()} AS latest_browser_pending,
|
|
724
730
|
latest.error AS latest_turn_error,
|
|
725
|
-
latest.completed_at AS latest_turn_completed_at
|
|
731
|
+
latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id
|
|
726
732
|
FROM ai.conversations conversation
|
|
727
733
|
LEFT JOIN LATERAL (
|
|
728
|
-
SELECT status, error, completed_at, live_blocks
|
|
734
|
+
SELECT short_id, status, error, completed_at, live_blocks
|
|
729
735
|
FROM ai.turns
|
|
730
736
|
WHERE conversation_id = conversation.id AND NOT COALESCE(run_config ? 'background', false)
|
|
731
737
|
ORDER BY created_at DESC, id DESC
|
|
@@ -783,13 +789,30 @@ const messageSearchText = (message: Message): string => {
|
|
|
783
789
|
* time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
|
|
784
790
|
* never looks finished. A failure replaces the `aborted` that a loop cut off by its run time limit recorded, so the
|
|
785
791
|
* limit never looks like a user stop. A call the user approved that never returned keeps its approval in history.
|
|
792
|
+
* A failure's reason goes to the turn's last message, also when that is the user's own, so history can say why the
|
|
793
|
+
* turn failed and what to do next.
|
|
786
794
|
*/
|
|
787
795
|
const recordTurnEnd = async (
|
|
788
796
|
db: typeof sql,
|
|
789
|
-
input: { conversationId: string; turnId: string; reason: "aborted" | "error" },
|
|
797
|
+
input: { conversationId: string; turnId: string; reason: "aborted" | "error"; turnError?: AiTurnError | null },
|
|
790
798
|
): Promise<void> => {
|
|
791
799
|
const replaces = input.reason === "error" ? "aborted" : null;
|
|
792
800
|
for (const table of ["ai.messages", "ai.task_messages"]) {
|
|
801
|
+
if (input.turnError) {
|
|
802
|
+
await db`
|
|
803
|
+
UPDATE ${db(table)}
|
|
804
|
+
SET meta = COALESCE(meta, '{}'::jsonb) || jsonb_build_object('turnError', ${JSON.stringify(input.turnError)}::text::jsonb)
|
|
805
|
+
WHERE id = (
|
|
806
|
+
SELECT id
|
|
807
|
+
FROM ${db(table)}
|
|
808
|
+
WHERE conversation_id = ${input.conversationId}
|
|
809
|
+
AND loop_id = ${input.turnId}::text
|
|
810
|
+
AND kind = 'message'
|
|
811
|
+
ORDER BY seq DESC
|
|
812
|
+
LIMIT 1
|
|
813
|
+
)
|
|
814
|
+
`;
|
|
815
|
+
}
|
|
793
816
|
await db`
|
|
794
817
|
UPDATE ${db(table)}
|
|
795
818
|
SET loop_done_reason = ${input.reason}
|
|
@@ -1183,10 +1206,10 @@ export const aiConversations: AiConversationService = {
|
|
|
1183
1206
|
latest.status AS latest_turn_status,
|
|
1184
1207
|
${browserWorkPending()} AS latest_browser_pending,
|
|
1185
1208
|
latest.error AS latest_turn_error,
|
|
1186
|
-
latest.completed_at AS latest_turn_completed_at
|
|
1209
|
+
latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id
|
|
1187
1210
|
FROM ai.conversations conversation
|
|
1188
1211
|
LEFT JOIN LATERAL (
|
|
1189
|
-
SELECT status, error, completed_at, live_blocks
|
|
1212
|
+
SELECT short_id, status, error, completed_at, live_blocks
|
|
1190
1213
|
FROM ai.turns
|
|
1191
1214
|
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
|
|
1192
1215
|
ORDER BY created_at DESC, id DESC
|
|
@@ -1239,12 +1262,12 @@ export const aiConversations: AiConversationService = {
|
|
|
1239
1262
|
SELECT conversation.*, ${effectiveDone()} AS is_done,
|
|
1240
1263
|
EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
|
|
1241
1264
|
latest.status AS latest_turn_status, ${browserWorkPending()} AS latest_browser_pending,
|
|
1242
|
-
latest.error AS latest_turn_error, latest.completed_at AS latest_turn_completed_at,
|
|
1265
|
+
latest.error AS latest_turn_error, latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id,
|
|
1243
1266
|
jsonb_build_object('completed', COALESCE(progress.completed,0), 'total', COALESCE(progress.total,0),
|
|
1244
1267
|
'step', progress.step, 'tool', ai.sidebar_tool_label(latest.live_blocks)->>'label') AS activity
|
|
1245
1268
|
FROM ai.conversations conversation
|
|
1246
1269
|
LEFT JOIN LATERAL (
|
|
1247
|
-
SELECT status, error, completed_at, live_blocks FROM ai.turns
|
|
1270
|
+
SELECT short_id, status, error, completed_at, live_blocks FROM ai.turns
|
|
1248
1271
|
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id ORDER BY created_at DESC, id DESC LIMIT 1
|
|
1249
1272
|
) latest ON TRUE
|
|
1250
1273
|
LEFT JOIN LATERAL (
|
|
@@ -1286,10 +1309,10 @@ export const aiConversations: AiConversationService = {
|
|
|
1286
1309
|
latest.status AS latest_turn_status,
|
|
1287
1310
|
${browserWorkPending()} AS latest_browser_pending,
|
|
1288
1311
|
latest.error AS latest_turn_error,
|
|
1289
|
-
latest.completed_at AS latest_turn_completed_at
|
|
1312
|
+
latest.completed_at AS latest_turn_completed_at, latest.short_id AS latest_turn_short_id
|
|
1290
1313
|
FROM ai.conversations conversation
|
|
1291
1314
|
LEFT JOIN LATERAL (
|
|
1292
|
-
SELECT status, error, completed_at, live_blocks
|
|
1315
|
+
SELECT short_id, status, error, completed_at, live_blocks
|
|
1293
1316
|
FROM ai.turns
|
|
1294
1317
|
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
|
|
1295
1318
|
ORDER BY created_at DESC, id DESC
|
|
@@ -1440,9 +1463,19 @@ export const aiConversations: AiConversationService = {
|
|
|
1440
1463
|
preview = COALESCE(EXCLUDED.preview, ai.conversation_resource_refs.preview),
|
|
1441
1464
|
icon = COALESCE(EXCLUDED.icon, ai.conversation_resource_refs.icon),
|
|
1442
1465
|
href = COALESCE(EXCLUDED.href, ai.conversation_resource_refs.href),
|
|
1443
|
-
|
|
1466
|
+
-- Project context is indexed on every turn without a call. It must not move a resource a tool call
|
|
1467
|
+
-- read to the latest turn, or the chat would show it as read just now.
|
|
1468
|
+
source_turn_id = CASE
|
|
1469
|
+
WHEN EXCLUDED.source_call_id IS NULL AND ai.conversation_resource_refs.source_call_id IS NOT NULL
|
|
1470
|
+
THEN ai.conversation_resource_refs.source_turn_id
|
|
1471
|
+
ELSE COALESCE(EXCLUDED.source_turn_id, ai.conversation_resource_refs.source_turn_id)
|
|
1472
|
+
END,
|
|
1444
1473
|
source_call_id = COALESCE(EXCLUDED.source_call_id, ai.conversation_resource_refs.source_call_id),
|
|
1445
|
-
last_seen_at =
|
|
1474
|
+
last_seen_at = CASE
|
|
1475
|
+
WHEN EXCLUDED.source_call_id IS NULL AND ai.conversation_resource_refs.source_call_id IS NOT NULL
|
|
1476
|
+
THEN ai.conversation_resource_refs.last_seen_at
|
|
1477
|
+
ELSE now()
|
|
1478
|
+
END
|
|
1446
1479
|
`;
|
|
1447
1480
|
}
|
|
1448
1481
|
});
|
|
@@ -1452,9 +1485,10 @@ export const aiConversations: AiConversationService = {
|
|
|
1452
1485
|
const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
|
|
1453
1486
|
const pattern = searchPattern(input.search);
|
|
1454
1487
|
const cursor = decodeResourceCursor(input.before);
|
|
1455
|
-
const rows = await sql<ConversationResourceRefRow[]>`
|
|
1488
|
+
const rows = await sql<(ConversationResourceRefRow & { cursor_at: string })[]>`
|
|
1456
1489
|
SELECT indexed.resource_type, indexed.resource_id, indexed.title, indexed.preview, indexed.icon, indexed.href,
|
|
1457
|
-
source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at
|
|
1490
|
+
source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at,
|
|
1491
|
+
to_char(indexed.last_seen_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_at
|
|
1458
1492
|
FROM ai.conversation_resource_refs indexed
|
|
1459
1493
|
LEFT JOIN ai.turns source_turn ON source_turn.id = indexed.source_turn_id
|
|
1460
1494
|
WHERE indexed.conversation_id = ${input.conversationId}::uuid
|
|
@@ -1475,11 +1509,12 @@ export const aiConversations: AiConversationService = {
|
|
|
1475
1509
|
const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
|
|
1476
1510
|
const pattern = searchPattern(input.search);
|
|
1477
1511
|
const cursor = decodeResourceOccurrenceCursor(input.before);
|
|
1478
|
-
const rows = await sql<ConversationResourceOccurrenceRow[]>`
|
|
1512
|
+
const rows = await sql<(ConversationResourceOccurrenceRow & { cursor_at: string })[]>`
|
|
1479
1513
|
SELECT indexed.resource_type, indexed.resource_id, indexed.title, indexed.preview, indexed.icon, indexed.href,
|
|
1480
1514
|
source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at,
|
|
1481
1515
|
conversation.short_id AS conversation_short_id, conversation.title AS conversation_title,
|
|
1482
|
-
conversation.updated_at AS conversation_updated_at
|
|
1516
|
+
conversation.updated_at AS conversation_updated_at,
|
|
1517
|
+
to_char(indexed.last_seen_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_at
|
|
1483
1518
|
FROM ai.conversation_resource_refs indexed
|
|
1484
1519
|
JOIN ai.conversations conversation ON conversation.id = indexed.conversation_id
|
|
1485
1520
|
LEFT JOIN ai.turns source_turn ON source_turn.id = indexed.source_turn_id
|
|
@@ -1502,17 +1537,21 @@ export const aiConversations: AiConversationService = {
|
|
|
1502
1537
|
const source = input.source;
|
|
1503
1538
|
await sql`
|
|
1504
1539
|
INSERT INTO ai.conversation_sources (
|
|
1505
|
-
conversation_id, kind, source_key, title, preview, icon, href, source_turn_id, source_call_id
|
|
1540
|
+
conversation_id, kind, source_key, title, preview, icon, href, resource_type, resource_id, source_turn_id, source_call_id
|
|
1506
1541
|
) VALUES (
|
|
1507
1542
|
${input.conversationId}::uuid, ${source.kind}, ${source.key}, ${source.title}, ${source.preview ?? null},
|
|
1508
|
-
${source.icon ?? null}, ${source.href ?? null}, ${
|
|
1543
|
+
${source.icon ?? null}, ${source.href ?? null}, ${source.ref?.type ?? null}, ${source.ref?.id ?? null},
|
|
1544
|
+
${input.turnId ?? null}::uuid, ${input.callId ?? null}
|
|
1509
1545
|
)
|
|
1510
1546
|
ON CONFLICT (conversation_id, kind, source_key)
|
|
1511
1547
|
DO UPDATE SET
|
|
1512
1548
|
title = EXCLUDED.title,
|
|
1513
|
-
|
|
1549
|
+
-- A new delivery describes the result anew; an old description must not outlive changed content.
|
|
1550
|
+
preview = CASE WHEN EXCLUDED.kind = 'result' THEN EXCLUDED.preview ELSE COALESCE(EXCLUDED.preview, ai.conversation_sources.preview) END,
|
|
1514
1551
|
icon = COALESCE(EXCLUDED.icon, ai.conversation_sources.icon),
|
|
1515
1552
|
href = COALESCE(EXCLUDED.href, ai.conversation_sources.href),
|
|
1553
|
+
resource_type = COALESCE(EXCLUDED.resource_type, ai.conversation_sources.resource_type),
|
|
1554
|
+
resource_id = COALESCE(EXCLUDED.resource_id, ai.conversation_sources.resource_id),
|
|
1516
1555
|
occurrences = ai.conversation_sources.occurrences +
|
|
1517
1556
|
CASE WHEN ai.conversation_sources.source_call_id IS DISTINCT FROM EXCLUDED.source_call_id THEN 1 ELSE 0 END,
|
|
1518
1557
|
source_turn_id = COALESCE(EXCLUDED.source_turn_id, ai.conversation_sources.source_turn_id),
|
|
@@ -1525,50 +1564,85 @@ export const aiConversations: AiConversationService = {
|
|
|
1525
1564
|
const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
|
|
1526
1565
|
const pattern = searchPattern(input.search);
|
|
1527
1566
|
const cursor = decodeResourceCursor(input.before);
|
|
1528
|
-
const
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1567
|
+
const kinds = input.kinds?.length ? toPgTextArray([...input.kinds]) : null;
|
|
1568
|
+
const observed = input.observed === true;
|
|
1569
|
+
const rows = await sql<(ConversationSourceRow & { total: number | string; source_message_seq: number | null; cursor_at: string })[]>`
|
|
1570
|
+
WITH matched AS (
|
|
1571
|
+
SELECT *
|
|
1572
|
+
FROM (
|
|
1573
|
+
SELECT source.kind AS source_kind, source.source_key, source.title, source.preview,
|
|
1574
|
+
source.icon, source.href, file.path, file.media_type, file.size,
|
|
1575
|
+
source.resource_type, source.resource_id, source.occurrences,
|
|
1576
|
+
turn.short_id AS source_turn_id, source.source_turn_id AS turn_uuid, source.source_call_id,
|
|
1577
|
+
source.first_seen_at, source.last_seen_at
|
|
1578
|
+
FROM ai.conversation_sources source
|
|
1579
|
+
LEFT JOIN ai.turns turn ON turn.id = source.source_turn_id
|
|
1580
|
+
LEFT JOIN ai.files file
|
|
1581
|
+
ON source.kind = 'result' AND file.conversation_id = source.conversation_id AND file.path = source.source_key
|
|
1582
|
+
WHERE source.conversation_id = ${input.conversationId}::uuid
|
|
1583
|
+
|
|
1584
|
+
UNION ALL
|
|
1585
|
+
|
|
1586
|
+
SELECT 'file' AS source_kind, file.path AS source_key,
|
|
1587
|
+
regexp_replace(file.path, '^.*/', '') AS title, 'Attached to the conversation' AS preview,
|
|
1588
|
+
CASE WHEN file.media_type LIKE 'image/%' THEN 'ti ti-photo' ELSE 'ti ti-file' END AS icon,
|
|
1589
|
+
NULL::text AS href, file.path, file.media_type, file.size,
|
|
1590
|
+
NULL::text AS resource_type, NULL::text AS resource_id, 1 AS occurrences,
|
|
1591
|
+
NULL::text AS source_turn_id, NULL::uuid AS turn_uuid, NULL::text AS source_call_id,
|
|
1592
|
+
file.updated_at AS first_seen_at, file.updated_at AS last_seen_at
|
|
1593
|
+
FROM ai.files file
|
|
1594
|
+
WHERE file.conversation_id = ${input.conversationId}::uuid
|
|
1595
|
+
|
|
1596
|
+
UNION ALL
|
|
1597
|
+
|
|
1598
|
+
SELECT 'resource' AS source_kind, indexed.resource_type || ':' || indexed.resource_id AS source_key,
|
|
1599
|
+
COALESCE(indexed.title, indexed.resource_type || ' ' || indexed.resource_id) AS title,
|
|
1600
|
+
indexed.preview, indexed.icon, indexed.href, NULL::text AS path, NULL::text AS media_type, NULL::bigint AS size,
|
|
1601
|
+
indexed.resource_type, indexed.resource_id, 1 AS occurrences,
|
|
1602
|
+
turn.short_id AS source_turn_id, indexed.source_turn_id AS turn_uuid, indexed.source_call_id,
|
|
1603
|
+
indexed.first_seen_at, indexed.last_seen_at
|
|
1604
|
+
FROM ai.conversation_resource_refs indexed
|
|
1605
|
+
LEFT JOIN ai.turns turn ON turn.id = indexed.source_turn_id
|
|
1606
|
+
WHERE indexed.conversation_id = ${input.conversationId}::uuid
|
|
1607
|
+
) source
|
|
1608
|
+
WHERE (${kinds}::text[] IS NULL OR source.source_kind = ANY(${kinds}::text[]))
|
|
1609
|
+
AND (NOT ${observed} OR source.source_kind <> 'resource' OR source.source_call_id IS NOT NULL)
|
|
1610
|
+
AND (${pattern}::text IS NULL OR LOWER(source.title || ' ' || COALESCE(source.preview, '') || ' ' || source.source_key) LIKE ${pattern})
|
|
1611
|
+
),
|
|
1612
|
+
page AS (
|
|
1613
|
+
SELECT *
|
|
1614
|
+
FROM matched source
|
|
1615
|
+
WHERE ${cursor?.at ?? null}::timestamptz IS NULL OR (source.last_seen_at, source.source_kind, source.source_key) <
|
|
1616
|
+
(${cursor?.at ?? null}::timestamptz, ${cursor?.type ?? null}::text, ${cursor?.id ?? null}::text)
|
|
1617
|
+
ORDER BY source.last_seen_at DESC, source.source_kind DESC, source.source_key DESC
|
|
1618
|
+
LIMIT ${limit + 1}
|
|
1619
|
+
)
|
|
1620
|
+
SELECT page.*, to_char(page.last_seen_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_at, totals.total,
|
|
1621
|
+
COALESCE(
|
|
1622
|
+
(SELECT message.seq FROM ai.messages message
|
|
1623
|
+
WHERE message.conversation_id = ${input.conversationId}::uuid AND message.loop_id = page.turn_uuid::text
|
|
1624
|
+
AND message.role = 'assistant' AND page.source_call_id IS NOT NULL
|
|
1625
|
+
AND (CASE WHEN jsonb_typeof(message.message) = 'string' THEN (message.message #>> '{}')::jsonb ELSE message.message END) -> 'content'
|
|
1626
|
+
@> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', page.source_call_id))
|
|
1627
|
+
ORDER BY message.seq ASC
|
|
1628
|
+
LIMIT 1),
|
|
1629
|
+
(SELECT min(message.seq) FROM ai.messages message
|
|
1630
|
+
WHERE message.conversation_id = ${input.conversationId}::uuid AND message.loop_id = page.turn_uuid::text)
|
|
1631
|
+
) AS source_message_seq
|
|
1632
|
+
FROM (SELECT count(*) AS total FROM matched) totals
|
|
1633
|
+
LEFT JOIN page ON true
|
|
1634
|
+
ORDER BY page.last_seen_at DESC, page.source_kind DESC, page.source_key DESC
|
|
1635
|
+
`;
|
|
1636
|
+
// One row carries the total even when the page is empty.
|
|
1637
|
+
const total = Number(rows[0]?.total ?? 0);
|
|
1638
|
+
const page = rows.filter((row) => row.source_kind !== null).slice(0, limit);
|
|
1569
1639
|
return {
|
|
1570
|
-
sources: page.map(
|
|
1640
|
+
sources: page.map((row) => ({
|
|
1641
|
+
...rowToConversationSource(row),
|
|
1642
|
+
sourceMessageSeq: row.source_message_seq === null ? null : Number(row.source_message_seq),
|
|
1643
|
+
})),
|
|
1571
1644
|
...(rows.length > limit && page.at(-1) ? { nextCursor: encodeSourceCursor(page.at(-1)!) } : {}),
|
|
1645
|
+
total,
|
|
1572
1646
|
};
|
|
1573
1647
|
},
|
|
1574
1648
|
|
|
@@ -2905,6 +2979,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2905
2979
|
conversationId: input.conversationId,
|
|
2906
2980
|
turnId: input.turnId,
|
|
2907
2981
|
reason: input.status === "aborted" ? "aborted" : "error",
|
|
2982
|
+
turnError: input.status === "failed" ? (input.turnError ?? { code: "failed" }) : null,
|
|
2908
2983
|
});
|
|
2909
2984
|
await tx`
|
|
2910
2985
|
UPDATE ai.turn_steers
|
|
@@ -2925,11 +3000,12 @@ export const aiConversations: AiConversationService = {
|
|
|
2925
3000
|
// 1) Finalize turns that exhausted actual recovery attempts. Resuming a
|
|
2926
3001
|
// resolved user/frontend action is normal progress and does not consume
|
|
2927
3002
|
// this budget.
|
|
3003
|
+
// The sweep does not know a turn's language; the chat words the recorded reason in the reader's.
|
|
2928
3004
|
const exhaustedRows = await sql<{ id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string }[]>`
|
|
2929
3005
|
UPDATE ai.turns
|
|
2930
3006
|
SET status = 'failed',
|
|
2931
3007
|
completed_at = now(),
|
|
2932
|
-
error =
|
|
3008
|
+
error = ${aiTurnErrorText({ code: "interrupted" }, "en")},
|
|
2933
3009
|
lease_owner = NULL,
|
|
2934
3010
|
lease_expires_at = NULL,
|
|
2935
3011
|
live_blocks = NULL
|
|
@@ -2977,11 +3053,13 @@ export const aiConversations: AiConversationService = {
|
|
|
2977
3053
|
`;
|
|
2978
3054
|
|
|
2979
3055
|
// 2) Finalize over-budget turns without a live lease.
|
|
2980
|
-
const budgetRows = await sql<
|
|
3056
|
+
const budgetRows = await sql<
|
|
3057
|
+
{ id: string; conversation_id: string; error: string; attempt: number; live_seq: number | string; run_budget_ms: number | null }[]
|
|
3058
|
+
>`
|
|
2981
3059
|
UPDATE ai.turns
|
|
2982
3060
|
SET status = 'failed',
|
|
2983
3061
|
completed_at = now(),
|
|
2984
|
-
error =
|
|
3062
|
+
error = ${aiTurnErrorText({ code: "time_limit" }, "en")},
|
|
2985
3063
|
lease_owner = NULL,
|
|
2986
3064
|
lease_expires_at = NULL,
|
|
2987
3065
|
live_blocks = NULL
|
|
@@ -2995,8 +3073,12 @@ export const aiConversations: AiConversationService = {
|
|
|
2995
3073
|
AND (lease_owner IS NULL OR lease_expires_at IS NULL OR lease_expires_at < now())
|
|
2996
3074
|
LIMIT ${limit}
|
|
2997
3075
|
)
|
|
2998
|
-
RETURNING id, conversation_id, error, attempt, live_seq
|
|
3076
|
+
RETURNING id, conversation_id, error, attempt, live_seq, run_budget_ms
|
|
2999
3077
|
`;
|
|
3078
|
+
const turnErrors = new Map<string, AiTurnError>([
|
|
3079
|
+
...exhaustedRows.map((row): [string, AiTurnError] => [row.id, { code: "interrupted" }]),
|
|
3080
|
+
...budgetRows.map((row): [string, AiTurnError] => [row.id, new AiRunTimeout(Number(row.run_budget_ms) || null).turnError()]),
|
|
3081
|
+
]);
|
|
3000
3082
|
result.failed = [...exhaustedRows, ...budgetRows].map((row) => ({
|
|
3001
3083
|
conversationId: row.conversation_id,
|
|
3002
3084
|
turnId: row.id,
|
|
@@ -3035,10 +3117,12 @@ export const aiConversations: AiConversationService = {
|
|
|
3035
3117
|
// History tells a stop from a turn that failed or whose wait expired, as it does for turns that end on their own.
|
|
3036
3118
|
const stopped = new Set(abortedRows.filter((row) => row.stopped).map((row) => row.id));
|
|
3037
3119
|
for (const finalized of [...result.failed, ...result.aborted]) {
|
|
3120
|
+
const stop = stopped.has(finalized.turnId);
|
|
3038
3121
|
await recordTurnEnd(sql, {
|
|
3039
3122
|
conversationId: finalized.conversationId,
|
|
3040
3123
|
turnId: finalized.turnId,
|
|
3041
|
-
reason:
|
|
3124
|
+
reason: stop ? "aborted" : "error",
|
|
3125
|
+
turnError: stop ? null : (turnErrors.get(finalized.turnId) ?? { code: "wait_expired" }),
|
|
3042
3126
|
});
|
|
3043
3127
|
await sql`
|
|
3044
3128
|
UPDATE ai.pending_actions
|