@k2b/cloud 0.28.0 → 0.29.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 +7 -5
- package/src/_internal/cli-plugins.ts +18 -0
- package/src/_internal/define-app.ts +21 -5
- package/src/_internal/registry-validation.ts +30 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.tsx +11 -4
- package/src/ai/assistant-models.ts +25 -4
- package/src/ai/audio-tool.ts +3 -10
- package/src/ai/capabilities.ts +35 -9
- package/src/ai/chat/blocks.tsx +97 -17
- package/src/ai/chat/builtin-tools.tsx +3 -1
- package/src/ai/chat/composer-adapter.ts +8 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +12 -3
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +28 -6
- package/src/ai/chat/messages.ts +16 -0
- package/src/ai/chat/presentation.tsx +1 -8
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-layout.ts +12 -0
- package/src/ai/chat/turn-view.tsx +15 -7
- package/src/ai/chat-tasks.ts +16 -3
- package/src/ai/code-mode-skill.ts +4 -4
- package/src/ai/compaction.ts +22 -3
- package/src/ai/default-tools.ts +9 -2
- package/src/ai/executor.ts +127 -40
- package/src/ai/file-media-type.ts +4 -0
- package/src/ai/files-store.ts +17 -10
- package/src/ai/http.ts +11 -10
- package/src/ai/index.ts +1 -0
- package/src/ai/memories.ts +2 -0
- package/src/ai/memory-learning.ts +59 -5
- package/src/ai/memory-workflow-evidence.ts +35 -10
- package/src/ai/projects-routes.ts +5 -1
- package/src/ai/projects.ts +5 -1
- package/src/ai/protocol.ts +34 -1
- package/src/ai/provider.ts +16 -1
- package/src/ai/quota-provider.ts +4 -1
- package/src/ai/routes.ts +20 -10
- package/src/ai/settings.ts +54 -17
- package/src/ai/skill-seeds.ts +11 -3
- package/src/ai/store.ts +56 -20
- package/src/ai/structured.ts +2 -1
- package/src/ai/system-prompt.ts +5 -1
- package/src/ai/transcription.ts +58 -13
- package/src/ai/turn-failure.ts +5 -1
- package/src/ai/turn-guidance.ts +505 -0
- package/src/ai/types.ts +1 -0
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/me.ts +42 -1
- package/src/browser/FileChooser.tsx +71 -295
- package/src/browser/FileSaver.tsx +407 -0
- package/src/browser/app-badges.ts +16 -0
- package/src/browser/choose-files.browser-harness.tsx +22 -6
- package/src/browser/choose-files.tsx +23 -36
- package/src/browser/file-chooser-messages.ts +64 -0
- package/src/browser/file-providers.ts +258 -8
- package/src/browser/files.tsx +26 -3
- package/src/browser/notifications.ts +37 -1
- package/src/browser/provider-folder.tsx +301 -0
- package/src/browser/provider-list.ts +39 -0
- package/src/browser/save-files.browser-harness.tsx +35 -0
- package/src/browser/save-files.tsx +107 -0
- package/src/contracts/app.ts +10 -0
- package/src/contracts/commands.ts +3 -1
- package/src/contracts/notification-group.ts +6 -0
- package/src/contracts/notification-types.ts +6 -0
- package/src/contracts/registry.ts +1 -0
- package/src/contracts/user-notifications.ts +74 -0
- package/src/server/middleware/request-logger.ts +11 -2
- package/src/services/accounts/avatar.ts +5 -2
- package/src/services/image-metadata.ts +438 -0
- package/src/services/index.ts +1 -0
- package/src/services/logging/redaction.ts +50 -0
- package/src/services/mandates/index.ts +4 -2
- package/src/services/mandates/policy.ts +20 -0
- package/src/services/notifications/browser.ts +48 -15
- package/src/services/notifications/dispatcher.ts +31 -1
- package/src/services/notifications/platform.ts +32 -4
- package/src/services/notifications/preview.ts +23 -0
- package/src/services/notifications/quiet.ts +153 -0
- package/src/services/notifications/user.ts +6 -0
- package/src/services/pdf/template-preview.ts +2 -1
- package/src/services/settings/app.ts +3 -0
- package/src/services/settings/store.ts +4 -1
- package/src/services/weather/geo.ts +6 -2
- package/src/shared/ai-platform-prompt.ts +2 -2
- package/src/shared/redirect.ts +2 -1
- package/src/shared/template-rendering.ts +120 -7
- package/src/ssr/AppBadge.tsx +30 -0
- package/src/ssr/AppLaunchpadPanel.tsx +47 -26
- package/src/ssr/IslandError.tsx +33 -0
- package/src/ssr/Layout.tsx +3 -0
- package/src/ssr/LayoutHelp.tsx +4 -2
- package/src/ssr/RailApps.island.tsx +9 -2
- package/src/ssr/app-badges.ts +93 -0
- package/src/ssr/rail-messages.ts +4 -0
- package/src/ssr/rail-navigation.ts +2 -0
- package/src/styles/effects.css +23 -0
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/utilities-navigation.css +41 -0
package/src/ai/files-store.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
|
+
import { stripImageMetadata } from "../services/image-metadata";
|
|
2
3
|
import { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
|
|
4
|
+
import { isAiImage } from "./file-media-type";
|
|
3
5
|
|
|
4
6
|
export { guessAiMediaType } from "./file-media-type";
|
|
5
7
|
|
|
@@ -79,9 +81,11 @@ export const createUniqueAiFileInTransaction = async (
|
|
|
79
81
|
throw new AiFileWriteError("STORAGE_FULL", `File exceeds the per-file limit of ${Math.floor(maxFile / (1024 * 1024))} MB.`);
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
const bytes = isAiImage(input.path, input.mediaType ?? "application/octet-stream") ? stripImageMetadata(input.bytes) : input.bytes;
|
|
85
|
+
|
|
82
86
|
await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE`;
|
|
83
87
|
const total = await aiConversationStoredBytes(tx, input.conversationId);
|
|
84
|
-
if (total +
|
|
88
|
+
if (total + bytes.byteLength > maxConversation) {
|
|
85
89
|
throw new AiFileWriteError("STORAGE_FULL", `Conversation storage limit of ${Math.floor(maxConversation / (1024 * 1024))} MB exceeded.`);
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -89,7 +93,7 @@ export const createUniqueAiFileInTransaction = async (
|
|
|
89
93
|
const path = numberedAiFilePath(input.path, number);
|
|
90
94
|
const rows = await tx<FileRow[]>`
|
|
91
95
|
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at)
|
|
92
|
-
SELECT ${input.conversationId}, ${path}, ${
|
|
96
|
+
SELECT ${input.conversationId}, ${path}, ${bytes}, ${input.mediaType ?? "application/octet-stream"}, ${bytes.byteLength}, ${input.origin}, ${input.dictationRecordedAt ?? null}, now()
|
|
93
97
|
WHERE NOT EXISTS (
|
|
94
98
|
SELECT 1 FROM ai.files WHERE conversation_id = ${input.conversationId} AND normalize(path, NFC) = ${path.normalize("NFC")}
|
|
95
99
|
)
|
|
@@ -191,7 +195,8 @@ export const aiFileStore = {
|
|
|
191
195
|
producerCallKey: string;
|
|
192
196
|
mediaType: string;
|
|
193
197
|
}): Promise<AiFileStat> {
|
|
194
|
-
|
|
198
|
+
const bytes = isAiImage(input.path, input.mediaType) ? stripImageMetadata(input.bytes) : input.bytes;
|
|
199
|
+
if (bytes.byteLength > AI_FILES_MAX_FILE_BYTES_DEFAULT)
|
|
195
200
|
throw new AiFileWriteError("STORAGE_FULL", "File exceeds the destination file limit; nothing was written.");
|
|
196
201
|
if (!normalizeAiFilePath(input.path)) {
|
|
197
202
|
throw new Error("Invalid tool artifact path or file size.");
|
|
@@ -210,18 +215,18 @@ export const aiFileStore = {
|
|
|
210
215
|
Number(row.version) === 1 &&
|
|
211
216
|
row.producer_call_key === input.producerCallKey &&
|
|
212
217
|
row.media_type === input.mediaType &&
|
|
213
|
-
Buffer.from(row.bytes).equals(Buffer.from(
|
|
218
|
+
Buffer.from(row.bytes).equals(Buffer.from(bytes))
|
|
214
219
|
)
|
|
215
220
|
return toStat(row);
|
|
216
221
|
throw new AiFileWriteError("CONFLICT", "Destination file already exists. Choose another path; nothing was written.");
|
|
217
222
|
}
|
|
218
223
|
const total = await aiConversationStoredBytes(tx, input.conversationId);
|
|
219
|
-
if (total +
|
|
224
|
+
if (total + bytes.byteLength > AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT) {
|
|
220
225
|
throw new AiFileWriteError("STORAGE_FULL", "Conversation storage limit exceeded.");
|
|
221
226
|
}
|
|
222
227
|
const rows = await tx<FileRow[]>`
|
|
223
228
|
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, producer_call_key)
|
|
224
|
-
VALUES (${input.conversationId}, ${path}, ${
|
|
229
|
+
VALUES (${input.conversationId}, ${path}, ${bytes}, ${input.mediaType}, ${bytes.byteLength}, 'assistant', ${input.producerCallKey})
|
|
225
230
|
RETURNING path, size, media_type, origin, dictation_recorded_at, updated_at, version
|
|
226
231
|
`;
|
|
227
232
|
return toStat(rows[0]!);
|
|
@@ -364,6 +369,8 @@ export const aiFileStore = {
|
|
|
364
369
|
throw new AiFileWriteError("STORAGE_FULL", `File exceeds the per-file limit of ${Math.floor(maxFile / (1024 * 1024))} MB.`);
|
|
365
370
|
}
|
|
366
371
|
|
|
372
|
+
const bytes = isAiImage(input.path, input.mediaType ?? "application/octet-stream") ? stripImageMetadata(input.bytes) : input.bytes;
|
|
373
|
+
|
|
367
374
|
return sql.begin(async (tx) => {
|
|
368
375
|
const [conversation] = await tx<
|
|
369
376
|
{ id: string; created_by_user_id: string; archived_at: Date | null }[]
|
|
@@ -384,7 +391,7 @@ export const aiFileStore = {
|
|
|
384
391
|
if (version !== input.expectedVersion) throw new AiFileVersionConflict();
|
|
385
392
|
}
|
|
386
393
|
const otherBytes = await aiConversationStoredBytes(tx, input.conversationId, path);
|
|
387
|
-
if (otherBytes +
|
|
394
|
+
if (otherBytes + bytes.byteLength > maxConversation) {
|
|
388
395
|
throw new AiFileWriteError(
|
|
389
396
|
"STORAGE_FULL",
|
|
390
397
|
`Conversation storage limit of ${Math.floor(maxConversation / (1024 * 1024))} MB exceeded.`,
|
|
@@ -394,7 +401,7 @@ export const aiFileStore = {
|
|
|
394
401
|
if (input.allowUserOverwrite) {
|
|
395
402
|
const written = await tx<{ id: string }[]>`
|
|
396
403
|
UPDATE ai.files
|
|
397
|
-
SET dictation_recorded_at = NULL, bytes = ${
|
|
404
|
+
SET dictation_recorded_at = NULL, bytes = ${bytes}, media_type = ${input.mediaType ?? "application/octet-stream"}, size = ${bytes.byteLength}, updated_at = now(), version = version + 1
|
|
398
405
|
WHERE conversation_id = ${input.conversationId} AND path = ${path} AND origin = 'user'
|
|
399
406
|
RETURNING id
|
|
400
407
|
`;
|
|
@@ -402,13 +409,13 @@ export const aiFileStore = {
|
|
|
402
409
|
} else {
|
|
403
410
|
await tx`
|
|
404
411
|
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
|
|
405
|
-
VALUES (${input.conversationId}, ${path}, ${
|
|
412
|
+
VALUES (${input.conversationId}, ${path}, ${bytes}, ${input.mediaType ?? "application/octet-stream"}, ${bytes.byteLength}, 'user', now())
|
|
406
413
|
`;
|
|
407
414
|
}
|
|
408
415
|
} else {
|
|
409
416
|
const written = await tx<{ id: string }[]>`
|
|
410
417
|
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
|
|
411
|
-
VALUES (${input.conversationId}, ${path}, ${
|
|
418
|
+
VALUES (${input.conversationId}, ${path}, ${bytes}, ${input.mediaType ?? "application/octet-stream"}, ${bytes.byteLength}, 'assistant', now())
|
|
412
419
|
ON CONFLICT (conversation_id, path) DO UPDATE SET
|
|
413
420
|
bytes = EXCLUDED.bytes,
|
|
414
421
|
media_type = EXCLUDED.media_type,
|
package/src/ai/http.ts
CHANGED
|
@@ -122,6 +122,15 @@ export type AiTurnContentPart = z.infer<typeof AiUserContentPartSchema>;
|
|
|
122
122
|
|
|
123
123
|
export const AiClientToolIdSchema = z.enum(["local_bash", ...CODE_RUNTIME_TOOL_NAMES] satisfies [AiClientToolId, ...AiClientToolId[]]);
|
|
124
124
|
|
|
125
|
+
// Names use aiCapabilityToolName's provider grammar and 64-character limit. The 64-item cap matches allowedTools, with headroom
|
|
126
|
+
// for retired and not-yet-known names; 64 names × 64 ASCII characters bounds advertised name text to 4 KiB.
|
|
127
|
+
const AI_CLIENT_TOOL_MAX_ITEMS = 64;
|
|
128
|
+
const AiClientToolIdsSchema = z
|
|
129
|
+
.array(z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/))
|
|
130
|
+
.max(AI_CLIENT_TOOL_MAX_ITEMS)
|
|
131
|
+
.refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique")
|
|
132
|
+
.transform((ids) => ids.filter((id): id is AiClientToolId => AiClientToolIdSchema.safeParse(id).success));
|
|
133
|
+
|
|
125
134
|
export const AiTurnInputSchema = z
|
|
126
135
|
.object({
|
|
127
136
|
message: z.string().trim().max(20000).optional(),
|
|
@@ -131,11 +140,7 @@ export const AiTurnInputSchema = z
|
|
|
131
140
|
.max(AI_TURN_ATTACHMENT_MAX_ITEMS * 2 + 1)
|
|
132
141
|
.optional(),
|
|
133
142
|
modelProfileId: z.string().trim().min(1).optional(),
|
|
134
|
-
clientToolIds:
|
|
135
|
-
.array(AiClientToolIdSchema)
|
|
136
|
-
.max(1 + CODE_RUNTIME_TOOL_NAMES.length)
|
|
137
|
-
.refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique")
|
|
138
|
-
.optional(),
|
|
143
|
+
clientToolIds: AiClientToolIdsSchema.optional(),
|
|
139
144
|
})
|
|
140
145
|
.refine((input) => Boolean(input.message?.trim() || input.content?.length), {
|
|
141
146
|
message: "Message or content is required.",
|
|
@@ -156,11 +161,7 @@ export const AiSubmitConversationDraftInputSchema = z.object({
|
|
|
156
161
|
queueId: z.uuid().optional(),
|
|
157
162
|
draftRevision: z.number().int().min(1),
|
|
158
163
|
modelProfileId: z.string().trim().min(1).optional(),
|
|
159
|
-
clientToolIds:
|
|
160
|
-
.array(AiClientToolIdSchema)
|
|
161
|
-
.max(1 + CODE_RUNTIME_TOOL_NAMES.length)
|
|
162
|
-
.refine((ids) => new Set(ids).size === ids.length, "Client tool IDs must be unique")
|
|
163
|
-
.optional(),
|
|
164
|
+
clientToolIds: AiClientToolIdsSchema.optional(),
|
|
164
165
|
});
|
|
165
166
|
|
|
166
167
|
export const AiSteerInputSchema = z.object({
|
package/src/ai/index.ts
CHANGED
|
@@ -76,6 +76,7 @@ export {
|
|
|
76
76
|
} from "./enrich";
|
|
77
77
|
export { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
|
|
78
78
|
export { aiConversationFileManifest } from "./file-context";
|
|
79
|
+
export { isAiImage } from "./file-media-type";
|
|
79
80
|
export { AiFileLocation, AiFileReference } from "./file-reference-contracts";
|
|
80
81
|
export {
|
|
81
82
|
AI_FILES_MAX_CONVERSATION_BYTES_DEFAULT,
|
package/src/ai/memories.ts
CHANGED
|
@@ -412,6 +412,7 @@ export const aiMemories = {
|
|
|
412
412
|
* Apply one model proposal as an ownership-checked transaction. Background
|
|
413
413
|
* learning may only mutate normal background memories; user, agent, and
|
|
414
414
|
* pinned entries remain authoritative.
|
|
415
|
+
* The source chat must still be active; the row lock makes this atomic with archiving.
|
|
415
416
|
*/
|
|
416
417
|
async applyBackgroundProposal(input: {
|
|
417
418
|
userId: string;
|
|
@@ -437,6 +438,7 @@ export const aiMemories = {
|
|
|
437
438
|
FROM ai.conversations c
|
|
438
439
|
WHERE c.id = ${input.sourceConversationId}::uuid
|
|
439
440
|
AND c.created_by_user_id = ${input.userId}::uuid
|
|
441
|
+
AND c.archived_at IS NULL
|
|
440
442
|
AND (${input.sourceMessageId ?? null}::uuid IS NULL OR EXISTS (
|
|
441
443
|
SELECT 1 FROM ai.messages m
|
|
442
444
|
WHERE m.id = ${input.sourceMessageId ?? null}::uuid AND m.conversation_id = c.id
|
|
@@ -12,6 +12,7 @@ import { type AiMemoryLearningChange, aiMemoryLearningRuns } from "./memory-lear
|
|
|
12
12
|
import {
|
|
13
13
|
type AiMemoryWorkflowEvidence,
|
|
14
14
|
type AiMemoryWorkflowPattern,
|
|
15
|
+
isAiWorkflowPatternEligible,
|
|
15
16
|
listAiPendingWorkflowPatterns,
|
|
16
17
|
listAiTurnWorkflowEvidence,
|
|
17
18
|
markAiWorkflowPatternReviewed,
|
|
@@ -199,6 +200,21 @@ const markFailed = async (candidate: Candidate): Promise<void> => {
|
|
|
199
200
|
|
|
200
201
|
const learningEnabled = async (userId: string): Promise<boolean> => (await aiUserPrefs.get(userId)).memoryLearningEnabled;
|
|
201
202
|
|
|
203
|
+
const turnEligible = async (candidate: Candidate): Promise<boolean> => {
|
|
204
|
+
const [row] = await sql<{ id: string }[]>`
|
|
205
|
+
SELECT turn.id
|
|
206
|
+
FROM ai.turns turn
|
|
207
|
+
JOIN ai.conversations conversation ON conversation.id = turn.conversation_id
|
|
208
|
+
WHERE turn.id = ${candidate.turnId}::uuid
|
|
209
|
+
AND conversation.id = ${candidate.conversationId}::uuid
|
|
210
|
+
AND conversation.created_by_user_id = ${candidate.userId}::uuid
|
|
211
|
+
AND conversation.archived_at IS NULL
|
|
212
|
+
AND turn.status = 'completed'
|
|
213
|
+
AND turn.memory_learned_at IS NULL
|
|
214
|
+
`;
|
|
215
|
+
return Boolean(row);
|
|
216
|
+
};
|
|
217
|
+
|
|
202
218
|
const monthlyAccountedTokens = async (userId: string): Promise<number> => {
|
|
203
219
|
const [row] = await sql<{ tokens: number }[]>`
|
|
204
220
|
SELECT COALESCE(sum(accounted_tokens), 0)::int AS tokens
|
|
@@ -342,6 +358,7 @@ const applyChanges = async (
|
|
|
342
358
|
const workflowPatternInput = async (
|
|
343
359
|
pattern: AiMemoryWorkflowPattern,
|
|
344
360
|
): Promise<{ input: string; source: TurnEvidence | null; mutableMemoryIds: Set<string> }> => {
|
|
361
|
+
if (!(await isAiWorkflowPatternEligible(pattern))) return { input: "", source: null, mutableMemoryIds: new Set() };
|
|
345
362
|
const sources = await Promise.all(
|
|
346
363
|
pattern.turnIds.map(async (turnId) => {
|
|
347
364
|
const [row] = await sql<{ conversation_id: string; completed_as_of: string; fail_count: number }[]>`
|
|
@@ -349,6 +366,7 @@ const workflowPatternInput = async (
|
|
|
349
366
|
FROM ai.turns turn
|
|
350
367
|
JOIN ai.conversations conversation ON conversation.id = turn.conversation_id
|
|
351
368
|
WHERE turn.id = ${turnId}::uuid AND conversation.created_by_user_id = ${pattern.userId}::uuid
|
|
369
|
+
AND conversation.archived_at IS NULL
|
|
352
370
|
`;
|
|
353
371
|
if (!row) return null;
|
|
354
372
|
return loadTurnEvidence({
|
|
@@ -361,6 +379,7 @@ const workflowPatternInput = async (
|
|
|
361
379
|
}),
|
|
362
380
|
);
|
|
363
381
|
const evidence = sources.filter((source): source is TurnEvidence => Boolean(source));
|
|
382
|
+
if (evidence.length !== pattern.turnIds.length) return { input: "", source: null, mutableMemoryIds: new Set() };
|
|
364
383
|
const query = evidence.map((source) => source.userText).join(" ");
|
|
365
384
|
const selected = await aiMemories.selectHot(pattern.userId, query);
|
|
366
385
|
const examples = evidence
|
|
@@ -441,6 +460,12 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
441
460
|
let runId: string | null = null;
|
|
442
461
|
let changes: AiMemoryLearningChange[] = [];
|
|
443
462
|
try {
|
|
463
|
+
if (!(await turnEligible(candidate))) {
|
|
464
|
+
await markLearned(candidate);
|
|
465
|
+
summary.skipped += 1;
|
|
466
|
+
processedCandidates += 1;
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
444
469
|
const evidence = await loadTurnEvidence(candidate);
|
|
445
470
|
if (!evidence.userText) {
|
|
446
471
|
await markLearned(candidate);
|
|
@@ -458,7 +483,6 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
458
483
|
});
|
|
459
484
|
const reservedTokens = estimatedTokens(systemPrompt, taskInput);
|
|
460
485
|
if ((await readMonthlyUsage(candidate.userId)) + reservedTokens > monthlyTokenBudget) continue;
|
|
461
|
-
// Read the choice again right before the turn can reach the model.
|
|
462
486
|
if (!(await learningEnabled(candidate.userId))) continue;
|
|
463
487
|
runId = await aiMemoryLearningRuns.start({
|
|
464
488
|
userId: candidate.userId,
|
|
@@ -468,6 +492,22 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
468
492
|
modelProfileId: resolved.profile.id,
|
|
469
493
|
accountedTokens: reservedTokens,
|
|
470
494
|
});
|
|
495
|
+
// Claiming the run also awaits the database: recheck immediately before inference.
|
|
496
|
+
// runAiStructured may still wait for background admission; checks after the call discard results for chats that became ineligible.
|
|
497
|
+
if (!(await turnEligible(candidate))) {
|
|
498
|
+
await markLearned(candidate);
|
|
499
|
+
if (runId)
|
|
500
|
+
await aiMemoryLearningRuns.finish({
|
|
501
|
+
runId,
|
|
502
|
+
status: "skipped",
|
|
503
|
+
durationMs: Date.now() - startedAt,
|
|
504
|
+
changes: [],
|
|
505
|
+
accountedTokens: 0,
|
|
506
|
+
});
|
|
507
|
+
summary.skipped += 1;
|
|
508
|
+
processedCandidates += 1;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
471
511
|
if (!runId) continue;
|
|
472
512
|
const result = await structured({
|
|
473
513
|
task: "memory-learn-turn",
|
|
@@ -481,7 +521,7 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
481
521
|
signal: input.signal,
|
|
482
522
|
resolveModel: async () => resolved,
|
|
483
523
|
});
|
|
484
|
-
if (!(await learningEnabled(candidate.userId))) {
|
|
524
|
+
if (!(await learningEnabled(candidate.userId)) || !(await turnEligible(candidate))) {
|
|
485
525
|
await markLearned(candidate);
|
|
486
526
|
} else {
|
|
487
527
|
changes = await applyChanges(
|
|
@@ -547,7 +587,8 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
547
587
|
try {
|
|
548
588
|
const context = await workflowPatternInput(pattern);
|
|
549
589
|
if (!context.source) {
|
|
550
|
-
|
|
590
|
+
// Archive retires its own receipts; preserve other chats' evidence for a later pattern.
|
|
591
|
+
summary.skipped += 1;
|
|
551
592
|
continue;
|
|
552
593
|
}
|
|
553
594
|
const locale = await resolveLearningLocale(context.source.candidate.locale);
|
|
@@ -568,6 +609,18 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
568
609
|
modelProfileId: resolved.profile.id,
|
|
569
610
|
accountedTokens: reservedTokens,
|
|
570
611
|
});
|
|
612
|
+
if (!(await isAiWorkflowPatternEligible(pattern))) {
|
|
613
|
+
if (runId)
|
|
614
|
+
await aiMemoryLearningRuns.finish({
|
|
615
|
+
runId,
|
|
616
|
+
status: "skipped",
|
|
617
|
+
durationMs: Date.now() - startedAt,
|
|
618
|
+
changes: [],
|
|
619
|
+
accountedTokens: 0,
|
|
620
|
+
});
|
|
621
|
+
summary.skipped += 1;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
571
624
|
if (!runId) continue;
|
|
572
625
|
const result = await structured({
|
|
573
626
|
task: "memory-learn-workflow",
|
|
@@ -586,7 +639,8 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
586
639
|
resolveModel: async () => resolved,
|
|
587
640
|
});
|
|
588
641
|
let changes: AiMemoryLearningChange[] = [];
|
|
589
|
-
|
|
642
|
+
// The write locks only the source chat; check the other examples right before it.
|
|
643
|
+
if (result.output.workflow && (await learningEnabled(pattern.userId)) && (await isAiWorkflowPatternEligible(pattern))) {
|
|
590
644
|
const memoryIds = result.output.workflow.memoryIds;
|
|
591
645
|
const action = memoryIds.length > 1 ? "merge" : memoryIds.length === 1 ? "replace" : "add";
|
|
592
646
|
changes = await applyChanges(
|
|
@@ -603,7 +657,7 @@ export const learnAiMemoriesFromPrivateChats = async (
|
|
|
603
657
|
context.mutableMemoryIds,
|
|
604
658
|
);
|
|
605
659
|
}
|
|
606
|
-
await markAiWorkflowPatternReviewed(pattern);
|
|
660
|
+
if (await isAiWorkflowPatternEligible(pattern)) await markAiWorkflowPatternReviewed(pattern);
|
|
607
661
|
await aiMemoryLearningRuns.finish({
|
|
608
662
|
runId,
|
|
609
663
|
status: changes.length > 0 ? "ok" : "skipped",
|
|
@@ -25,8 +25,8 @@ export type AiMemoryWorkflowPattern = {
|
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Persist only successful, schema-valid capability/resource receipts, and only
|
|
28
|
-
* while learning is on for the user
|
|
29
|
-
* count toward a
|
|
28
|
+
* while learning is on for the user and the chat is active: receipts from a
|
|
29
|
+
* time it was off or the chat was archived never count toward a pattern.
|
|
30
30
|
*/
|
|
31
31
|
export const recordAiMemoryWorkflowEvidence = async (input: {
|
|
32
32
|
userId: string;
|
|
@@ -70,6 +70,7 @@ export const recordAiMemoryWorkflowEvidence = async (input: {
|
|
|
70
70
|
WHERE turn.id = ${input.turnId}::uuid
|
|
71
71
|
AND conversation.id = ${input.conversationId}::uuid
|
|
72
72
|
AND conversation.created_by_user_id = ${input.userId}::uuid
|
|
73
|
+
AND conversation.archived_at IS NULL
|
|
73
74
|
AND COALESCE(prefs.memory_learning_enabled, ${AI_MEMORY_LEARNING_DEFAULT_ENABLED})
|
|
74
75
|
ON CONFLICT (turn_id, capability_id, resource_type, resource_id)
|
|
75
76
|
DO UPDATE SET resource_title = COALESCE(EXCLUDED.resource_title, ai.memory_workflow_evidence.resource_title)
|
|
@@ -96,6 +97,7 @@ export const listAiTurnWorkflowEvidence = async (userId: string, turnId: string)
|
|
|
96
97
|
WHERE evidence.user_id = ${userId}::uuid
|
|
97
98
|
AND evidence.turn_id = ${turnId}::uuid
|
|
98
99
|
AND conversation.created_by_user_id = evidence.user_id
|
|
100
|
+
AND conversation.archived_at IS NULL
|
|
99
101
|
ORDER BY evidence.capability_id, evidence.resource_type, evidence.resource_id
|
|
100
102
|
`;
|
|
101
103
|
return rows.map((row) => ({
|
|
@@ -127,6 +129,7 @@ export const listAiPendingWorkflowPatterns = async (limit = 5): Promise<AiMemory
|
|
|
127
129
|
JOIN ai.conversations conversation
|
|
128
130
|
ON conversation.id = evidence.conversation_id AND conversation.created_by_user_id = evidence.user_id
|
|
129
131
|
WHERE evidence.reviewed_at IS NULL
|
|
132
|
+
AND conversation.archived_at IS NULL
|
|
130
133
|
AND COALESCE(prefs.memory_learning_enabled, ${AI_MEMORY_LEARNING_DEFAULT_ENABLED})
|
|
131
134
|
GROUP BY evidence.user_id, evidence.capability_id, evidence.resource_type, evidence.resource_id
|
|
132
135
|
HAVING count(*) >= 3
|
|
@@ -136,14 +139,17 @@ export const listAiPendingWorkflowPatterns = async (limit = 5): Promise<AiMemory
|
|
|
136
139
|
return Promise.all(
|
|
137
140
|
groups.map(async (group) => {
|
|
138
141
|
const turns = await sql<{ turn_id: string }[]>`
|
|
139
|
-
SELECT turn_id
|
|
140
|
-
FROM ai.memory_workflow_evidence
|
|
141
|
-
|
|
142
|
-
AND
|
|
143
|
-
|
|
144
|
-
AND
|
|
145
|
-
AND
|
|
146
|
-
|
|
142
|
+
SELECT evidence.turn_id
|
|
143
|
+
FROM ai.memory_workflow_evidence evidence
|
|
144
|
+
JOIN ai.conversations conversation
|
|
145
|
+
ON conversation.id = evidence.conversation_id AND conversation.created_by_user_id = evidence.user_id
|
|
146
|
+
WHERE evidence.user_id = ${group.user_id}::uuid
|
|
147
|
+
AND evidence.capability_id = ${group.capability_id}
|
|
148
|
+
AND evidence.resource_type = ${group.resource_type}
|
|
149
|
+
AND evidence.resource_id = ${group.resource_id}
|
|
150
|
+
AND evidence.reviewed_at IS NULL
|
|
151
|
+
AND conversation.archived_at IS NULL
|
|
152
|
+
ORDER BY evidence.observed_at DESC, evidence.turn_id DESC
|
|
147
153
|
LIMIT 3
|
|
148
154
|
`;
|
|
149
155
|
return {
|
|
@@ -158,6 +164,25 @@ export const listAiPendingWorkflowPatterns = async (limit = 5): Promise<AiMemory
|
|
|
158
164
|
);
|
|
159
165
|
};
|
|
160
166
|
|
|
167
|
+
/** Every example used by the model must still belong to pending, active evidence. */
|
|
168
|
+
export const isAiWorkflowPatternEligible = async (pattern: AiMemoryWorkflowPattern): Promise<boolean> => {
|
|
169
|
+
if (pattern.turnIds.length < 3) return false;
|
|
170
|
+
const [row] = await sql<{ count: number }[]>`
|
|
171
|
+
SELECT count(*)::int AS count
|
|
172
|
+
FROM ai.memory_workflow_evidence evidence
|
|
173
|
+
JOIN ai.conversations conversation
|
|
174
|
+
ON conversation.id = evidence.conversation_id AND conversation.created_by_user_id = evidence.user_id
|
|
175
|
+
WHERE evidence.user_id = ${pattern.userId}::uuid
|
|
176
|
+
AND evidence.capability_id = ${pattern.capabilityId}
|
|
177
|
+
AND evidence.resource_type = ${pattern.resourceRef.type}
|
|
178
|
+
AND evidence.resource_id = ${pattern.resourceRef.id}
|
|
179
|
+
AND evidence.turn_id IN ${sql(pattern.turnIds)}
|
|
180
|
+
AND evidence.reviewed_at IS NULL
|
|
181
|
+
AND conversation.archived_at IS NULL
|
|
182
|
+
`;
|
|
183
|
+
return row?.count === pattern.turnIds.length;
|
|
184
|
+
};
|
|
185
|
+
|
|
161
186
|
export const markAiWorkflowPatternReviewed = async (pattern: AiMemoryWorkflowPattern): Promise<void> => {
|
|
162
187
|
await sql`
|
|
163
188
|
UPDATE ai.memory_workflow_evidence
|
|
@@ -3,7 +3,9 @@ import { z } from "zod";
|
|
|
3
3
|
import { getCapability } from "../_internal/registry";
|
|
4
4
|
import { CloudResourceRefSchema, cloudResourceRefAppId, resolveCapabilityResourceReader } from "../contracts/capabilities";
|
|
5
5
|
import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
6
|
-
import { type AuthContext, auth, err, fail, ok, rateLimit, respond, v } from "../server";
|
|
6
|
+
import { type AuthContext, auth, err, fail, getLocale, ok, rateLimit, respond, v } from "../server";
|
|
7
|
+
import { ImageMetadataError } from "../services/image-metadata";
|
|
8
|
+
import { aiChatMessages } from "./chat/messages";
|
|
7
9
|
import { decodeAiFileContent } from "./files-store";
|
|
8
10
|
import {
|
|
9
11
|
AI_PROJECT_DESCRIPTION_MAX_CHARS,
|
|
@@ -188,6 +190,8 @@ const buildAiProjectsRoutes = (dependencies: AiProjectsRouteDependencies = {}) =
|
|
|
188
190
|
: null;
|
|
189
191
|
return file ? respond(c, ok({ file: publicFile(file, project!.shortId) }), 201) : notFound(c);
|
|
190
192
|
} catch (error) {
|
|
193
|
+
if (error instanceof ImageMetadataError)
|
|
194
|
+
return respond(c, { ok: false, error: aiChatMessages(getLocale(c)).malformedImage, status: 422, code: error.code });
|
|
191
195
|
return respond(c, fail(err.badInput(error instanceof Error ? error.message : "Invalid project file.")));
|
|
192
196
|
}
|
|
193
197
|
})
|
package/src/ai/projects.ts
CHANGED
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
type PermissionLevel,
|
|
11
11
|
type Principal,
|
|
12
12
|
} from "../server/services/access";
|
|
13
|
+
import { stripImageMetadata } from "../services/image-metadata";
|
|
13
14
|
import { toPgUuidArray } from "../services/postgres";
|
|
14
15
|
import { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from "./file-content-version";
|
|
16
|
+
import { isAiImage } from "./file-media-type";
|
|
15
17
|
import { mountAiProjectFilePath } from "./file-mount";
|
|
16
18
|
import { pickStoredAiFilePath } from "./files-store";
|
|
17
19
|
import { withAiShortIdForDb } from "./short-id";
|
|
@@ -819,6 +821,8 @@ export const aiProjects = {
|
|
|
819
821
|
if (!(await requireProject(projectId, subject, "write"))) return null;
|
|
820
822
|
if (input.bytes.byteLength > AI_PROJECT_FILE_MAX_BYTES)
|
|
821
823
|
throw new AiFileWriteError("STORAGE_FULL", "Project file exceeds the size limit; nothing was written.");
|
|
824
|
+
const mimeType = input.mediaType.trim() || "application/octet-stream";
|
|
825
|
+
const bytes = isAiImage(input.path, mimeType) ? stripImageMetadata(input.bytes) : input.bytes;
|
|
822
826
|
const path = normalizeProjectPath(input.path);
|
|
823
827
|
return sql.begin(async (tx) => {
|
|
824
828
|
const [row] = await tx<ProjectRow[]>`SELECT * FROM ai.projects WHERE id=${projectId}::uuid FOR UPDATE`;
|
|
@@ -836,7 +840,7 @@ export const aiProjects = {
|
|
|
836
840
|
"idx_ai_project_files_short_id",
|
|
837
841
|
(attempt, shortId) => attempt<FileRow[]>`
|
|
838
842
|
INSERT INTO ai.project_files (short_id, project_id, path, media_type, bytes, size, created_by_user_id)
|
|
839
|
-
VALUES (${shortId}, ${projectId}::uuid, ${path}, ${
|
|
843
|
+
VALUES (${shortId}, ${projectId}::uuid, ${path}, ${mimeType}, ${bytes}, ${bytes.byteLength}, ${actorUserId(subject)}::uuid)
|
|
840
844
|
ON CONFLICT (project_id, path) DO UPDATE SET
|
|
841
845
|
media_type = EXCLUDED.media_type, bytes = EXCLUDED.bytes, size = EXCLUDED.size, updated_at = now()
|
|
842
846
|
RETURNING id, short_id, project_id, path, media_type, size, updated_at
|
package/src/ai/protocol.ts
CHANGED
|
@@ -179,6 +179,39 @@ export const reconcileResolvedTurnActions = (
|
|
|
179
179
|
});
|
|
180
180
|
};
|
|
181
181
|
|
|
182
|
+
/** Decode complete provider names; hashes and ambiguous separators have no exact inverse. */
|
|
183
|
+
const canonicalToolName = (name: string, presentation?: AiToolPresentation): string => {
|
|
184
|
+
if (name.length === 64 && /__[0-9a-f]{12}$/.test(name)) return name;
|
|
185
|
+
const decode = (value: string): string | undefined => {
|
|
186
|
+
const tokens = /[a-zA-Z0-9-]|__|_dot_|_u([0-9a-f]+)_/gy;
|
|
187
|
+
let decoded = "",
|
|
188
|
+
offset = 0;
|
|
189
|
+
while (offset < value.length) {
|
|
190
|
+
const token = tokens.exec(value);
|
|
191
|
+
if (!token) return undefined;
|
|
192
|
+
if (token[0] === "__") decoded += "_";
|
|
193
|
+
else if (token[0] === "_dot_") decoded += ".";
|
|
194
|
+
else if (token[1]) {
|
|
195
|
+
const codePoint = Number.parseInt(token[1], 16);
|
|
196
|
+
if (codePoint > 0x10ffff || codePoint.toString(16) !== token[1]) return undefined;
|
|
197
|
+
const character = String.fromCodePoint(codePoint);
|
|
198
|
+
if (/^[a-zA-Z0-9_.-]$/.test(character)) return undefined;
|
|
199
|
+
decoded += character;
|
|
200
|
+
} else decoded += token[0];
|
|
201
|
+
offset = tokens.lastIndex;
|
|
202
|
+
}
|
|
203
|
+
return decoded || undefined;
|
|
204
|
+
};
|
|
205
|
+
const candidates: string[] = [];
|
|
206
|
+
for (const match of name.matchAll(/(?=__(query|action)__)/g)) {
|
|
207
|
+
const appId = decode(name.slice(0, match.index));
|
|
208
|
+
const localId = decode(name.slice(match.index + match[1]!.length + 4));
|
|
209
|
+
if (appId && localId && (!presentation || (presentation.appId === appId && presentation.capabilityKind === match[1])))
|
|
210
|
+
candidates.push(`${appId}.${localId}`);
|
|
211
|
+
}
|
|
212
|
+
return candidates.length === 1 ? candidates[0]! : name;
|
|
213
|
+
};
|
|
214
|
+
|
|
182
215
|
/**
|
|
183
216
|
* Convert persisted loop messages into the block model. Shared by the executor
|
|
184
217
|
* (baseline rebuild on claim) and the client (rendering finished turns), so a
|
|
@@ -211,7 +244,7 @@ export const buildBlocksFromMessages = (
|
|
|
211
244
|
id: toolBlockId(block.id),
|
|
212
245
|
kind: "tool",
|
|
213
246
|
callId: block.id,
|
|
214
|
-
name: block.name,
|
|
247
|
+
name: canonicalToolName(block.name, meta?.toolPresentations?.[block.id]),
|
|
215
248
|
args: block.args,
|
|
216
249
|
status: "running",
|
|
217
250
|
presentation: meta?.toolPresentations?.[block.id],
|
package/src/ai/provider.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
import type { Provider } from "@k2b/nessi/ai";
|
|
1
|
+
import type { Provider, ReasoningEffort } from "@k2b/nessi/ai";
|
|
2
2
|
import { anthropic, gemini, mistral, ollama, openAICompatible, openai, openrouter } from "@k2b/nessi/ai";
|
|
3
3
|
import type { AiModelProfile } from "./types";
|
|
4
4
|
|
|
5
5
|
/** Bound provider stalls while allowing long-context requests up to 60s to begin streaming. */
|
|
6
6
|
const PROVIDER_TIMEOUTS = { firstByteMs: 60_000, idleMs: 60_000 } as const;
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Reasoning for structured tasks and compaction: the requests every provider accepted before Nessi 0.17. A global
|
|
10
|
+
* "none" breaks models that cannot turn reasoning off, so Anthropic, Mistral and Ollama keep their model defaults.
|
|
11
|
+
*/
|
|
12
|
+
export const taskReasoningEffort = (provider: Pick<Provider, "family">): ReasoningEffort | undefined => {
|
|
13
|
+
switch (provider.family) {
|
|
14
|
+
case "openai-compatible":
|
|
15
|
+
return "low";
|
|
16
|
+
case "gemini":
|
|
17
|
+
return "none";
|
|
18
|
+
default:
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
8
23
|
const commonOptions = (profile: AiModelProfile, apiKey?: string) => ({
|
|
9
24
|
apiKey,
|
|
10
25
|
baseURL: profile.baseURL,
|
package/src/ai/quota-provider.ts
CHANGED
|
@@ -170,7 +170,10 @@ export function inferenceProvider(
|
|
|
170
170
|
)
|
|
171
171
|
usage = { input: 0, output: 0 };
|
|
172
172
|
if (event.type === "usage") {
|
|
173
|
-
if (event.finishReason === "aborted" || event.finishReason === "interrupted" || event.finishReason === "error")
|
|
173
|
+
if (event.finishReason === "aborted" || event.finishReason === "interrupted" || event.finishReason === "error") {
|
|
174
|
+
failed = true;
|
|
175
|
+
error ??= `The provider finished with ${event.finishReason}.`;
|
|
176
|
+
}
|
|
174
177
|
const { input, output } = event.usage;
|
|
175
178
|
// Some adapters synthesize all-zero usage when the endpoint reports none.
|
|
176
179
|
if (Number.isSafeInteger(input) && Number.isSafeInteger(output) && input >= 0 && output >= 0 && input + output > 0)
|
package/src/ai/routes.ts
CHANGED
|
@@ -18,13 +18,15 @@ import {
|
|
|
18
18
|
v,
|
|
19
19
|
} from "../server";
|
|
20
20
|
import { isRequestCredentialCurrent } from "../server/middleware/auth";
|
|
21
|
+
import { ImageMetadataError } from "../services/image-metadata";
|
|
21
22
|
import { logger } from "../services/logging";
|
|
22
23
|
import { coreSettings } from "../services/settings/api";
|
|
23
24
|
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
24
25
|
import type { AiToolApprovalContext } from "./approvals";
|
|
25
|
-
import { assistantAiSettingsState, listAssistantAiModels, selectAssistantAiModelId } from "./assistant-models";
|
|
26
|
+
import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
|
|
26
27
|
import { AI_AUDIO_MAX_BYTES } from "./audio-format";
|
|
27
28
|
import { buildAiCapabilityCatalog } from "./capabilities";
|
|
29
|
+
import { aiChatMessages } from "./chat/messages";
|
|
28
30
|
import { getAiChatQuotas } from "./chat-quotas";
|
|
29
31
|
import { createConfiguredDefaultCloudAiTools } from "./default-tools";
|
|
30
32
|
import { enqueueAiDictation } from "./dictation-runtime";
|
|
@@ -58,7 +60,6 @@ import {
|
|
|
58
60
|
queuedMessageAccepted,
|
|
59
61
|
updateQueuedMessage,
|
|
60
62
|
} from "./message-queue";
|
|
61
|
-
import { aiModelAccess } from "./model-access";
|
|
62
63
|
import { personalAiModelPolicy } from "./personal-agent";
|
|
63
64
|
import { aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
|
|
64
65
|
import { aiProjects } from "./projects";
|
|
@@ -84,7 +85,6 @@ import { aiConversations } from "./store";
|
|
|
84
85
|
import { createAiConversationStreamResponse, loadAiStreamState } from "./stream";
|
|
85
86
|
import { composeAiSystemPrompt } from "./system-prompt";
|
|
86
87
|
import { aiToolPromptHints } from "./tools";
|
|
87
|
-
import { resolveAiAudioModel } from "./transcription";
|
|
88
88
|
import type { AiConversation, AiModelPolicy, AiTurn } from "./types";
|
|
89
89
|
|
|
90
90
|
/** Everything a resolved, authorized request needs to run against the shared runtime. */
|
|
@@ -420,7 +420,10 @@ export const aiRoutes = (() => {
|
|
|
420
420
|
const skillCatalog = selectAiSkillCatalog(availableSkills, previewProfile?.contextWindow ?? 0, "");
|
|
421
421
|
const tools = toolsSupported
|
|
422
422
|
? [
|
|
423
|
-
...(await createConfiguredDefaultCloudAiTools(
|
|
423
|
+
...(await createConfiguredDefaultCloudAiTools({
|
|
424
|
+
accessSubject: c.get("accessSubject"),
|
|
425
|
+
allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries,
|
|
426
|
+
})),
|
|
424
427
|
...(memoryEnabled ? [createCloudAiMemoryTool()] : []),
|
|
425
428
|
...(availableSkills.length ? [createCloudAiLoadSkillTool(c.get("accessSubject"))] : []),
|
|
426
429
|
...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(c.get("accessSubject"))] : []),
|
|
@@ -526,7 +529,14 @@ export const aiRoutes = (() => {
|
|
|
526
529
|
return respond(c, fail(err.internal("The live capability catalog is unavailable.")));
|
|
527
530
|
}
|
|
528
531
|
}
|
|
529
|
-
const builtInNames = new Set(
|
|
532
|
+
const builtInNames = new Set(
|
|
533
|
+
(
|
|
534
|
+
await createConfiguredDefaultCloudAiTools({
|
|
535
|
+
accessSubject: c.get("accessSubject"),
|
|
536
|
+
allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries,
|
|
537
|
+
})
|
|
538
|
+
).map((tool) => tool.def.name),
|
|
539
|
+
);
|
|
530
540
|
const preloadTools = (body.preloadTools ?? []).map((requested) => {
|
|
531
541
|
if ("name" in requested) return builtInNames.has(requested.name) ? requested.name : null;
|
|
532
542
|
return (
|
|
@@ -1178,11 +1188,7 @@ export const aiRoutes = (() => {
|
|
|
1178
1188
|
conversationId: conversation.id,
|
|
1179
1189
|
userId: ctx.ownerUserId,
|
|
1180
1190
|
bytes: new Uint8Array(await file.arrayBuffer()),
|
|
1181
|
-
resolveModel:
|
|
1182
|
-
const model = await resolveAiAudioModel({ allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries });
|
|
1183
|
-
await aiModelAccess.assertAllowed(model.profile.id, c.get("accessSubject"));
|
|
1184
|
-
return model;
|
|
1185
|
-
},
|
|
1191
|
+
resolveModel: () => resolveAssistantAudioModel(c.get("accessSubject"), ctx.modelPolicy.allowedDataBoundaries),
|
|
1186
1192
|
});
|
|
1187
1193
|
await enqueueAiDictation(dictation.id).catch(() => undefined); // The DB sweep heals failed delivery.
|
|
1188
1194
|
return respond(c, ok(dictation));
|
|
@@ -1312,6 +1318,8 @@ export const aiRoutes = (() => {
|
|
|
1312
1318
|
});
|
|
1313
1319
|
return respond(c, ok({ file: stat }));
|
|
1314
1320
|
} catch (error) {
|
|
1321
|
+
if (error instanceof ImageMetadataError)
|
|
1322
|
+
return respond(c, { ok: false, error: aiChatMessages(getLocale(c)).malformedImage, status: 422, code: error.code });
|
|
1315
1323
|
logger("ai:files").warn("Conversation upload failed", {
|
|
1316
1324
|
code: "file_upload_failed",
|
|
1317
1325
|
conversationId: conversation.id,
|
|
@@ -1372,6 +1380,8 @@ export const aiRoutes = (() => {
|
|
|
1372
1380
|
allowUserOverwrite: existing?.origin === "user",
|
|
1373
1381
|
});
|
|
1374
1382
|
} catch (error) {
|
|
1383
|
+
if (error instanceof ImageMetadataError)
|
|
1384
|
+
return respond(c, { ok: false, error: aiChatMessages(getLocale(c)).malformedImage, status: 422, code: error.code });
|
|
1375
1385
|
return respond(c, fail(err.badInput(error instanceof Error ? error.message : "Failed to write file")));
|
|
1376
1386
|
}
|
|
1377
1387
|
return respond(c, ok({ file: await aiFileStore.stat({ conversationId: conversation.id, path }) }));
|