@k2b/cloud 0.26.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/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/browser-code-contracts.ts +14 -2
- package/src/ai/browser.ts +8 -1
- package/src/ai/capabilities.ts +82 -22
- package/src/ai/chat/builtin-tools.tsx +31 -23
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +11 -4
- package/src/ai/chat/messages.ts +8 -2
- package/src/ai/chat/presentation.tsx +29 -9
- package/src/ai/chat/turn-view.tsx +47 -12
- package/src/ai/client/controller.ts +60 -31
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +7 -2
- 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 +148 -84
- 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 +6 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +2 -2
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +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 +109 -57
- package/src/ai/stream.ts +180 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +25 -0
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-policy.ts +247 -0
- package/src/ai/types.ts +24 -3
- 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 +2 -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/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/workspace-navigation.ts +7 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { toPgTextArray } from "../services/postgres";
|
|
3
|
+
import { decryptValue, encryptValue } from "../services/settings/crypto";
|
|
4
|
+
import {
|
|
5
|
+
AI_REQUEST_HEADERS_PROVIDER_ERROR,
|
|
6
|
+
AiRequestHeadersSchema,
|
|
7
|
+
patchAiRequestHeaders,
|
|
8
|
+
providerSupportsRequestHeaders,
|
|
9
|
+
} from "../shared/ai-request-options";
|
|
10
|
+
import type { AiModelProfile } from "./types";
|
|
11
|
+
|
|
12
|
+
type SqlClient = typeof sql;
|
|
13
|
+
export type AiRequestHeaderPatch = { profileId: string; patch: unknown };
|
|
14
|
+
|
|
15
|
+
const decryptHeaders = async (profileId: string, secret: string): Promise<Record<string, string>> => {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = AiRequestHeadersSchema.safeParse(await decryptValue(secret));
|
|
18
|
+
if (parsed.success && Object.values(parsed.data).every((value) => typeof value === "string"))
|
|
19
|
+
return patchAiRequestHeaders({}, parsed.data);
|
|
20
|
+
} catch {}
|
|
21
|
+
console.warn(`[ai] ignoring unreadable request headers for profile ${JSON.stringify(profileId)}`);
|
|
22
|
+
return {};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Secrets stay server-side, in a separate encrypted row like provider API keys. */
|
|
26
|
+
export const getAiRequestHeaders = async (profileId: string, db: SqlClient = sql): Promise<Record<string, string>> => {
|
|
27
|
+
const [row] = await db<{ secret: string }[]>`SELECT secret FROM ai.model_request_headers WHERE profile_id = ${profileId}`;
|
|
28
|
+
return row ? decryptHeaders(profileId, row.secret) : {};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const setAiRequestHeaders = async (profileId: string, headers: Record<string, string>, db: SqlClient = sql): Promise<void> => {
|
|
32
|
+
const normalized = patchAiRequestHeaders({}, headers);
|
|
33
|
+
if (!Object.keys(normalized).length) {
|
|
34
|
+
await db`DELETE FROM ai.model_request_headers WHERE profile_id = ${profileId}`;
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const encrypted = await encryptValue(normalized);
|
|
38
|
+
await db`INSERT INTO ai.model_request_headers (profile_id, secret) VALUES (${profileId}, ${encrypted})
|
|
39
|
+
ON CONFLICT (profile_id) DO UPDATE SET secret = EXCLUDED.secret, updated_at = now()`;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Admin reads contain names only. Never serialize header values into profiles. */
|
|
43
|
+
export const listAiRequestHeaderNames = async (db: SqlClient = sql): Promise<Record<string, string[]>> => {
|
|
44
|
+
const rows = await db<{ profile_id: string; secret: string }[]>`SELECT profile_id, secret FROM ai.model_request_headers`;
|
|
45
|
+
const names: Record<string, string[]> = {};
|
|
46
|
+
for (const row of rows)
|
|
47
|
+
Object.defineProperty(names, row.profile_id, {
|
|
48
|
+
value: Object.keys(await decryptHeaders(row.profile_id, row.secret)).sort(),
|
|
49
|
+
enumerable: true,
|
|
50
|
+
});
|
|
51
|
+
return names;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const pruneAiRequestHeaders = async (keepProfileIds: readonly string[], db: SqlClient = sql): Promise<void> => {
|
|
55
|
+
if (!keepProfileIds.length) await db`DELETE FROM ai.model_request_headers`;
|
|
56
|
+
else await db`DELETE FROM ai.model_request_headers WHERE profile_id <> ALL(${toPgTextArray([...keepProfileIds])}::text[])`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Follow credentials: changing provider discards stored secrets; omission preserves them on the same provider. */
|
|
60
|
+
export const planAiProfileRequestHeaders = (input: {
|
|
61
|
+
currentProfiles: readonly AiModelProfile[];
|
|
62
|
+
nextProfiles: readonly AiModelProfile[];
|
|
63
|
+
existingNames: Record<string, string[]>;
|
|
64
|
+
submitted: readonly AiRequestHeaderPatch[];
|
|
65
|
+
}): { keepHeaderProfileIds: string[]; patches: { profileId: string; patch: Record<string, string | null> }[]; error?: string } => {
|
|
66
|
+
const current = new Map(input.currentProfiles.map((profile) => [profile.id, profile]));
|
|
67
|
+
const keepHeaderProfileIds = input.nextProfiles
|
|
68
|
+
.filter(
|
|
69
|
+
(profile) =>
|
|
70
|
+
providerSupportsRequestHeaders(profile.provider) &&
|
|
71
|
+
current.get(profile.id)?.provider === profile.provider &&
|
|
72
|
+
!profile.capabilities.includes("transcription"),
|
|
73
|
+
)
|
|
74
|
+
.map((profile) => profile.id);
|
|
75
|
+
const patches: { profileId: string; patch: Record<string, string | null> }[] = [];
|
|
76
|
+
for (const submitted of input.submitted) {
|
|
77
|
+
const profile = input.nextProfiles.find((profile) => profile.id === submitted.profileId);
|
|
78
|
+
const parsed = AiRequestHeadersSchema.safeParse(submitted.patch);
|
|
79
|
+
if (!parsed.success)
|
|
80
|
+
return { keepHeaderProfileIds: [], patches: [], error: `requestHeaders: ${zodHeaderMessage(parsed.error.issues)}` };
|
|
81
|
+
if (!profile || !providerSupportsRequestHeaders(profile.provider))
|
|
82
|
+
return { keepHeaderProfileIds: [], patches: [], error: AI_REQUEST_HEADERS_PROVIDER_ERROR };
|
|
83
|
+
if (profile.capabilities.includes("transcription"))
|
|
84
|
+
return {
|
|
85
|
+
keepHeaderProfileIds: [],
|
|
86
|
+
patches: [],
|
|
87
|
+
error: "requestHeaders: Request settings are not supported on transcription profiles.",
|
|
88
|
+
};
|
|
89
|
+
const oldNames =
|
|
90
|
+
keepHeaderProfileIds.includes(profile.id) && Object.hasOwn(input.existingNames, profile.id) ? input.existingNames[profile.id]! : [];
|
|
91
|
+
try {
|
|
92
|
+
patchAiRequestHeaders(Object.fromEntries(oldNames.map((name) => [name, ""])), parsed.data);
|
|
93
|
+
} catch {
|
|
94
|
+
return {
|
|
95
|
+
keepHeaderProfileIds: [],
|
|
96
|
+
patches: [],
|
|
97
|
+
error: "requestHeaders: At most 32 extra headers are allowed after applying the patch.",
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
patches.push({ profileId: profile.id, patch: parsed.data });
|
|
101
|
+
}
|
|
102
|
+
return { keepHeaderProfileIds, patches };
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const zodHeaderMessage = (issues: readonly { path: readonly PropertyKey[]; message: string }[]) =>
|
|
106
|
+
issues.map((issue) => `${issue.path.map(String).join(".") || "headers"}: ${issue.message}`).join("; ");
|
|
107
|
+
|
|
108
|
+
/** Apply after pruning so a provider change cannot inherit old secrets. Call inside the settings transaction. */
|
|
109
|
+
export const storeAiRequestHeaderPlan = async (
|
|
110
|
+
plan: ReturnType<typeof planAiProfileRequestHeaders>,
|
|
111
|
+
db: SqlClient = sql,
|
|
112
|
+
): Promise<void> => {
|
|
113
|
+
if (plan.error) throw new Error(plan.error);
|
|
114
|
+
await pruneAiRequestHeaders(plan.keepHeaderProfileIds, db);
|
|
115
|
+
for (const { profileId, patch } of plan.patches)
|
|
116
|
+
await setAiRequestHeaders(profileId, patchAiRequestHeaders(await getAiRequestHeaders(profileId, db), patch), db);
|
|
117
|
+
};
|
package/src/ai/routes.ts
CHANGED
|
@@ -168,10 +168,26 @@ const resourcesQuerySchema = (scope: "conversation" | "user") =>
|
|
|
168
168
|
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
169
169
|
});
|
|
170
170
|
const ConversationResourcesQuerySchema = resourcesQuerySchema("conversation");
|
|
171
|
+
const ConversationSourcesQuerySchema = ConversationResourcesQuerySchema.extend({
|
|
172
|
+
/** Kinds separated by commas, for example `web,activity,resource`. */
|
|
173
|
+
kind: z
|
|
174
|
+
.string()
|
|
175
|
+
.max(64)
|
|
176
|
+
.transform((value) => value.split(","))
|
|
177
|
+
.pipe(z.array(z.enum(["result", "web", "file", "resource", "activity"])).min(1))
|
|
178
|
+
.optional(),
|
|
179
|
+
observed: z.enum(["true", "false"]).optional(),
|
|
180
|
+
});
|
|
171
181
|
const UserResourcesQuerySchema = resourcesQuerySchema("user");
|
|
172
182
|
|
|
173
|
-
const FilesListQuerySchema = z.object({
|
|
183
|
+
const FilesListQuerySchema = z.object({
|
|
184
|
+
prefix: z.string().optional(),
|
|
185
|
+
/** Pages by path: pass the last path of a page as `after`. Without `limit`, every file comes newest first. */
|
|
186
|
+
limit: z.coerce.number().int().min(1).max(1000).optional(),
|
|
187
|
+
after: z.string().max(4096).optional(),
|
|
188
|
+
});
|
|
174
189
|
const FilePathQuerySchema = z.object({ path: z.string().min(1) });
|
|
190
|
+
const FileDeleteQuerySchema = FilePathQuerySchema.extend({ recursive: z.enum(["true", "false"]).optional() });
|
|
175
191
|
const FileWriteSchema = z.object({
|
|
176
192
|
path: z.string().min(1),
|
|
177
193
|
content: z.string().max(12_000_000),
|
|
@@ -425,6 +441,7 @@ export const aiRoutes = (() => {
|
|
|
425
441
|
memory: memory?.text,
|
|
426
442
|
timeZone,
|
|
427
443
|
locale: promptLocale,
|
|
444
|
+
skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
|
|
428
445
|
});
|
|
429
446
|
return respond(c, ok({ prompt, renderedAt: new Date().toISOString() }));
|
|
430
447
|
})
|
|
@@ -629,7 +646,7 @@ export const aiRoutes = (() => {
|
|
|
629
646
|
),
|
|
630
647
|
);
|
|
631
648
|
})
|
|
632
|
-
.get("/conversations/:conversationId/sources", v("query",
|
|
649
|
+
.get("/conversations/:conversationId/sources", v("query", ConversationSourcesQuerySchema), async (c) => {
|
|
633
650
|
const ctx = await resolveContext(c);
|
|
634
651
|
if (ctx instanceof Response) return ctx;
|
|
635
652
|
const conversation = await loadConversation(c, ctx);
|
|
@@ -643,6 +660,8 @@ export const aiRoutes = (() => {
|
|
|
643
660
|
search: query.q,
|
|
644
661
|
before: query.cursor,
|
|
645
662
|
limit: query.limit,
|
|
663
|
+
kinds: query.kind,
|
|
664
|
+
observed: query.observed === "true",
|
|
646
665
|
}),
|
|
647
666
|
),
|
|
648
667
|
);
|
|
@@ -1127,7 +1146,12 @@ export const aiRoutes = (() => {
|
|
|
1127
1146
|
if (ctx instanceof Response) return ctx;
|
|
1128
1147
|
const conversation = await loadConversation(c, ctx);
|
|
1129
1148
|
if (!conversation) return notFound(c);
|
|
1130
|
-
const
|
|
1149
|
+
const query = c.req.valid("query");
|
|
1150
|
+
const files = await aiFileStore.list({
|
|
1151
|
+
conversationId: conversation.id,
|
|
1152
|
+
prefix: query.prefix ?? "/",
|
|
1153
|
+
...(query.limit ? { limit: query.limit, after: query.after } : {}),
|
|
1154
|
+
});
|
|
1131
1155
|
return respond(c, ok({ files, totalBytes: await aiFileStore.totalBytes(conversation.id) }));
|
|
1132
1156
|
})
|
|
1133
1157
|
.post("/conversations/:conversationId/dictations", bodyLimit({ maxSize: AI_AUDIO_MAX_BYTES + 65_536 }), async (c) => {
|
|
@@ -1307,14 +1331,18 @@ export const aiRoutes = (() => {
|
|
|
1307
1331
|
"Cache-Control": "private, no-store",
|
|
1308
1332
|
});
|
|
1309
1333
|
})
|
|
1310
|
-
.delete("/conversations/:conversationId/files", v("query",
|
|
1334
|
+
.delete("/conversations/:conversationId/files", v("query", FileDeleteQuerySchema), async (c) => {
|
|
1311
1335
|
const ctx = await resolveContext(c);
|
|
1312
1336
|
if (ctx instanceof Response) return ctx;
|
|
1313
1337
|
const conversation = await loadConversation(c, ctx);
|
|
1314
1338
|
if (!conversation) return notFound(c);
|
|
1315
|
-
const
|
|
1339
|
+
const query = c.req.valid("query");
|
|
1340
|
+
const path = normalizeAiFilePath(query.path);
|
|
1316
1341
|
if (!path) return fileNotFound(c);
|
|
1317
|
-
|
|
1342
|
+
// A folder goes with everything below it, never the whole chat at once.
|
|
1343
|
+
const recursive = query.recursive === "true";
|
|
1344
|
+
if (recursive && path === "/") return respond(c, fail(err.badInput("Choose a folder below /.")));
|
|
1345
|
+
const removed = await aiFileStore.remove({ conversationId: conversation.id, path, recursive });
|
|
1318
1346
|
if (removed === 0) return fileNotFound(c);
|
|
1319
1347
|
return respond(c, ok({ deleted: true }));
|
|
1320
1348
|
})
|
package/src/ai/runtime.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { startAiDictationRuntime } from "./dictation-runtime";
|
|
|
13
13
|
import { AiTurnExecutor } from "./executor";
|
|
14
14
|
import { canonicalizeAiConversationAttachments, snapshotAiConversationFiles } from "./file-context";
|
|
15
15
|
import { drainQueuedMessages } from "./message-queue";
|
|
16
|
+
import { AI_TURN_LEASE_MS } from "./protocol";
|
|
16
17
|
import { aiQuotas } from "./quotas";
|
|
17
18
|
import { parseAiResourceMarker } from "./resource-markers";
|
|
18
19
|
import { isAiVisionModelConfigured } from "./settings";
|
|
@@ -43,7 +44,6 @@ export { isAiSettingsError, validateAiTurnRequest } from "./validate";
|
|
|
43
44
|
const log = logger("ai:runtime");
|
|
44
45
|
|
|
45
46
|
const AI_WORKER_ID = `worker-${crypto.randomUUID()}`;
|
|
46
|
-
const AI_TURN_LEASE_MS = 45_000;
|
|
47
47
|
const AI_TURN_HEARTBEAT_MS = 3_000;
|
|
48
48
|
const AI_TURN_WORKER_CONCURRENCY = 8;
|
|
49
49
|
const AI_TURN_MAX_ATTEMPTS = 5;
|
package/src/ai/settings.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { coreSettings } from "../services";
|
|
3
3
|
import { AiModelPricingSchema } from "../shared/ai-costs";
|
|
4
|
+
import {
|
|
5
|
+
AI_REQUEST_HEADERS_PROVIDER_ERROR,
|
|
6
|
+
AiExtraBodySchema,
|
|
7
|
+
AiReasoningEffortSchema,
|
|
8
|
+
AiRequestHeadersSchema,
|
|
9
|
+
providerSupportsRequestHeaders,
|
|
10
|
+
} from "../shared/ai-request-options";
|
|
4
11
|
import { getAiCredential, listAiCredentialProfileIds } from "./credentials";
|
|
5
12
|
import { AI_FIRECRAWL_API_KEY_SETTING_KEY } from "./firecrawl-tools";
|
|
6
13
|
import { createAiProvider } from "./provider";
|
|
14
|
+
import { getAiRequestHeaders } from "./request-headers";
|
|
7
15
|
import {
|
|
8
16
|
AI_DATA_BOUNDARIES,
|
|
9
17
|
AI_MODEL_CAPABILITIES,
|
|
@@ -93,12 +101,20 @@ const ModelProfileSchema = z
|
|
|
93
101
|
contextWindow: z.number().int().positive().optional(),
|
|
94
102
|
temperature: z.number().min(0).max(2).optional(),
|
|
95
103
|
maxOutputTokens: z.number().int().positive().optional(),
|
|
104
|
+
reasoningEffort: AiReasoningEffortSchema,
|
|
105
|
+
extraBody: AiExtraBodySchema.optional(),
|
|
106
|
+
requestHeaders: AiRequestHeadersSchema.optional(),
|
|
96
107
|
maxLoadedTools: z.number().int().optional(),
|
|
97
108
|
maxToolRounds: z.number().int().optional(),
|
|
98
109
|
pricing: AiModelPricingSchema.optional(),
|
|
99
110
|
})
|
|
100
111
|
.superRefine((profile, ctx) => {
|
|
112
|
+
if (profile.requestHeaders !== undefined && !providerSupportsRequestHeaders(profile.provider))
|
|
113
|
+
ctx.addIssue({ code: "custom", path: ["requestHeaders"], message: AI_REQUEST_HEADERS_PROVIDER_ERROR });
|
|
101
114
|
if (profile.capabilities?.includes("transcription")) {
|
|
115
|
+
for (const key of ["reasoningEffort", "extraBody", "requestHeaders"] as const)
|
|
116
|
+
if (profile[key] !== undefined)
|
|
117
|
+
ctx.addIssue({ code: "custom", path: [key], message: "Request settings are not supported on transcription profiles." });
|
|
102
118
|
if (profile.pricing) ctx.addIssue({ code: "custom", path: ["pricing"], message: "Audio pricing is not supported." });
|
|
103
119
|
if (profile.capabilities.some((capability) => capability !== "transcription")) {
|
|
104
120
|
ctx.addIssue({ code: "custom", path: ["capabilities"], message: "Audio transcription cannot be combined with chat capabilities." });
|
|
@@ -128,7 +144,7 @@ const profileToPublic = (profile: AiModelProfile): AiPublicModelProfile => ({
|
|
|
128
144
|
});
|
|
129
145
|
|
|
130
146
|
const normalizeProfile = (raw: z.infer<typeof ModelProfileSchema>): AiModelProfile => {
|
|
131
|
-
const { capabilities, dataBoundary, dataPolicy: legacyDataPolicy, tags: _legacyTags, ...profile } = raw;
|
|
147
|
+
const { requestHeaders: _requestHeaders, capabilities, dataBoundary, dataPolicy: legacyDataPolicy, tags: _legacyTags, ...profile } = raw;
|
|
132
148
|
return {
|
|
133
149
|
...profile,
|
|
134
150
|
capabilities: normalizeCapabilities(capabilities),
|
|
@@ -522,7 +538,8 @@ export const resolveAiModelFromState = async (
|
|
|
522
538
|
});
|
|
523
539
|
}
|
|
524
540
|
|
|
525
|
-
|
|
541
|
+
const headers = providerSupportsRequestHeaders(profile.provider) ? await getAiRequestHeaders(profile.id) : {};
|
|
542
|
+
return { profile, provider: createAiProvider(profile, credential?.trim() || undefined, headers) };
|
|
526
543
|
};
|
|
527
544
|
|
|
528
545
|
const isUsableProfile = (profile: AiModelProfile, credentialProfileIds: ReadonlySet<string>): boolean =>
|
package/src/ai/skill-seeds.ts
CHANGED
|
@@ -14,6 +14,29 @@ Help the user turn a recurring workflow, specialist knowledge, or a set of instr
|
|
|
14
14
|
- Ask only about a missing choice that would materially change discovery, behavior, or output. If the request is already clear, draft the Skill directly.
|
|
15
15
|
- Preserve the user's scope. Do not turn one example, preference, or past failure into a universal rule unless the user clearly intends that.
|
|
16
16
|
|
|
17
|
+
## Choose the right place
|
|
18
|
+
|
|
19
|
+
Recommend the lightest place that does the job:
|
|
20
|
+
|
|
21
|
+
- one lasting fact or preference, such as reports always as PDF: personalization memory;
|
|
22
|
+
- always using one mailbox, notebook, or Space for a kind of request: a personalization workflow default, which Assistant learns from the user's stated rule or repeated use;
|
|
23
|
+
- rules for one Project's shared work: Project instructions;
|
|
24
|
+
- a recurring procedure, judgment, or output format used across chats: a Skill;
|
|
25
|
+
- work that should run at a set time: a scheduled task, which can load the Skill;
|
|
26
|
+
- a repeated calculation or an interactive tool: a Studio App that the Skill references.
|
|
27
|
+
|
|
28
|
+
## Draft from the conversation
|
|
29
|
+
|
|
30
|
+
When the Skill comes from work in this conversation, capture what made it succeed:
|
|
31
|
+
|
|
32
|
+
- when to load it, as narrowly as the user's real requests: name the subject, such as the team or the report, not only the output format;
|
|
33
|
+
- the steps and the exact capability IDs that worked, so later runs can call load_tools without searching;
|
|
34
|
+
- every correction the user made, rewritten as a positive rule; corrections are the most valuable content;
|
|
35
|
+
- the output shape, as a compact template when layout matters;
|
|
36
|
+
- inputs by role and title, such as "the Space Sales"; when a personalization workflow default already routes this kind of request, refer to it instead of repeating it. Never copy IDs of mailboxes, Spaces, notebooks, records, or other resources from the chat; the exact ID of a Studio App the Skill calls is the one exception.
|
|
37
|
+
|
|
38
|
+
Keep only procedure, format, and stable references. Leave out content from attachments, mails, web pages, or other quoted data; names of people or customers, amounts, and example records from this chat; one-off values; and failed attempts. Before the create review, tell the user in one or two sentences what the Skill will do and when it will load.
|
|
39
|
+
|
|
17
40
|
## Design the Skill
|
|
18
41
|
|
|
19
42
|
### Name
|
|
@@ -59,9 +82,10 @@ Read the draft once as if you were a different Assistant receiving it later. Che
|
|
|
59
82
|
- the workflow can run without private conversation context;
|
|
60
83
|
- instructions are direct, non-repetitive, and compatible with available capabilities;
|
|
61
84
|
- examples generalize beyond the original case;
|
|
85
|
+
- it holds no names of people or customers, amounts, records, or resource IDs from this conversation, except the exact ID of a Studio App it calls;
|
|
62
86
|
- no surprising mutation, permission expansion, or external side effect is implied.
|
|
63
87
|
|
|
64
|
-
For an existing Skill, read it first and preserve fields the user did not ask to change. Prefer a narrow correction over accumulating rules for every observed example.
|
|
88
|
+
For an existing Skill, read it first and preserve fields the user did not ask to change. Prefer a narrow correction over accumulating rules for every observed example. Built-in Skills and Skills shared with others change for everyone who uses them, so change one only when you can edit it and the user wants the change for everyone. \`core.ai.skill.read\` shows your permission, and \`core.ai.skill.access.read\` shows who else can use a Skill you manage. Otherwise leave the Skill unchanged. When personalization memory is on, a correction meant only for the user can become a preference; memory saves only the user's own words, so ask the user to state the rule, such as "always write mails formally".
|
|
65
89
|
|
|
66
90
|
## Use Cloud Skill capabilities
|
|
67
91
|
|
|
@@ -82,6 +106,10 @@ Pass the exact current revision to mutations and read again after a revision con
|
|
|
82
106
|
|
|
83
107
|
Creating, changing, and deleting Skills are reviewed mutations. Prepare the concrete content the user requested, then use the Capability review instead of asking for an extra confirmation that duplicates it.
|
|
84
108
|
|
|
109
|
+
## After saving
|
|
110
|
+
|
|
111
|
+
Say that the Skill now loads for matching requests and can also be selected with /skill. When a later run needs a correction, update the Skill narrowly instead of creating another one. Share it only when the user asks.
|
|
112
|
+
|
|
85
113
|
## Optional App-backed workflows
|
|
86
114
|
|
|
87
115
|
A Skill can reference a reusable Studio App and its published actions instead of duplicating code. Load \`assistant-code-mode\` only when building, changing, or discovering such an App is useful. Apps may be stateless scripts, agent-only services with shared data, dashboards, or a combination. Many Skills need no code and many Apps need no Skill.
|
|
@@ -438,7 +466,7 @@ const BUILTIN_CLOUD_AI_SKILLS: AiSkillTemplate[] = [
|
|
|
438
466
|
ASSISTANT_CODE_MODE_SKILL,
|
|
439
467
|
ASSISTANT_DATA_ANALYSIS_SKILL,
|
|
440
468
|
{
|
|
441
|
-
version:
|
|
469
|
+
version: 7,
|
|
442
470
|
key: "grids:cloud-grids",
|
|
443
471
|
name: "cloud-grids",
|
|
444
472
|
description:
|
|
@@ -463,7 +491,7 @@ const BUILTIN_CLOUD_AI_SKILLS: AiSkillTemplate[] = [
|
|
|
463
491
|
instructions: SCHEDULED_TASKS_INSTRUCTIONS,
|
|
464
492
|
},
|
|
465
493
|
{
|
|
466
|
-
version:
|
|
494
|
+
version: 3,
|
|
467
495
|
key: "core:skill-creator",
|
|
468
496
|
name: "skill-creator",
|
|
469
497
|
description:
|
package/src/ai/skills.ts
CHANGED
|
@@ -562,7 +562,33 @@ async function linkedSkillProjects(skillId: string, subject: AccessSubject | nul
|
|
|
562
562
|
return result;
|
|
563
563
|
}
|
|
564
564
|
|
|
565
|
+
export type AiConversationSkillUse = { name: string; description: string; turns: number; lastLoadedAt: string };
|
|
566
|
+
|
|
565
567
|
export const aiSkills = {
|
|
568
|
+
/**
|
|
569
|
+
* Skills a chat loaded, from the revision snapshots its turns pinned, most recently loaded first. The caller resolves
|
|
570
|
+
* the chat for its owner first; this is the chat's own history and does not recheck current Skill access.
|
|
571
|
+
*/
|
|
572
|
+
async listConversationUses(conversationId: string, limit = 50): Promise<AiConversationSkillUse[]> {
|
|
573
|
+
const rows = await sql<{ name: string; description: string; turns: number | string; last_loaded_at: Date | string }[]>`
|
|
574
|
+
SELECT snapshot.skill_name AS name,
|
|
575
|
+
(array_agg(snapshot.description ORDER BY snapshot.loaded_at DESC))[1] AS description,
|
|
576
|
+
count(*) AS turns, max(snapshot.loaded_at) AS last_loaded_at
|
|
577
|
+
FROM ai.turn_skill_snapshots snapshot
|
|
578
|
+
JOIN ai.turns turn ON turn.id = snapshot.turn_id
|
|
579
|
+
WHERE turn.conversation_id = ${conversationId}::uuid
|
|
580
|
+
GROUP BY snapshot.skill_name
|
|
581
|
+
ORDER BY max(snapshot.loaded_at) DESC, snapshot.skill_name ASC
|
|
582
|
+
LIMIT ${Math.min(Math.max(Math.floor(limit), 1), 100)}
|
|
583
|
+
`;
|
|
584
|
+
return rows.map((row) => ({
|
|
585
|
+
name: row.name,
|
|
586
|
+
description: row.description,
|
|
587
|
+
turns: Number(row.turns),
|
|
588
|
+
lastLoadedAt: (row.last_loaded_at instanceof Date ? row.last_loaded_at : new Date(row.last_loaded_at)).toISOString(),
|
|
589
|
+
}));
|
|
590
|
+
},
|
|
591
|
+
|
|
566
592
|
async seedOnce(template: AiSkillTemplate): Promise<void> {
|
|
567
593
|
const { fields, hash } = validateTemplate(template);
|
|
568
594
|
await sql.begin(async (tx) => {
|
package/src/ai/solid.ts
CHANGED
|
@@ -6,7 +6,7 @@ export {
|
|
|
6
6
|
type CreateAiChatControllerOptions,
|
|
7
7
|
createAiChatController,
|
|
8
8
|
} from "./client/controller";
|
|
9
|
-
export { conversationFileSource } from "./client/file-source";
|
|
9
|
+
export { type ConversationFileSource, conversationFileSource } from "./client/file-source";
|
|
10
10
|
export {
|
|
11
11
|
type AiActiveTurn,
|
|
12
12
|
type AiChatProjection,
|
package/src/ai/store.ts
CHANGED
|
@@ -304,10 +304,10 @@ const rowToConversationResourceRef = (row: ConversationResourceRefRow): AiConver
|
|
|
304
304
|
});
|
|
305
305
|
|
|
306
306
|
type ResourceCursor = { at: string; type: string; id: string };
|
|
307
|
-
const encodeResourceCursor = (row: ConversationResourceRefRow): string =>
|
|
308
|
-
encodeURIComponent(JSON.stringify({ at:
|
|
309
|
-
const encodeSourceCursor = (row: ConversationSourceRow): string =>
|
|
310
|
-
encodeURIComponent(JSON.stringify({ at:
|
|
307
|
+
const encodeResourceCursor = (row: ConversationResourceRefRow & { cursor_at: string }): string =>
|
|
308
|
+
encodeURIComponent(JSON.stringify({ at: row.cursor_at, type: row.resource_type, id: row.resource_id } satisfies ResourceCursor));
|
|
309
|
+
const encodeSourceCursor = (row: ConversationSourceRow & { cursor_at: string }): string =>
|
|
310
|
+
encodeURIComponent(JSON.stringify({ at: row.cursor_at, type: row.source_kind, id: row.source_key } satisfies ResourceCursor));
|
|
311
311
|
const decodeResourceCursor = (value: string | undefined): ResourceCursor | null => {
|
|
312
312
|
if (!value) return null;
|
|
313
313
|
try {
|
|
@@ -348,6 +348,7 @@ const rowToConversationSource = (row: ConversationSourceRow): AiConversationSour
|
|
|
348
348
|
lastSeenAt: iso(row.last_seen_at),
|
|
349
349
|
sourceTurnId: row.source_turn_id,
|
|
350
350
|
sourceCallId: row.source_call_id,
|
|
351
|
+
sourceMessageSeq: null,
|
|
351
352
|
});
|
|
352
353
|
|
|
353
354
|
const rowToInterChatMessage = (row: InterChatMessageRow): AiInterChatMessage => ({
|
|
@@ -389,10 +390,10 @@ const interChatMessageSelect = () => sql`
|
|
|
389
390
|
`;
|
|
390
391
|
|
|
391
392
|
type ResourceOccurrenceCursor = ResourceCursor & { chat: string };
|
|
392
|
-
const encodeResourceOccurrenceCursor = (row: ConversationResourceOccurrenceRow): string =>
|
|
393
|
+
const encodeResourceOccurrenceCursor = (row: ConversationResourceOccurrenceRow & { cursor_at: string }): string =>
|
|
393
394
|
encodeURIComponent(
|
|
394
395
|
JSON.stringify({
|
|
395
|
-
at:
|
|
396
|
+
at: row.cursor_at,
|
|
396
397
|
type: row.resource_type,
|
|
397
398
|
id: row.resource_id,
|
|
398
399
|
chat: row.conversation_short_id,
|
|
@@ -1440,9 +1441,19 @@ export const aiConversations: AiConversationService = {
|
|
|
1440
1441
|
preview = COALESCE(EXCLUDED.preview, ai.conversation_resource_refs.preview),
|
|
1441
1442
|
icon = COALESCE(EXCLUDED.icon, ai.conversation_resource_refs.icon),
|
|
1442
1443
|
href = COALESCE(EXCLUDED.href, ai.conversation_resource_refs.href),
|
|
1443
|
-
|
|
1444
|
+
-- Project context is indexed on every turn without a call. It must not move a resource a tool call
|
|
1445
|
+
-- read to the latest turn, or the chat would show it as read just now.
|
|
1446
|
+
source_turn_id = CASE
|
|
1447
|
+
WHEN EXCLUDED.source_call_id IS NULL AND ai.conversation_resource_refs.source_call_id IS NOT NULL
|
|
1448
|
+
THEN ai.conversation_resource_refs.source_turn_id
|
|
1449
|
+
ELSE COALESCE(EXCLUDED.source_turn_id, ai.conversation_resource_refs.source_turn_id)
|
|
1450
|
+
END,
|
|
1444
1451
|
source_call_id = COALESCE(EXCLUDED.source_call_id, ai.conversation_resource_refs.source_call_id),
|
|
1445
|
-
last_seen_at =
|
|
1452
|
+
last_seen_at = CASE
|
|
1453
|
+
WHEN EXCLUDED.source_call_id IS NULL AND ai.conversation_resource_refs.source_call_id IS NOT NULL
|
|
1454
|
+
THEN ai.conversation_resource_refs.last_seen_at
|
|
1455
|
+
ELSE now()
|
|
1456
|
+
END
|
|
1446
1457
|
`;
|
|
1447
1458
|
}
|
|
1448
1459
|
});
|
|
@@ -1452,9 +1463,10 @@ export const aiConversations: AiConversationService = {
|
|
|
1452
1463
|
const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
|
|
1453
1464
|
const pattern = searchPattern(input.search);
|
|
1454
1465
|
const cursor = decodeResourceCursor(input.before);
|
|
1455
|
-
const rows = await sql<ConversationResourceRefRow[]>`
|
|
1466
|
+
const rows = await sql<(ConversationResourceRefRow & { cursor_at: string })[]>`
|
|
1456
1467
|
SELECT indexed.resource_type, indexed.resource_id, indexed.title, indexed.preview, indexed.icon, indexed.href,
|
|
1457
|
-
source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at
|
|
1468
|
+
source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at,
|
|
1469
|
+
to_char(indexed.last_seen_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_at
|
|
1458
1470
|
FROM ai.conversation_resource_refs indexed
|
|
1459
1471
|
LEFT JOIN ai.turns source_turn ON source_turn.id = indexed.source_turn_id
|
|
1460
1472
|
WHERE indexed.conversation_id = ${input.conversationId}::uuid
|
|
@@ -1475,11 +1487,12 @@ export const aiConversations: AiConversationService = {
|
|
|
1475
1487
|
const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
|
|
1476
1488
|
const pattern = searchPattern(input.search);
|
|
1477
1489
|
const cursor = decodeResourceOccurrenceCursor(input.before);
|
|
1478
|
-
const rows = await sql<ConversationResourceOccurrenceRow[]>`
|
|
1490
|
+
const rows = await sql<(ConversationResourceOccurrenceRow & { cursor_at: string })[]>`
|
|
1479
1491
|
SELECT indexed.resource_type, indexed.resource_id, indexed.title, indexed.preview, indexed.icon, indexed.href,
|
|
1480
1492
|
source_turn.short_id AS source_turn_id, indexed.source_call_id, indexed.first_seen_at, indexed.last_seen_at,
|
|
1481
1493
|
conversation.short_id AS conversation_short_id, conversation.title AS conversation_title,
|
|
1482
|
-
conversation.updated_at AS conversation_updated_at
|
|
1494
|
+
conversation.updated_at AS conversation_updated_at,
|
|
1495
|
+
to_char(indexed.last_seen_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_at
|
|
1483
1496
|
FROM ai.conversation_resource_refs indexed
|
|
1484
1497
|
JOIN ai.conversations conversation ON conversation.id = indexed.conversation_id
|
|
1485
1498
|
LEFT JOIN ai.turns source_turn ON source_turn.id = indexed.source_turn_id
|
|
@@ -1502,17 +1515,21 @@ export const aiConversations: AiConversationService = {
|
|
|
1502
1515
|
const source = input.source;
|
|
1503
1516
|
await sql`
|
|
1504
1517
|
INSERT INTO ai.conversation_sources (
|
|
1505
|
-
conversation_id, kind, source_key, title, preview, icon, href, source_turn_id, source_call_id
|
|
1518
|
+
conversation_id, kind, source_key, title, preview, icon, href, resource_type, resource_id, source_turn_id, source_call_id
|
|
1506
1519
|
) VALUES (
|
|
1507
1520
|
${input.conversationId}::uuid, ${source.kind}, ${source.key}, ${source.title}, ${source.preview ?? null},
|
|
1508
|
-
${source.icon ?? null}, ${source.href ?? null}, ${
|
|
1521
|
+
${source.icon ?? null}, ${source.href ?? null}, ${source.ref?.type ?? null}, ${source.ref?.id ?? null},
|
|
1522
|
+
${input.turnId ?? null}::uuid, ${input.callId ?? null}
|
|
1509
1523
|
)
|
|
1510
1524
|
ON CONFLICT (conversation_id, kind, source_key)
|
|
1511
1525
|
DO UPDATE SET
|
|
1512
1526
|
title = EXCLUDED.title,
|
|
1513
|
-
|
|
1527
|
+
-- A new delivery describes the result anew; an old description must not outlive changed content.
|
|
1528
|
+
preview = CASE WHEN EXCLUDED.kind = 'result' THEN EXCLUDED.preview ELSE COALESCE(EXCLUDED.preview, ai.conversation_sources.preview) END,
|
|
1514
1529
|
icon = COALESCE(EXCLUDED.icon, ai.conversation_sources.icon),
|
|
1515
1530
|
href = COALESCE(EXCLUDED.href, ai.conversation_sources.href),
|
|
1531
|
+
resource_type = COALESCE(EXCLUDED.resource_type, ai.conversation_sources.resource_type),
|
|
1532
|
+
resource_id = COALESCE(EXCLUDED.resource_id, ai.conversation_sources.resource_id),
|
|
1516
1533
|
occurrences = ai.conversation_sources.occurrences +
|
|
1517
1534
|
CASE WHEN ai.conversation_sources.source_call_id IS DISTINCT FROM EXCLUDED.source_call_id THEN 1 ELSE 0 END,
|
|
1518
1535
|
source_turn_id = COALESCE(EXCLUDED.source_turn_id, ai.conversation_sources.source_turn_id),
|
|
@@ -1525,50 +1542,85 @@ export const aiConversations: AiConversationService = {
|
|
|
1525
1542
|
const limit = Math.min(Math.max(Math.floor(input.limit ?? 20), 1), 100);
|
|
1526
1543
|
const pattern = searchPattern(input.search);
|
|
1527
1544
|
const cursor = decodeResourceCursor(input.before);
|
|
1528
|
-
const
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1545
|
+
const kinds = input.kinds?.length ? toPgTextArray([...input.kinds]) : null;
|
|
1546
|
+
const observed = input.observed === true;
|
|
1547
|
+
const rows = await sql<(ConversationSourceRow & { total: number | string; source_message_seq: number | null; cursor_at: string })[]>`
|
|
1548
|
+
WITH matched AS (
|
|
1549
|
+
SELECT *
|
|
1550
|
+
FROM (
|
|
1551
|
+
SELECT source.kind AS source_kind, source.source_key, source.title, source.preview,
|
|
1552
|
+
source.icon, source.href, file.path, file.media_type, file.size,
|
|
1553
|
+
source.resource_type, source.resource_id, source.occurrences,
|
|
1554
|
+
turn.short_id AS source_turn_id, source.source_turn_id AS turn_uuid, source.source_call_id,
|
|
1555
|
+
source.first_seen_at, source.last_seen_at
|
|
1556
|
+
FROM ai.conversation_sources source
|
|
1557
|
+
LEFT JOIN ai.turns turn ON turn.id = source.source_turn_id
|
|
1558
|
+
LEFT JOIN ai.files file
|
|
1559
|
+
ON source.kind = 'result' AND file.conversation_id = source.conversation_id AND file.path = source.source_key
|
|
1560
|
+
WHERE source.conversation_id = ${input.conversationId}::uuid
|
|
1561
|
+
|
|
1562
|
+
UNION ALL
|
|
1563
|
+
|
|
1564
|
+
SELECT 'file' AS source_kind, file.path AS source_key,
|
|
1565
|
+
regexp_replace(file.path, '^.*/', '') AS title, 'Attached to the conversation' AS preview,
|
|
1566
|
+
CASE WHEN file.media_type LIKE 'image/%' THEN 'ti ti-photo' ELSE 'ti ti-file' END AS icon,
|
|
1567
|
+
NULL::text AS href, file.path, file.media_type, file.size,
|
|
1568
|
+
NULL::text AS resource_type, NULL::text AS resource_id, 1 AS occurrences,
|
|
1569
|
+
NULL::text AS source_turn_id, NULL::uuid AS turn_uuid, NULL::text AS source_call_id,
|
|
1570
|
+
file.updated_at AS first_seen_at, file.updated_at AS last_seen_at
|
|
1571
|
+
FROM ai.files file
|
|
1572
|
+
WHERE file.conversation_id = ${input.conversationId}::uuid
|
|
1573
|
+
|
|
1574
|
+
UNION ALL
|
|
1575
|
+
|
|
1576
|
+
SELECT 'resource' AS source_kind, indexed.resource_type || ':' || indexed.resource_id AS source_key,
|
|
1577
|
+
COALESCE(indexed.title, indexed.resource_type || ' ' || indexed.resource_id) AS title,
|
|
1578
|
+
indexed.preview, indexed.icon, indexed.href, NULL::text AS path, NULL::text AS media_type, NULL::bigint AS size,
|
|
1579
|
+
indexed.resource_type, indexed.resource_id, 1 AS occurrences,
|
|
1580
|
+
turn.short_id AS source_turn_id, indexed.source_turn_id AS turn_uuid, indexed.source_call_id,
|
|
1581
|
+
indexed.first_seen_at, indexed.last_seen_at
|
|
1582
|
+
FROM ai.conversation_resource_refs indexed
|
|
1583
|
+
LEFT JOIN ai.turns turn ON turn.id = indexed.source_turn_id
|
|
1584
|
+
WHERE indexed.conversation_id = ${input.conversationId}::uuid
|
|
1585
|
+
) source
|
|
1586
|
+
WHERE (${kinds}::text[] IS NULL OR source.source_kind = ANY(${kinds}::text[]))
|
|
1587
|
+
AND (NOT ${observed} OR source.source_kind <> 'resource' OR source.source_call_id IS NOT NULL)
|
|
1588
|
+
AND (${pattern}::text IS NULL OR LOWER(source.title || ' ' || COALESCE(source.preview, '') || ' ' || source.source_key) LIKE ${pattern})
|
|
1589
|
+
),
|
|
1590
|
+
page AS (
|
|
1591
|
+
SELECT *
|
|
1592
|
+
FROM matched source
|
|
1593
|
+
WHERE ${cursor?.at ?? null}::timestamptz IS NULL OR (source.last_seen_at, source.source_kind, source.source_key) <
|
|
1594
|
+
(${cursor?.at ?? null}::timestamptz, ${cursor?.type ?? null}::text, ${cursor?.id ?? null}::text)
|
|
1595
|
+
ORDER BY source.last_seen_at DESC, source.source_kind DESC, source.source_key DESC
|
|
1596
|
+
LIMIT ${limit + 1}
|
|
1597
|
+
)
|
|
1598
|
+
SELECT page.*, to_char(page.last_seen_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_at, totals.total,
|
|
1599
|
+
COALESCE(
|
|
1600
|
+
(SELECT message.seq FROM ai.messages message
|
|
1601
|
+
WHERE message.conversation_id = ${input.conversationId}::uuid AND message.loop_id = page.turn_uuid::text
|
|
1602
|
+
AND message.role = 'assistant' AND page.source_call_id IS NOT NULL
|
|
1603
|
+
AND (CASE WHEN jsonb_typeof(message.message) = 'string' THEN (message.message #>> '{}')::jsonb ELSE message.message END) -> 'content'
|
|
1604
|
+
@> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', page.source_call_id))
|
|
1605
|
+
ORDER BY message.seq ASC
|
|
1606
|
+
LIMIT 1),
|
|
1607
|
+
(SELECT min(message.seq) FROM ai.messages message
|
|
1608
|
+
WHERE message.conversation_id = ${input.conversationId}::uuid AND message.loop_id = page.turn_uuid::text)
|
|
1609
|
+
) AS source_message_seq
|
|
1610
|
+
FROM (SELECT count(*) AS total FROM matched) totals
|
|
1611
|
+
LEFT JOIN page ON true
|
|
1612
|
+
ORDER BY page.last_seen_at DESC, page.source_kind DESC, page.source_key DESC
|
|
1613
|
+
`;
|
|
1614
|
+
// One row carries the total even when the page is empty.
|
|
1615
|
+
const total = Number(rows[0]?.total ?? 0);
|
|
1616
|
+
const page = rows.filter((row) => row.source_kind !== null).slice(0, limit);
|
|
1569
1617
|
return {
|
|
1570
|
-
sources: page.map(
|
|
1618
|
+
sources: page.map((row) => ({
|
|
1619
|
+
...rowToConversationSource(row),
|
|
1620
|
+
sourceMessageSeq: row.source_message_seq === null ? null : Number(row.source_message_seq),
|
|
1621
|
+
})),
|
|
1571
1622
|
...(rows.length > limit && page.at(-1) ? { nextCursor: encodeSourceCursor(page.at(-1)!) } : {}),
|
|
1623
|
+
total,
|
|
1572
1624
|
};
|
|
1573
1625
|
},
|
|
1574
1626
|
|