@k2b/cloud 0.28.0 → 0.29.1
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 +9 -6
- 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 +259 -197
- 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 +16 -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 +20 -24
- package/src/ai/chat/presentation.tsx +1 -8
- package/src/ai/chat/tool-disclosure.tsx +21 -14
- package/src/ai/chat/tool-groups.ts +64 -37
- package/src/ai/chat/tool-names.ts +157 -0
- package/src/ai/chat/turn-layout.ts +14 -2
- package/src/ai/chat/turn-view.tsx +132 -70
- 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 +43 -5
- 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 +76 -33
- 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 +3 -1
- package/src/ai/ui.tsx +1 -0
- package/src/api/admin-core-settings.ts +16 -1
- package/src/api/admin-outgoing-mail.ts +18 -0
- 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/cli/admin/outgoing-mail.ts +30 -2
- 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/outgoing-mail.ts +56 -11
- 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/outbox.ts +24 -9
- package/src/services/outgoing-mail/index.ts +33 -5
- package/src/services/outgoing-mail/messages.ts +60 -19
- package/src/services/outgoing-mail/store.ts +63 -4
- 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/network-address.ts +1 -0
- 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 +165 -0
- package/src/styles/file-chooser.css +25 -0
- package/src/styles/utilities-markdown-table.css +5 -1
- package/src/styles/utilities-navigation.css +41 -0
|
@@ -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
|
@@ -45,7 +45,10 @@ export type AiTurnBlock =
|
|
|
45
45
|
isError?: boolean;
|
|
46
46
|
/** Present while status is awaiting_approval. */
|
|
47
47
|
approval?: { message?: string; review?: CapabilityActionReview; allowAlways: boolean };
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* `true` once the user approved this call in the chat; `false` when its turn ended while the approval still
|
|
50
|
+
* waited. Either way the approval stays visible as a receipt.
|
|
51
|
+
*/
|
|
49
52
|
approved?: boolean;
|
|
50
53
|
/** Present for frontend tools. */
|
|
51
54
|
frontendMode?: AiFrontendToolMode;
|
|
@@ -179,6 +182,39 @@ export const reconcileResolvedTurnActions = (
|
|
|
179
182
|
});
|
|
180
183
|
};
|
|
181
184
|
|
|
185
|
+
/** Decode complete provider names; hashes and ambiguous separators have no exact inverse. */
|
|
186
|
+
const canonicalToolName = (name: string, presentation?: AiToolPresentation): string => {
|
|
187
|
+
if (name.length === 64 && /__[0-9a-f]{12}$/.test(name)) return name;
|
|
188
|
+
const decode = (value: string): string | undefined => {
|
|
189
|
+
const tokens = /[a-zA-Z0-9-]|__|_dot_|_u([0-9a-f]+)_/gy;
|
|
190
|
+
let decoded = "",
|
|
191
|
+
offset = 0;
|
|
192
|
+
while (offset < value.length) {
|
|
193
|
+
const token = tokens.exec(value);
|
|
194
|
+
if (!token) return undefined;
|
|
195
|
+
if (token[0] === "__") decoded += "_";
|
|
196
|
+
else if (token[0] === "_dot_") decoded += ".";
|
|
197
|
+
else if (token[1]) {
|
|
198
|
+
const codePoint = Number.parseInt(token[1], 16);
|
|
199
|
+
if (codePoint > 0x10ffff || codePoint.toString(16) !== token[1]) return undefined;
|
|
200
|
+
const character = String.fromCodePoint(codePoint);
|
|
201
|
+
if (/^[a-zA-Z0-9_.-]$/.test(character)) return undefined;
|
|
202
|
+
decoded += character;
|
|
203
|
+
} else decoded += token[0];
|
|
204
|
+
offset = tokens.lastIndex;
|
|
205
|
+
}
|
|
206
|
+
return decoded || undefined;
|
|
207
|
+
};
|
|
208
|
+
const candidates: string[] = [];
|
|
209
|
+
for (const match of name.matchAll(/(?=__(query|action)__)/g)) {
|
|
210
|
+
const appId = decode(name.slice(0, match.index));
|
|
211
|
+
const localId = decode(name.slice(match.index + match[1]!.length + 4));
|
|
212
|
+
if (appId && localId && (!presentation || (presentation.appId === appId && presentation.capabilityKind === match[1])))
|
|
213
|
+
candidates.push(`${appId}.${localId}`);
|
|
214
|
+
}
|
|
215
|
+
return candidates.length === 1 ? candidates[0]! : name;
|
|
216
|
+
};
|
|
217
|
+
|
|
182
218
|
/**
|
|
183
219
|
* Convert persisted loop messages into the block model. Shared by the executor
|
|
184
220
|
* (baseline rebuild on claim) and the client (rendering finished turns), so a
|
|
@@ -191,7 +227,7 @@ export const buildBlocksFromMessages = (
|
|
|
191
227
|
meta?: {
|
|
192
228
|
steerId?: string;
|
|
193
229
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
194
|
-
toolOutcomes?: Record<string, "rejected" | "approved">;
|
|
230
|
+
toolOutcomes?: Record<string, "rejected" | "approved" | "expired">;
|
|
195
231
|
} | null;
|
|
196
232
|
}[],
|
|
197
233
|
): AiTurnBlock[] => {
|
|
@@ -206,17 +242,19 @@ export const buildBlocksFromMessages = (
|
|
|
206
242
|
} else if (block.type === "thinking") {
|
|
207
243
|
blocks.push({ id: messageBlockId(seq, index), kind: "thinking", text: block.thinking });
|
|
208
244
|
} else if (block.type === "tool_call") {
|
|
245
|
+
const outcome = meta?.toolOutcomes?.[block.id];
|
|
209
246
|
toolIndex.set(block.id, blocks.length);
|
|
210
247
|
blocks.push({
|
|
211
248
|
id: toolBlockId(block.id),
|
|
212
249
|
kind: "tool",
|
|
213
250
|
callId: block.id,
|
|
214
|
-
name: block.name,
|
|
251
|
+
name: canonicalToolName(block.name, meta?.toolPresentations?.[block.id]),
|
|
215
252
|
args: block.args,
|
|
216
253
|
status: "running",
|
|
217
254
|
presentation: meta?.toolPresentations?.[block.id],
|
|
218
|
-
// A turn that ended before an approved call returned records the approval on the call's message
|
|
219
|
-
|
|
255
|
+
// A turn that ended before an approved call returned records the approval on the call's message, and an
|
|
256
|
+
// approval it left waiting as expired.
|
|
257
|
+
...(outcome === "approved" || outcome === "expired" ? { approved: outcome === "approved" } : {}),
|
|
220
258
|
});
|
|
221
259
|
}
|
|
222
260
|
});
|
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 }) }));
|
package/src/ai/settings.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { coreSettings } from "../services";
|
|
3
|
+
import { stripImageDataUrlMetadata } from "../services/image-metadata";
|
|
3
4
|
import { AiModelPricingSchema } from "../shared/ai-costs";
|
|
4
5
|
import {
|
|
5
6
|
AI_REQUEST_HEADERS_PROVIDER_ERROR,
|
|
@@ -152,6 +153,18 @@ const normalizeProfile = (raw: z.infer<typeof ModelProfileSchema>): AiModelProfi
|
|
|
152
153
|
};
|
|
153
154
|
};
|
|
154
155
|
|
|
156
|
+
/** Sanitize every submitted model logo, including unchanged images from stored profiles. */
|
|
157
|
+
export const prepareAiModelProfileImages = (rawJson: string): string => {
|
|
158
|
+
const raw: unknown = JSON.parse(rawJson);
|
|
159
|
+
if (!Array.isArray(raw)) return rawJson;
|
|
160
|
+
return JSON.stringify(
|
|
161
|
+
raw.map((profile: unknown) => {
|
|
162
|
+
if (typeof profile !== "object" || profile === null || !("image" in profile) || typeof profile.image !== "string") return profile;
|
|
163
|
+
return { ...profile, image: stripImageDataUrlMetadata(profile.image) };
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
};
|
|
167
|
+
|
|
155
168
|
export const parseAiModelProfiles = (rawJson: string): { profiles: AiModelProfile[]; error?: AiSettingsError } => {
|
|
156
169
|
let raw: unknown;
|
|
157
170
|
try {
|
|
@@ -468,13 +481,18 @@ export const selectAiModelProfile = (
|
|
|
468
481
|
const modelId = resolvePolicyModelId(state, policy, requestedModelId);
|
|
469
482
|
const profile = state.profiles.find((candidate) => candidate.id === modelId);
|
|
470
483
|
if (!profile || !matchesPolicy(profile, policy)) {
|
|
471
|
-
throw Object.assign(
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
484
|
+
throw Object.assign(
|
|
485
|
+
new Error(
|
|
486
|
+
`AI model "${modelId}" is unavailable or not allowed for this chat or data boundary. An administrator can enable a compatible model permitted by the application policy in the AI settings.`,
|
|
487
|
+
),
|
|
488
|
+
{
|
|
489
|
+
aiError: {
|
|
490
|
+
code: "model_policy_mismatch",
|
|
491
|
+
message: `AI model "${modelId}" is unavailable or not allowed for this chat or data boundary. An administrator can enable a compatible model permitted by the application policy in the AI settings.`,
|
|
492
|
+
fields: { modelProfileId: "Choose an allowed enabled model profile." },
|
|
493
|
+
} satisfies AiSettingsError,
|
|
494
|
+
},
|
|
495
|
+
);
|
|
478
496
|
}
|
|
479
497
|
return profile;
|
|
480
498
|
};
|
|
@@ -497,8 +515,19 @@ const visionModelPolicy = (modelId: string, allowedDataBoundaries?: AiDataBounda
|
|
|
497
515
|
export const resolveAiVisionModel = async (allowedDataBoundaries?: AiDataBoundary[]): Promise<AiResolvedModel> => {
|
|
498
516
|
const state = await readAiSettingsState();
|
|
499
517
|
if (!state.ok) throw Object.assign(new Error(state.error.message), { aiError: state.error });
|
|
518
|
+
if (!state.enabled) {
|
|
519
|
+
throw Object.assign(new Error("AI is disabled. An administrator can enable AI in the AI settings."), {
|
|
520
|
+
aiError: {
|
|
521
|
+
code: "ai_disabled",
|
|
522
|
+
message: "AI is disabled. An administrator can enable AI in the AI settings.",
|
|
523
|
+
} satisfies AiSettingsError,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
500
526
|
const modelId = (state.visionModelId ?? "").trim();
|
|
501
|
-
if (!modelId)
|
|
527
|
+
if (!modelId)
|
|
528
|
+
throw new Error(
|
|
529
|
+
"Image inspection is not set up: no vision model is configured. An administrator can configure a vision model in the AI settings.",
|
|
530
|
+
);
|
|
502
531
|
return resolveAiModelFromState(state, visionModelPolicy(modelId, allowedDataBoundaries));
|
|
503
532
|
};
|
|
504
533
|
|
|
@@ -520,8 +549,11 @@ export const resolveAiModelFromState = async (
|
|
|
520
549
|
): Promise<AiResolvedModel> => {
|
|
521
550
|
if (!state.ok) throw Object.assign(new Error(state.error.message), { aiError: state.error });
|
|
522
551
|
if (!state.enabled) {
|
|
523
|
-
throw Object.assign(new Error("AI is disabled."), {
|
|
524
|
-
aiError: {
|
|
552
|
+
throw Object.assign(new Error("AI is disabled. An administrator can enable AI in the AI settings."), {
|
|
553
|
+
aiError: {
|
|
554
|
+
code: "ai_disabled",
|
|
555
|
+
message: "AI is disabled. An administrator can enable AI in the AI settings.",
|
|
556
|
+
} satisfies AiSettingsError,
|
|
525
557
|
});
|
|
526
558
|
}
|
|
527
559
|
|
|
@@ -529,13 +561,18 @@ export const resolveAiModelFromState = async (
|
|
|
529
561
|
|
|
530
562
|
const credential = providerSupportsCredential(profile.provider) ? await getAiCredential(profile.id) : null;
|
|
531
563
|
if (providerRequiresCredential(profile.provider) && !credential?.trim()) {
|
|
532
|
-
throw Object.assign(
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
564
|
+
throw Object.assign(
|
|
565
|
+
new Error(
|
|
566
|
+
`AI model "${profile.id}" is missing provider credentials. An administrator can add the provider API key in the AI settings.`,
|
|
567
|
+
),
|
|
568
|
+
{
|
|
569
|
+
aiError: {
|
|
570
|
+
code: "missing_provider_credential",
|
|
571
|
+
message: `AI model "${profile.id}" is missing provider credentials. An administrator can add the provider API key in the AI settings.`,
|
|
572
|
+
fields: { "ai.model_profiles_json": "Enter the provider API key on this model profile." },
|
|
573
|
+
} satisfies AiSettingsError,
|
|
574
|
+
},
|
|
575
|
+
);
|
|
539
576
|
}
|
|
540
577
|
|
|
541
578
|
const headers = providerSupportsRequestHeaders(profile.provider) ? await getAiRequestHeaders(profile.id) : {};
|