@k2b/cloud 0.25.0 → 0.27.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/capabilities.ts +12 -0
- 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/registry.ts +1 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/GroupCoverage.tsx +175 -0
- package/src/access/PermissionEditor.tsx +119 -99
- package/src/access/messages.ts +30 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/approval-routes.ts +5 -5
- package/src/ai/browser-code-contracts.ts +14 -2
- package/src/ai/browser.ts +8 -1
- package/src/ai/capabilities.ts +115 -33
- package/src/ai/chat/blocks.tsx +86 -179
- package/src/ai/chat/builtin-tools.tsx +83 -48
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +44 -0
- package/src/ai/chat/message-actions.tsx +6 -2
- package/src/ai/chat/message-utils.ts +17 -14
- package/src/ai/chat/messages.ts +330 -2
- package/src/ai/chat/presentation.tsx +278 -104
- package/src/ai/chat/tool-groups.ts +55 -35
- package/src/ai/chat/turn-layout.ts +141 -0
- package/src/ai/chat/turn-view.tsx +644 -0
- package/src/ai/client/controller.ts +60 -31
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +42 -6
- package/src/ai/code-mode-skill.ts +27 -27
- package/src/ai/code-runtime-tools.ts +10 -1
- package/src/ai/code-source-contracts.ts +54 -4
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +2 -2
- package/src/ai/default-tools.ts +2 -2
- package/src/ai/executor.ts +177 -80
- 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 +7 -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/protocol.ts +26 -4
- package/src/ai/provider-fetch.ts +67 -15
- package/src/ai/provider-retry.ts +105 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +16 -7
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +34 -6
- package/src/ai/runtime.ts +1 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +31 -3
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +202 -59
- package/src/ai/stream.ts +182 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +25 -0
- package/src/ai/timeline.ts +9 -11
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-policy.ts +247 -0
- package/src/ai/turn-timing.ts +31 -3
- package/src/ai/types.ts +36 -5
- 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 +62 -0
- package/src/api/index.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +118 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +77 -0
- package/src/contracts/registry.ts +4 -0
- package/src/services/index.ts +3 -0
- package/src/services/notifications/email.ts +16 -26
- package/src/services/outgoing-mail/index.ts +19 -0
- package/src/services/outgoing-mail/store.ts +286 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +13 -0
- package/src/services/settings/core-settings.ts +1 -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 +1 -1
- package/src/shared/ai-request-options.ts +185 -0
- package/src/shared/app-presentation.ts +10 -2
- package/src/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/workspace-navigation.ts +7 -1
- package/src/styles/effects.css +69 -0
package/src/ai/files-store.ts
CHANGED
|
@@ -31,6 +31,13 @@ type FileRow = {
|
|
|
31
31
|
type FileContentRow = FileRow & { bytes: Uint8Array };
|
|
32
32
|
export type AiFileContent = AiFileStat & { bytes: Uint8Array };
|
|
33
33
|
|
|
34
|
+
/** `LIKE` pattern for every path below a folder; the folder name itself matches literally. */
|
|
35
|
+
const pathsBelow = (folder: string): string => `${folder.endsWith("/") ? folder : `${folder}/`}`.replace(/[\\%_]/gu, "\\$&") + "%";
|
|
36
|
+
|
|
37
|
+
/** Working files of a conversation live below this folder; the assistant keeps intermediate steps there. */
|
|
38
|
+
export const AI_WORKING_FILES_FOLDER = "/temp/";
|
|
39
|
+
export const isAiWorkingFilePath = (path: string): boolean => path.startsWith(AI_WORKING_FILES_FOLDER);
|
|
40
|
+
|
|
34
41
|
const iso = (value: Date | string): string => (value instanceof Date ? value.toISOString() : new Date(value).toISOString());
|
|
35
42
|
|
|
36
43
|
const toStat = (row: FileRow): AiFileStat => ({
|
|
@@ -246,7 +253,7 @@ export const aiFileStore = {
|
|
|
246
253
|
if (input.limit !== undefined && (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 1000))
|
|
247
254
|
throw new Error("Invalid file page limit");
|
|
248
255
|
const prefix = input.prefix ?? "/";
|
|
249
|
-
const pattern =
|
|
256
|
+
const pattern = pathsBelow(prefix);
|
|
250
257
|
const rows = await sql<FileRow[]>`
|
|
251
258
|
SELECT path, size, media_type, origin, dictation_recorded_at, updated_at, version
|
|
252
259
|
FROM ai.files
|
|
@@ -467,20 +474,17 @@ export const aiFileStore = {
|
|
|
467
474
|
return sql.begin(async (tx) => {
|
|
468
475
|
await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
|
|
469
476
|
const path = await storedAiFilePath(tx, input.conversationId, input.path);
|
|
470
|
-
|
|
471
|
-
const pattern = `${path.endsWith("/") ? path : `${path}/`}%`;
|
|
472
|
-
const rows = await tx<{ id: string }[]>`
|
|
473
|
-
DELETE FROM ai.files
|
|
474
|
-
WHERE conversation_id = ${input.conversationId} AND (path = ${path} OR path LIKE ${pattern})
|
|
475
|
-
RETURNING id
|
|
476
|
-
`;
|
|
477
|
-
return rows.length;
|
|
478
|
-
}
|
|
477
|
+
const pattern = input.recursive ? pathsBelow(path) : null;
|
|
479
478
|
const rows = await tx<{ id: string }[]>`
|
|
480
479
|
DELETE FROM ai.files
|
|
481
|
-
WHERE conversation_id = ${input.conversationId} AND path = ${path}
|
|
480
|
+
WHERE conversation_id = ${input.conversationId} AND (path = ${path} OR path LIKE ${pattern})
|
|
482
481
|
RETURNING id
|
|
483
482
|
`;
|
|
483
|
+
// A result is the file it delivered. Once that file is gone, another file at the same path is not that result.
|
|
484
|
+
await tx`
|
|
485
|
+
DELETE FROM ai.conversation_sources
|
|
486
|
+
WHERE conversation_id = ${input.conversationId} AND kind = 'result' AND (source_key = ${path} OR source_key LIKE ${pattern})
|
|
487
|
+
`;
|
|
484
488
|
return rows.length;
|
|
485
489
|
});
|
|
486
490
|
},
|
|
@@ -498,6 +502,19 @@ export const aiFileStore = {
|
|
|
498
502
|
`;
|
|
499
503
|
if (target[0]) return "conflict" as const;
|
|
500
504
|
await tx`UPDATE ai.files SET path = ${input.to}, updated_at = now(), version = version + 1 WHERE id = ${source[0].id}::uuid`;
|
|
505
|
+
// A delivered result is the file, not its old name. A result row left at the new name belonged to a file that
|
|
506
|
+
// is gone and must not attach to this one; the moved file's own delivery, if any, moves there.
|
|
507
|
+
await tx`
|
|
508
|
+
DELETE FROM ai.conversation_sources
|
|
509
|
+
WHERE conversation_id = ${input.conversationId} AND kind = 'result' AND source_key = ${input.to}
|
|
510
|
+
`;
|
|
511
|
+
// A title that was only the old file name follows the new one; a title the assistant chose stays.
|
|
512
|
+
await tx`
|
|
513
|
+
UPDATE ai.conversation_sources
|
|
514
|
+
SET source_key = ${input.to},
|
|
515
|
+
title = CASE WHEN title = ${from.slice(from.lastIndexOf("/") + 1)} THEN ${input.to.slice(input.to.lastIndexOf("/") + 1)} ELSE title END
|
|
516
|
+
WHERE conversation_id = ${input.conversationId} AND kind = 'result' AND source_key = ${from}
|
|
517
|
+
`;
|
|
501
518
|
return "renamed" as const;
|
|
502
519
|
});
|
|
503
520
|
},
|
|
@@ -534,6 +551,112 @@ export const aiFileStore = {
|
|
|
534
551
|
},
|
|
535
552
|
};
|
|
536
553
|
|
|
554
|
+
export type AiConversationWorkingGroup = {
|
|
555
|
+
/** The first folder below `/temp/`; null for files directly in `/temp/`. */
|
|
556
|
+
folder: string | null;
|
|
557
|
+
count: number;
|
|
558
|
+
bytes: number;
|
|
559
|
+
updatedAt: string;
|
|
560
|
+
/** Number of files per media type. */
|
|
561
|
+
mediaTypes: Record<string, number>;
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
export type AiConversationFileOverview = {
|
|
565
|
+
/** Uploads and other files outside `/temp/`, newest first. */
|
|
566
|
+
files: AiFileStat[];
|
|
567
|
+
fileCount: number;
|
|
568
|
+
/** Working files below `/temp/`, one group per first subfolder, most recently changed first. */
|
|
569
|
+
groups: AiConversationWorkingGroup[];
|
|
570
|
+
groupCount: number;
|
|
571
|
+
/** Working files directly in `/temp/`, outside any group folder, newest first. */
|
|
572
|
+
looseWorkingFiles: AiFileStat[];
|
|
573
|
+
workingCount: number;
|
|
574
|
+
workingBytes: number;
|
|
575
|
+
/** Storage the conversation uses against its limit, dictation recordings included. */
|
|
576
|
+
usedBytes: number;
|
|
577
|
+
maxBytes: number;
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* A bounded view of a conversation's files for interfaces: files outside the working folder by name, working files
|
|
582
|
+
* as groups with counts. Load the files of a group with `listAiConversationFiles` and its folder as prefix.
|
|
583
|
+
* Authorized services may expose this after resolving the conversation owner.
|
|
584
|
+
*/
|
|
585
|
+
export const loadAiConversationFileOverview = async (
|
|
586
|
+
conversationId: string,
|
|
587
|
+
limits: { files: number; groups: number; looseWorkingFiles: number } = { files: 500, groups: 200, looseWorkingFiles: 100 },
|
|
588
|
+
): Promise<AiConversationFileOverview> => {
|
|
589
|
+
const working = pathsBelow(AI_WORKING_FILES_FOLDER);
|
|
590
|
+
const [files, [counts], groups, looseWorkingFiles, usedBytes] = await Promise.all([
|
|
591
|
+
sql<FileRow[]>`
|
|
592
|
+
SELECT path, size, media_type, origin, dictation_recorded_at, updated_at, version
|
|
593
|
+
FROM ai.files
|
|
594
|
+
WHERE conversation_id = ${conversationId} AND path NOT LIKE ${working}
|
|
595
|
+
ORDER BY updated_at DESC, path ASC
|
|
596
|
+
LIMIT ${limits.files}
|
|
597
|
+
`,
|
|
598
|
+
sql<{ files: number | string; working: number | string; working_bytes: number | string }[]>`
|
|
599
|
+
SELECT count(*) FILTER (WHERE path NOT LIKE ${working}) AS files,
|
|
600
|
+
count(*) FILTER (WHERE path LIKE ${working}) AS working,
|
|
601
|
+
COALESCE(sum(size) FILTER (WHERE path LIKE ${working}), 0) AS working_bytes
|
|
602
|
+
FROM ai.files
|
|
603
|
+
WHERE conversation_id = ${conversationId}
|
|
604
|
+
`,
|
|
605
|
+
sql<
|
|
606
|
+
{
|
|
607
|
+
folder: string | null;
|
|
608
|
+
count: number | string;
|
|
609
|
+
bytes: number | string;
|
|
610
|
+
updated_at: Date | string;
|
|
611
|
+
media_types: Record<string, number | string>;
|
|
612
|
+
group_count: number | string;
|
|
613
|
+
}[]
|
|
614
|
+
>`
|
|
615
|
+
WITH working AS (
|
|
616
|
+
SELECT CASE WHEN path ~ '^/temp/[^/]+/' THEN split_part(path, '/', 3) END AS folder, media_type, size, updated_at
|
|
617
|
+
FROM ai.files
|
|
618
|
+
WHERE conversation_id = ${conversationId} AND path LIKE ${working}
|
|
619
|
+
),
|
|
620
|
+
by_type AS (
|
|
621
|
+
SELECT folder, media_type, count(*) AS files, sum(size) AS bytes, max(updated_at) AS updated_at
|
|
622
|
+
FROM working
|
|
623
|
+
GROUP BY folder, media_type
|
|
624
|
+
)
|
|
625
|
+
SELECT folder, sum(files) AS count, sum(bytes) AS bytes, max(updated_at) AS updated_at,
|
|
626
|
+
jsonb_object_agg(media_type, files) AS media_types, count(*) OVER () AS group_count
|
|
627
|
+
FROM by_type
|
|
628
|
+
GROUP BY folder
|
|
629
|
+
ORDER BY max(updated_at) DESC, folder ASC NULLS LAST
|
|
630
|
+
LIMIT ${limits.groups}
|
|
631
|
+
`,
|
|
632
|
+
sql<FileRow[]>`
|
|
633
|
+
SELECT path, size, media_type, origin, dictation_recorded_at, updated_at, version
|
|
634
|
+
FROM ai.files
|
|
635
|
+
WHERE conversation_id = ${conversationId} AND path LIKE ${working} AND path !~ '^/temp/[^/]+/'
|
|
636
|
+
ORDER BY updated_at DESC, path ASC
|
|
637
|
+
LIMIT ${limits.looseWorkingFiles}
|
|
638
|
+
`,
|
|
639
|
+
aiConversationStoredBytes(sql, conversationId),
|
|
640
|
+
]);
|
|
641
|
+
return {
|
|
642
|
+
files: files.map(toStat),
|
|
643
|
+
fileCount: Number(counts?.files ?? 0),
|
|
644
|
+
groups: groups.map((group) => ({
|
|
645
|
+
folder: group.folder,
|
|
646
|
+
count: Number(group.count),
|
|
647
|
+
bytes: Number(group.bytes),
|
|
648
|
+
updatedAt: iso(group.updated_at),
|
|
649
|
+
mediaTypes: Object.fromEntries(Object.entries(group.media_types).map(([type, count]) => [type, Number(count)])),
|
|
650
|
+
})),
|
|
651
|
+
groupCount: Number(groups[0]?.group_count ?? 0),
|
|
652
|
+
looseWorkingFiles: looseWorkingFiles.map(toStat),
|
|
653
|
+
workingCount: Number(counts?.working ?? 0),
|
|
654
|
+
workingBytes: Number(counts?.working_bytes ?? 0),
|
|
655
|
+
usedBytes,
|
|
656
|
+
maxBytes: AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT,
|
|
657
|
+
};
|
|
658
|
+
};
|
|
659
|
+
|
|
537
660
|
/** Authorized services may expose this read after resolving the conversation owner. */
|
|
538
661
|
export const listAiConversationFiles = (
|
|
539
662
|
conversationId: string,
|
package/src/ai/grids-skill.ts
CHANGED
|
@@ -153,6 +153,6 @@ A left join preserves unmatched readable source records. A relation with many ta
|
|
|
153
153
|
|
|
154
154
|
## Read stored document files
|
|
155
155
|
|
|
156
|
-
For download links, use the downloadUrl returned by document.list/read/create verbatim; each artifacts[] entry carries its own downloadUrl. Every format (PDF, CSV, JSON, XML, renderer artifacts) uses the same links. They require the user's current Cloud session and are not a public share. Studio/code tools needing file bytes use document.content.read plus capabilities.streams.read; do not fetch invented paths.
|
|
156
|
+
For download links, use the downloadUrl returned by document.list/read/create verbatim; each artifacts[] entry carries its own downloadUrl. Every format (PDF, CSV, JSON, XML, renderer artifacts) uses the same links. They require the user's current Cloud session and are not a public share. Studio/code tools needing file bytes use document.content.read plus cloud.capabilities.streams.read; do not fetch invented paths.
|
|
157
157
|
|
|
158
|
-
Use \`grids.document.read\` for metadata and available artifact keys. When the task needs actual PDF, XML or CSV bytes, use \`grids.document.content.read\` with the document ID and optional artifactKey; omission selects the primary artifact. In code mode, obtain the stream via capabilities.run in the current run and pass it to capabilities.streams.read to receive a File. Read XML/CSV as text or use an available PDF processor. A binary download alone does not extract PDF text or prove that you inspected its contents. Prefer GQL for structured data analysis. This is a read, not document issuance, a public share or sending a file. Respect the 50 MiB code-mode payload budget and 250 MiB total transfer budget; larger stored artifacts require a supported HTTP/CLI transfer. Streams expire, require current permissions and cannot be reused across turns or in mandate-backed background tasks. Never invent stream references or export capabilities.`;
|
|
158
|
+
Use \`grids.document.read\` for metadata and available artifact keys. When the task needs actual PDF, XML or CSV bytes, use \`grids.document.content.read\` with the document ID and optional artifactKey; omission selects the primary artifact. In code mode, obtain the stream via cloud.capabilities.run in the current run and pass it to cloud.capabilities.streams.read to receive a File. Read XML/CSV as text or use an available PDF processor. A binary download alone does not extract PDF text or prove that you inspected its contents. Prefer GQL for structured data analysis. This is a read, not document issuance, a public share or sending a file. Respect the 50 MiB code-mode payload budget and 250 MiB total transfer budget; larger stored artifacts require a supported HTTP/CLI transfer. Streams expire, require current permissions and cannot be reused across turns or in mandate-backed background tasks. Never invent stream references or export capabilities.`;
|
package/src/ai/index.ts
CHANGED
|
@@ -75,14 +75,20 @@ export {
|
|
|
75
75
|
shouldApplyEnrichedTitle,
|
|
76
76
|
} from "./enrich";
|
|
77
77
|
export { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
|
|
78
|
+
export { aiConversationFileManifest } from "./file-context";
|
|
78
79
|
export { AiFileLocation, AiFileReference } from "./file-reference-contracts";
|
|
79
80
|
export {
|
|
80
81
|
AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT,
|
|
81
82
|
AI_FILES_MAX_FILE_BYTES_DEFAULT,
|
|
83
|
+
AI_WORKING_FILES_FOLDER,
|
|
84
|
+
type AiConversationFileOverview,
|
|
85
|
+
type AiConversationWorkingGroup,
|
|
82
86
|
type AiFileStat,
|
|
83
87
|
createAiConversationArtifact,
|
|
84
88
|
guessAiMediaType,
|
|
89
|
+
isAiWorkingFilePath,
|
|
85
90
|
listAiConversationFiles,
|
|
91
|
+
loadAiConversationFileOverview,
|
|
86
92
|
normalizeAiFilePath,
|
|
87
93
|
readAiConversationFile,
|
|
88
94
|
writeAiConversationFile,
|
|
@@ -245,6 +251,7 @@ export {
|
|
|
245
251
|
validateAiSkillReferences,
|
|
246
252
|
} from "./skill-format";
|
|
247
253
|
export {
|
|
254
|
+
type AiConversationSkillUse,
|
|
248
255
|
type AiLoadedSkillSnapshot,
|
|
249
256
|
type AiSkill,
|
|
250
257
|
type AiSkillAccess,
|
package/src/ai/memories.ts
CHANGED
|
@@ -206,6 +206,20 @@ export const formatAiMemories = (
|
|
|
206
206
|
export const aiMemories = {
|
|
207
207
|
list: search,
|
|
208
208
|
|
|
209
|
+
/** Active memories saved from one of the user's chats, newest first. */
|
|
210
|
+
async listFromConversation(userId: string, conversationId: string, limit = 50): Promise<AiMemory[]> {
|
|
211
|
+
const rows = await sql<MemoryRow[]>`
|
|
212
|
+
SELECT id, short_id, user_id, kind, content, priority, source, source_conversation_id, source_message_id,
|
|
213
|
+
resource_type, resource_id, created_at, updated_at
|
|
214
|
+
FROM ai.memories
|
|
215
|
+
WHERE user_id = ${userId}::uuid AND source_conversation_id = ${conversationId}::uuid
|
|
216
|
+
AND deleted_at IS NULL AND superseded_by_id IS NULL
|
|
217
|
+
ORDER BY updated_at DESC, id ASC
|
|
218
|
+
LIMIT ${Math.min(Math.max(Math.floor(limit), 1), 100)}
|
|
219
|
+
`;
|
|
220
|
+
return rows.map(toMemory);
|
|
221
|
+
},
|
|
222
|
+
|
|
209
223
|
async resolveSourceConversationShortIds(userId: string, conversationIds: string[]): Promise<Map<string, string>> {
|
|
210
224
|
if (conversationIds.length === 0) return new Map();
|
|
211
225
|
const rows = await sql<{ id: string; short_id: string }[]>`
|
package/src/ai/migrate.ts
CHANGED
|
@@ -393,6 +393,12 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
393
393
|
ON ai.messages(conversation_id, loop_id, seq ASC)
|
|
394
394
|
WHERE compacted_at IS NULL AND loop_id IS NOT NULL
|
|
395
395
|
`.simple();
|
|
396
|
+
// Chat positions of sources and results, also in turns that compaction archived; archived messages stay visible.
|
|
397
|
+
await sql`
|
|
398
|
+
CREATE INDEX IF NOT EXISTS idx_ai_messages_conversation_loop_seq
|
|
399
|
+
ON ai.messages(conversation_id, loop_id, seq ASC)
|
|
400
|
+
WHERE loop_id IS NOT NULL
|
|
401
|
+
`.simple();
|
|
396
402
|
|
|
397
403
|
// Optional pg_textsearch ranking. Native weighted FTS above is always the
|
|
398
404
|
// correctness path; missing extension support must never block startup.
|
|
@@ -559,6 +565,13 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
559
565
|
ON ai.conversation_sources(conversation_id, last_seen_at DESC, kind, source_key)
|
|
560
566
|
`.simple();
|
|
561
567
|
|
|
568
|
+
// A delivered result may be a Cloud resource, for example an app opened with code_open.
|
|
569
|
+
await sql`
|
|
570
|
+
ALTER TABLE ai.conversation_sources
|
|
571
|
+
ADD COLUMN IF NOT EXISTS resource_type TEXT,
|
|
572
|
+
ADD COLUMN IF NOT EXISTS resource_id TEXT
|
|
573
|
+
`.simple();
|
|
574
|
+
|
|
562
575
|
await sql`
|
|
563
576
|
CREATE TABLE IF NOT EXISTS ai.turn_steers (
|
|
564
577
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -1272,6 +1285,109 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
1272
1285
|
END $$
|
|
1273
1286
|
`.simple();
|
|
1274
1287
|
|
|
1288
|
+
// Deliveries become `result` sources. The kind check widens once, and then the deliveries already stored in chat
|
|
1289
|
+
// history are indexed once, so existing chats show their results from the start; this needs ai.files above. The
|
|
1290
|
+
// widened check stays NOT VALID until that backfill committed: validating it marks the backfill done, and a start
|
|
1291
|
+
// that stopped in between repeats it. The table is locked exclusively only while the check changes; the scan of the
|
|
1292
|
+
// history leaves it readable.
|
|
1293
|
+
const [kindCheck] = await sql<{ definition: string; validated: boolean }[]>`
|
|
1294
|
+
SELECT pg_get_constraintdef(oid) AS definition, convalidated AS validated FROM pg_constraint
|
|
1295
|
+
WHERE conname = 'ai_conversation_sources_kind_check' AND conrelid = 'ai.conversation_sources'::regclass
|
|
1296
|
+
`;
|
|
1297
|
+
const resultsAllowed = kindCheck?.definition.includes("'result'") === true;
|
|
1298
|
+
if (!resultsAllowed)
|
|
1299
|
+
await sql.begin(async (tx) => {
|
|
1300
|
+
await tx`ALTER TABLE ai.conversation_sources DROP CONSTRAINT IF EXISTS ai_conversation_sources_kind_check`.simple();
|
|
1301
|
+
await tx`
|
|
1302
|
+
ALTER TABLE ai.conversation_sources
|
|
1303
|
+
ADD CONSTRAINT ai_conversation_sources_kind_check CHECK (kind IN ('result', 'web', 'activity')) NOT VALID
|
|
1304
|
+
`.simple();
|
|
1305
|
+
});
|
|
1306
|
+
if (!resultsAllowed || !kindCheck?.validated) {
|
|
1307
|
+
await sql.begin(async (tx) => {
|
|
1308
|
+
// History is not news: the backfill must not queue one live event per indexed row.
|
|
1309
|
+
const liveTrigger = (state: "ENABLE" | "DISABLE") =>
|
|
1310
|
+
tx.unsafe(`
|
|
1311
|
+
DO $$ BEGIN
|
|
1312
|
+
IF EXISTS (
|
|
1313
|
+
SELECT 1 FROM pg_trigger
|
|
1314
|
+
WHERE tgname = 'ai_live_conversation_sources_changed' AND tgrelid = 'ai.conversation_sources'::regclass
|
|
1315
|
+
) THEN
|
|
1316
|
+
ALTER TABLE ai.conversation_sources ${state} TRIGGER ai_live_conversation_sources_changed;
|
|
1317
|
+
END IF;
|
|
1318
|
+
END $$
|
|
1319
|
+
`);
|
|
1320
|
+
await liveTrigger("DISABLE");
|
|
1321
|
+
await tx`
|
|
1322
|
+
WITH results AS (
|
|
1323
|
+
SELECT message.conversation_id, message.loop_id, message.seq, message.created_at,
|
|
1324
|
+
CASE WHEN jsonb_typeof(message.message) = 'string' THEN (message.message #>> '{}')::jsonb ELSE message.message END AS payload
|
|
1325
|
+
FROM ai.messages message
|
|
1326
|
+
WHERE message.role = 'tool_result' AND message.kind = 'message'
|
|
1327
|
+
),
|
|
1328
|
+
delivered AS (
|
|
1329
|
+
SELECT result.*, result.payload ->> 'callId' AS call_id, result.payload ->> 'name' AS tool_name,
|
|
1330
|
+
result.payload -> 'result' AS output,
|
|
1331
|
+
(SELECT block -> 'args'
|
|
1332
|
+
FROM ai.messages call,
|
|
1333
|
+
jsonb_array_elements(CASE WHEN jsonb_typeof(call.message) = 'string' THEN (call.message #>> '{}')::jsonb ELSE call.message END -> 'content') block
|
|
1334
|
+
WHERE call.conversation_id = result.conversation_id AND call.loop_id = result.loop_id AND call.role = 'assistant'
|
|
1335
|
+
AND block ->> 'type' = 'tool_call' AND block ->> 'id' = result.payload ->> 'callId'
|
|
1336
|
+
LIMIT 1) AS args
|
|
1337
|
+
FROM results result
|
|
1338
|
+
WHERE result.payload ->> 'name' IN ('present', 'code_open', 'code_present')
|
|
1339
|
+
AND COALESCE(result.payload ->> 'isError', 'false') <> 'true'
|
|
1340
|
+
AND jsonb_typeof(result.payload -> 'result') = 'object'
|
|
1341
|
+
-- Browser tools report a failure as a result with an error.
|
|
1342
|
+
AND NOT (result.payload -> 'result' ? 'error')
|
|
1343
|
+
),
|
|
1344
|
+
indexed AS (
|
|
1345
|
+
SELECT DISTINCT ON (delivered.conversation_id, source_key)
|
|
1346
|
+
delivered.conversation_id, source_key, title, preview, icon, resource_type, resource_id,
|
|
1347
|
+
delivered.loop_id, delivered.call_id, delivered.created_at
|
|
1348
|
+
FROM delivered,
|
|
1349
|
+
LATERAL (
|
|
1350
|
+
SELECT
|
|
1351
|
+
CASE delivered.tool_name
|
|
1352
|
+
WHEN 'present' THEN delivered.output ->> 'path'
|
|
1353
|
+
WHEN 'code_open' THEN 'assistant.artifact:' || (delivered.args ->> 'id')
|
|
1354
|
+
ELSE 'code_present:' || delivered.call_id
|
|
1355
|
+
END AS source_key,
|
|
1356
|
+
CASE delivered.tool_name
|
|
1357
|
+
WHEN 'present' THEN COALESCE(NULLIF(btrim(delivered.args ->> 'title'), ''), regexp_replace(delivered.output ->> 'path', '^.*/', ''))
|
|
1358
|
+
WHEN 'code_open' THEN 'Studio app'
|
|
1359
|
+
ELSE COALESCE(NULLIF(btrim(delivered.args ->> 'title'), ''), NULLIF(btrim(delivered.output ->> 'title'), ''), 'Visualization')
|
|
1360
|
+
END AS title,
|
|
1361
|
+
CASE WHEN delivered.tool_name = 'present' THEN NULLIF(btrim(delivered.args ->> 'description'), '') END AS preview,
|
|
1362
|
+
CASE delivered.tool_name WHEN 'present' THEN 'ti ti-file' WHEN 'code_open' THEN 'ti ti-app-window' ELSE 'ti ti-chart-dots' END AS icon,
|
|
1363
|
+
CASE WHEN delivered.tool_name = 'code_open' THEN 'assistant.artifact' END AS resource_type,
|
|
1364
|
+
CASE WHEN delivered.tool_name = 'code_open' THEN delivered.args ->> 'id' END AS resource_id
|
|
1365
|
+
) shape
|
|
1366
|
+
WHERE (delivered.tool_name <> 'present' OR delivered.output ->> 'path' LIKE '/%')
|
|
1367
|
+
-- A result is the file it delivered: a file deleted since then is not one, and its path may hold another.
|
|
1368
|
+
AND (delivered.tool_name <> 'present' OR EXISTS (
|
|
1369
|
+
SELECT 1 FROM ai.files file
|
|
1370
|
+
WHERE file.conversation_id = delivered.conversation_id AND file.path = delivered.output ->> 'path'
|
|
1371
|
+
))
|
|
1372
|
+
AND (delivered.tool_name <> 'code_open' OR NULLIF(delivered.args ->> 'id', '') IS NOT NULL)
|
|
1373
|
+
AND (delivered.tool_name <> 'code_present' OR delivered.output ? 'presentationId')
|
|
1374
|
+
ORDER BY delivered.conversation_id, source_key, delivered.seq DESC
|
|
1375
|
+
)
|
|
1376
|
+
INSERT INTO ai.conversation_sources (
|
|
1377
|
+
conversation_id, kind, source_key, title, preview, icon, resource_type, resource_id,
|
|
1378
|
+
source_turn_id, source_call_id, first_seen_at, last_seen_at
|
|
1379
|
+
)
|
|
1380
|
+
SELECT indexed.conversation_id, 'result', indexed.source_key, indexed.title, indexed.preview, indexed.icon,
|
|
1381
|
+
indexed.resource_type, indexed.resource_id, turn.id, indexed.call_id, indexed.created_at, indexed.created_at
|
|
1382
|
+
FROM indexed
|
|
1383
|
+
LEFT JOIN ai.turns turn ON turn.id::text = indexed.loop_id AND turn.conversation_id = indexed.conversation_id
|
|
1384
|
+
ON CONFLICT (conversation_id, kind, source_key) DO NOTHING
|
|
1385
|
+
`.simple();
|
|
1386
|
+
await liveTrigger("ENABLE");
|
|
1387
|
+
});
|
|
1388
|
+
await sql`ALTER TABLE ai.conversation_sources VALIDATE CONSTRAINT ai_conversation_sources_kind_check`.simple();
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1275
1391
|
// Inline image parts were an alpha transport detail. Move their bytes into
|
|
1276
1392
|
// the authorized conversation file store and leave only stable references in
|
|
1277
1393
|
// message history. Re-running is idempotent because the path is derived from
|
|
@@ -2209,6 +2325,15 @@ export const migrateCloudAi = async (): Promise<void> => {
|
|
|
2209
2325
|
)
|
|
2210
2326
|
`.simple();
|
|
2211
2327
|
|
|
2328
|
+
await sql`
|
|
2329
|
+
CREATE TABLE IF NOT EXISTS ai.model_request_headers (
|
|
2330
|
+
profile_id TEXT PRIMARY KEY,
|
|
2331
|
+
secret TEXT NOT NULL,
|
|
2332
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
2333
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
2334
|
+
)
|
|
2335
|
+
`.simple();
|
|
2336
|
+
|
|
2212
2337
|
// One-time move of keys that were stored inside the profiles setting. Once
|
|
2213
2338
|
// the blob holds no apiKey the split yields nothing and this is a no-op, so
|
|
2214
2339
|
// it is safe to re-run. Dynamic imports keep the module free of store deps.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { coreSettings } from "../services";
|
|
3
|
+
import { audit } from "../services/audit";
|
|
4
|
+
import { invalidateSettingsCache, set as setSetting } from "../services/settings";
|
|
5
|
+
import { decryptValue } from "../services/settings/crypto";
|
|
6
|
+
import {
|
|
7
|
+
type AiModelRequestSettings,
|
|
8
|
+
type AiModelRequestSettingsUpdate,
|
|
9
|
+
AiModelRequestSettingsUpdateSchema,
|
|
10
|
+
} from "../shared/ai-model-request-settings";
|
|
11
|
+
import { AiQuotaError } from "./quotas";
|
|
12
|
+
import { getAiRequestHeaders, planAiProfileRequestHeaders, storeAiRequestHeaderPlan } from "./request-headers";
|
|
13
|
+
import { parseAiModelProfiles } from "./settings";
|
|
14
|
+
import type { AiModelProfile } from "./types";
|
|
15
|
+
|
|
16
|
+
const key = "ai.model_profiles_json";
|
|
17
|
+
export class AiModelRequestSettingsInvalid extends Error {}
|
|
18
|
+
|
|
19
|
+
const readSnapshot = async (id: string, fallback: string, db: typeof sql, lock = false) => {
|
|
20
|
+
const rows = lock
|
|
21
|
+
? await db<{ value: string }[]>`SELECT value FROM settings.entries WHERE key=${key} FOR UPDATE`
|
|
22
|
+
: await db<{ value: string }[]>`SELECT value FROM settings.entries WHERE key=${key}`;
|
|
23
|
+
const raw = rows[0] ? await decryptValue(rows[0].value) : fallback;
|
|
24
|
+
if (typeof raw !== "string") throw new AiModelRequestSettingsInvalid("Invalid model configuration.");
|
|
25
|
+
const parsed = parseAiModelProfiles(raw);
|
|
26
|
+
if (parsed.error) throw new AiModelRequestSettingsInvalid(parsed.error.fields?.[key] ?? parsed.error.message);
|
|
27
|
+
const profile = parsed.profiles.find((profile) => profile.id === id);
|
|
28
|
+
if (!profile) throw new AiQuotaError("quota_conflict", "Model no longer exists.");
|
|
29
|
+
const [headerRow] = await db<{ secret: string }[]>`SELECT secret FROM ai.model_request_headers WHERE profile_id=${id}`;
|
|
30
|
+
// Hash ciphertext, never secret values. Any settings/header change invalidates
|
|
31
|
+
// the snapshot, including replacement of a value under an unchanged name.
|
|
32
|
+
const revision = new Bun.CryptoHasher("sha256").update(JSON.stringify([rows[0]?.value ?? raw, headerRow?.secret ?? null])).digest("hex");
|
|
33
|
+
const requestHeaderNames = Object.keys(await getAiRequestHeaders(id, db)).sort();
|
|
34
|
+
return { profiles: parsed.profiles, profile, settings: present(profile, requestHeaderNames, revision) };
|
|
35
|
+
};
|
|
36
|
+
const present = (profile: AiModelProfile, requestHeaderNames: string[], revision: string): AiModelRequestSettings => ({
|
|
37
|
+
id: profile.id,
|
|
38
|
+
reasoningEffort: profile.reasoningEffort ?? null,
|
|
39
|
+
extraBody: profile.extraBody ?? null,
|
|
40
|
+
requestHeaderNames,
|
|
41
|
+
revision,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export const getAiModelRequestSettings = async (id: string): Promise<AiModelRequestSettings> =>
|
|
45
|
+
(await readSnapshot(id, await coreSettings.get<string>(key), sql)).settings;
|
|
46
|
+
|
|
47
|
+
/** One transaction changes the non-secret profile and encrypted headers; audit contains names only. */
|
|
48
|
+
export const setAiModelRequestSettings = async (
|
|
49
|
+
id: string,
|
|
50
|
+
input: AiModelRequestSettingsUpdate,
|
|
51
|
+
actorId: string,
|
|
52
|
+
): Promise<AiModelRequestSettings> => {
|
|
53
|
+
const data = AiModelRequestSettingsUpdateSchema.parse(input);
|
|
54
|
+
const fallback = await coreSettings.get<string>(key);
|
|
55
|
+
const result = await sql.begin(async (db) => {
|
|
56
|
+
const current = await readSnapshot(id, fallback, db, true);
|
|
57
|
+
if (current.settings.revision !== data.expected)
|
|
58
|
+
throw new AiQuotaError("quota_conflict", "Model request settings changed. Read them again before updating.");
|
|
59
|
+
const nextProfile = { ...current.profile };
|
|
60
|
+
if (Object.hasOwn(data, "reasoningEffort")) {
|
|
61
|
+
if (data.reasoningEffort) nextProfile.reasoningEffort = data.reasoningEffort;
|
|
62
|
+
else delete nextProfile.reasoningEffort;
|
|
63
|
+
}
|
|
64
|
+
if (Object.hasOwn(data, "extraBody")) {
|
|
65
|
+
if (data.extraBody) nextProfile.extraBody = data.extraBody;
|
|
66
|
+
else delete nextProfile.extraBody;
|
|
67
|
+
}
|
|
68
|
+
const validated = parseAiModelProfiles(JSON.stringify([nextProfile]));
|
|
69
|
+
if (validated.error) throw new AiModelRequestSettingsInvalid(validated.error.fields?.[key] ?? validated.error.message);
|
|
70
|
+
const headers = data.clearRequestHeaders ? {} : await getAiRequestHeaders(id, db);
|
|
71
|
+
const plan = planAiProfileRequestHeaders({
|
|
72
|
+
currentProfiles: [current.profile],
|
|
73
|
+
nextProfiles: [nextProfile],
|
|
74
|
+
existingNames: { [id]: Object.keys(headers) },
|
|
75
|
+
submitted: data.requestHeaders === undefined ? [] : [{ profileId: id, patch: data.requestHeaders }],
|
|
76
|
+
});
|
|
77
|
+
if (plan.error) throw new AiModelRequestSettingsInvalid(plan.error);
|
|
78
|
+
// This endpoint owns one profile only. Never prune the other profiles.
|
|
79
|
+
const keepOthers = current.profiles.filter((profile) => profile.id !== id).map((profile) => profile.id);
|
|
80
|
+
plan.keepHeaderProfileIds = [...keepOthers, ...(data.clearRequestHeaders ? [] : plan.keepHeaderProfileIds)];
|
|
81
|
+
await storeAiRequestHeaderPlan(plan, db);
|
|
82
|
+
await setSetting(key, JSON.stringify(current.profiles.map((profile) => (profile.id === id ? validated.profiles[0] : profile))), db);
|
|
83
|
+
const saved = (await readSnapshot(id, fallback, db)).settings;
|
|
84
|
+
await audit.record(
|
|
85
|
+
{
|
|
86
|
+
action: "ai.model.request-settings",
|
|
87
|
+
outcome: "allowed",
|
|
88
|
+
actor: { userId: actorId },
|
|
89
|
+
target: { type: "ai-model", id },
|
|
90
|
+
metadata: { reasoningEffort: saved.reasoningEffort, extraBody: saved.extraBody, requestHeaderNames: saved.requestHeaderNames },
|
|
91
|
+
},
|
|
92
|
+
db,
|
|
93
|
+
);
|
|
94
|
+
return saved;
|
|
95
|
+
});
|
|
96
|
+
await invalidateSettingsCache([key]);
|
|
97
|
+
return result;
|
|
98
|
+
};
|
package/src/ai/protocol.ts
CHANGED
|
@@ -19,6 +19,12 @@ import type { AiConversation, AiFrontendToolMode, AiStoredMessage, AiToolPresent
|
|
|
19
19
|
|
|
20
20
|
export const AI_WIRE_VERSION = 1;
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Lease of a turn worker. A running turn whose worker is gone is claimed again after one lease, so a running turn
|
|
24
|
+
* that sends nothing for this long either works silently or lost an update; a client then reloads the turn's state.
|
|
25
|
+
*/
|
|
26
|
+
export const AI_TURN_LEASE_MS = 45_000;
|
|
27
|
+
|
|
22
28
|
export type AiToolBlockStatus = "running" | "awaiting_approval" | "awaiting_client" | "completed" | "failed" | "rejected";
|
|
23
29
|
|
|
24
30
|
export type AiTurnBlock =
|
|
@@ -39,6 +45,8 @@ export type AiTurnBlock =
|
|
|
39
45
|
isError?: boolean;
|
|
40
46
|
/** Present while status is awaiting_approval. */
|
|
41
47
|
approval?: { message?: string; review?: CapabilityActionReview; allowAlways: boolean };
|
|
48
|
+
/** The user approved this call in the chat; the decided approval stays visible as a receipt. */
|
|
49
|
+
approved?: boolean;
|
|
42
50
|
/** Present for frontend tools. */
|
|
43
51
|
frontendMode?: AiFrontendToolMode;
|
|
44
52
|
/** Saved Cloud-owned display snapshot for capability calls. */
|
|
@@ -67,6 +75,8 @@ export type AiWireEvent =
|
|
|
67
75
|
| (AiWireEventBase & { type: "message_saved"; message: AiStoredMessage })
|
|
68
76
|
| (AiWireEventBase & { type: "block_set"; block: AiTurnBlock })
|
|
69
77
|
| (AiWireEventBase & { type: "block_delta"; blockId: string; blockKind: "text" | "thinking"; delta: string })
|
|
78
|
+
/** A model call failed transiently and waits for its retry. Transient: the next event of the turn ends it. */
|
|
79
|
+
| (AiWireEventBase & { type: "provider_retry" })
|
|
70
80
|
| (AiWireEventBase & {
|
|
71
81
|
type: "turn_finished";
|
|
72
82
|
status: AiTurnFinishedStatus;
|
|
@@ -85,6 +95,10 @@ export type AiTurnSnapshot = {
|
|
|
85
95
|
blocks: AiTurnBlock[];
|
|
86
96
|
modelProfileId: string | null;
|
|
87
97
|
createdAt: string;
|
|
98
|
+
/** Time the turn already waited for answered user actions such as approvals. Absent from older servers. */
|
|
99
|
+
actionWaitMs?: number;
|
|
100
|
+
/** Start of the user action the turn waits for now, or null while it works. Absent from older servers. */
|
|
101
|
+
waitingSince?: string | null;
|
|
88
102
|
};
|
|
89
103
|
|
|
90
104
|
/** Full projection seed sent as the first SSE event on every (re)connect. */
|
|
@@ -151,7 +165,12 @@ export const reconcileResolvedTurnActions = (
|
|
|
151
165
|
const event = resolvedByCallId.get(block.callId);
|
|
152
166
|
if (!event || event.callId !== block.callId) return block;
|
|
153
167
|
if (block.status === "awaiting_approval" && event.type === "approval_response") {
|
|
154
|
-
return {
|
|
168
|
+
return {
|
|
169
|
+
...block,
|
|
170
|
+
status: event.approved ? "running" : "rejected",
|
|
171
|
+
approval: undefined,
|
|
172
|
+
...(event.approved ? { approved: true } : {}),
|
|
173
|
+
};
|
|
155
174
|
}
|
|
156
175
|
if (block.status === "awaiting_client" && event.type === "tool_result") {
|
|
157
176
|
return { ...block, status: "completed", result: event.result, isError: false };
|
|
@@ -172,7 +191,7 @@ export const buildBlocksFromMessages = (
|
|
|
172
191
|
meta?: {
|
|
173
192
|
steerId?: string;
|
|
174
193
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
175
|
-
toolOutcomes?: Record<string, "rejected">;
|
|
194
|
+
toolOutcomes?: Record<string, "rejected" | "approved">;
|
|
176
195
|
} | null;
|
|
177
196
|
}[],
|
|
178
197
|
): AiTurnBlock[] => {
|
|
@@ -196,6 +215,8 @@ export const buildBlocksFromMessages = (
|
|
|
196
215
|
args: block.args,
|
|
197
216
|
status: "running",
|
|
198
217
|
presentation: meta?.toolPresentations?.[block.id],
|
|
218
|
+
// A turn that ended before an approved call returned records the approval on the call's message.
|
|
219
|
+
...(meta?.toolOutcomes?.[block.id] === "approved" ? { approved: true } : {}),
|
|
199
220
|
});
|
|
200
221
|
}
|
|
201
222
|
});
|
|
@@ -212,12 +233,13 @@ export const buildBlocksFromMessages = (
|
|
|
212
233
|
const at = toolIndex.get(message.callId);
|
|
213
234
|
const existing = at !== undefined ? blocks[at] : undefined;
|
|
214
235
|
if (existing?.kind === "tool") {
|
|
215
|
-
const
|
|
236
|
+
const outcome = meta?.toolOutcomes?.[message.callId];
|
|
216
237
|
blocks[at!] = {
|
|
217
238
|
...existing,
|
|
218
|
-
status: rejected ? "rejected" : message.isError ? "failed" : "completed",
|
|
239
|
+
status: outcome === "rejected" ? "rejected" : message.isError ? "failed" : "completed",
|
|
219
240
|
result: message.result,
|
|
220
241
|
isError: message.isError,
|
|
242
|
+
...(outcome === "approved" ? { approved: true } : {}),
|
|
221
243
|
};
|
|
222
244
|
}
|
|
223
245
|
}
|