@cosmoops/connect-sdk 0.15.0 → 0.16.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/CHANGELOG.md +86 -0
- package/README.md +2 -1
- package/dist/cjs/client/components/ai-screens.js +73 -0
- package/dist/cjs/client/components/ai-widgets.js +37 -0
- package/dist/cjs/client/components/ask-ai.js +31 -5
- package/dist/cjs/client/index.js +5 -2
- package/dist/cjs/index.js +6 -1
- package/dist/cjs/mcp/index.js +90 -0
- package/dist/cjs/mcp/tools.js +369 -0
- package/dist/cjs/server/client.js +1 -1
- package/dist/cjs/server/handler.js +7 -0
- package/dist/cjs/server/resources/ai.js +8 -1
- package/dist/cjs/store-schema.js +66 -0
- package/dist/esm/client/components/ai-screens.js +69 -0
- package/dist/esm/client/components/ai-widgets.js +37 -1
- package/dist/esm/client/components/ask-ai.js +32 -6
- package/dist/esm/client/index.js +1 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/mcp/index.js +85 -0
- package/dist/esm/mcp/tools.js +366 -0
- package/dist/esm/server/client.js +1 -1
- package/dist/esm/server/handler.js +7 -0
- package/dist/esm/server/resources/ai.js +8 -1
- package/dist/esm/store-schema.js +62 -0
- package/dist/types/client/components/ai-screens.d.ts +25 -0
- package/dist/types/client/components/ai-widgets.d.ts +34 -1
- package/dist/types/client/components/ask-ai.d.ts +47 -1
- package/dist/types/client/index.d.ts +2 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/mcp/index.d.ts +85 -0
- package/dist/types/mcp/tools.d.ts +107 -0
- package/dist/types/server/client.d.ts +1 -1
- package/dist/types/server/resources/ai.d.ts +11 -1
- package/dist/types/store-schema.d.ts +52 -0
- package/dist/types/types.d.ts +50 -1
- package/package.json +10 -1
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deriveStoreTools = deriveStoreTools;
|
|
4
|
+
const store_js_1 = require("../store.js");
|
|
5
|
+
const store_schema_js_1 = require("../store-schema.js");
|
|
6
|
+
/**
|
|
7
|
+
* The store, as tools a model can call.
|
|
8
|
+
*
|
|
9
|
+
* ## Why a declaration is enough
|
|
10
|
+
*
|
|
11
|
+
* `Application.storeSchema` already answers every question a tool definition
|
|
12
|
+
* asks. It names the collection and says what it holds (`description`), which
|
|
13
|
+
* fields may be filtered or ordered on (`queryableFields`), which query shapes
|
|
14
|
+
* the database can actually serve (`indexes`), and whether the collection
|
|
15
|
+
* refuses everything but a create (`appendOnly`). Writing those tools by hand
|
|
16
|
+
* means maintaining a second copy of all of it, and the copy is wrong the first
|
|
17
|
+
* time a filter is added to a screen.
|
|
18
|
+
*
|
|
19
|
+
* So they are derived. Adding a queryable field to the declaration and
|
|
20
|
+
* re-seeding widens the tools on the next boot, with no tool code to touch.
|
|
21
|
+
*
|
|
22
|
+
* ## What this is not
|
|
23
|
+
*
|
|
24
|
+
* **It is not an authorization boundary.** Connect is. Every call here goes out
|
|
25
|
+
* with the end user's session token (`asUser`), and Connect resolves it to a
|
|
26
|
+
* member and checks the collection's declared `readPermission`/`writePermission`
|
|
27
|
+
* against that member's live roles - so a tool can never reach further than the
|
|
28
|
+
* person on whose behalf it runs, and a permission revoked in the console is
|
|
29
|
+
* gone from the next call. The shaping done here - omitting write tools for an
|
|
30
|
+
* `appendOnly` collection, refusing an unservable query - is there so a model
|
|
31
|
+
* fails fast and legibly, not because it protects anything. Connect refuses the
|
|
32
|
+
* same things independently.
|
|
33
|
+
*
|
|
34
|
+
* **There is no `scan` tool, deliberately.** `data.scan` reads a collection
|
|
35
|
+
* across every workspace and, for exactly that reason, takes no `asUser` - a
|
|
36
|
+
* workspace admin is an admin of their own workspace and not of a partner's
|
|
37
|
+
* other customers. A toolset that runs on behalf of a signed-in user therefore
|
|
38
|
+
* has nothing to derive from `crossWorkspace`, and generating a scan tool would
|
|
39
|
+
* mean running that one call under the application's own key while every tool
|
|
40
|
+
* beside it ran under the user's. Cross-tenant reads belong in a back-office
|
|
41
|
+
* surface with its own gate.
|
|
42
|
+
*
|
|
43
|
+
* **`kv` collections are skipped.** The key/value store is addressed by
|
|
44
|
+
* namespace and key rather than queried, so it derives nothing from
|
|
45
|
+
* `queryableFields` or `indexes` and the tools it wants are a different shape.
|
|
46
|
+
*
|
|
47
|
+
* ## Deriving over every collection is usually wrong
|
|
48
|
+
*
|
|
49
|
+
* Measured on the two reference applications: club's fifteen collections yield
|
|
50
|
+
* 45 read tools and 88 with writes on; CRM's seven yield 21 and 40. Those are
|
|
51
|
+
* whole-catalogue numbers, and a model offered them chooses worse than one
|
|
52
|
+
* offered a handful - the ceiling is tool-choice accuracy, which degrades well
|
|
53
|
+
* before a context window fills. Connect's own assistant bounds each turn to
|
|
54
|
+
* the capabilities the current screen promotes for exactly this reason.
|
|
55
|
+
*
|
|
56
|
+
* So `include` is the normal way to call this, not a refinement: name the
|
|
57
|
+
* collections the surface at hand is about. Nothing here enforces a cap,
|
|
58
|
+
* because the right number depends on the surface and a silent truncation
|
|
59
|
+
* would be worse than a large list.
|
|
60
|
+
*/
|
|
61
|
+
/** Connect's own ceilings, mirrored so a tool can describe them before the call. */
|
|
62
|
+
const MAX_QUERY_LIMIT = 200;
|
|
63
|
+
const DEFAULT_QUERY_LIMIT = 50;
|
|
64
|
+
const QUERY_OPERATORS = [
|
|
65
|
+
"==",
|
|
66
|
+
"!=",
|
|
67
|
+
"<",
|
|
68
|
+
"<=",
|
|
69
|
+
">",
|
|
70
|
+
">=",
|
|
71
|
+
"in",
|
|
72
|
+
"not-in",
|
|
73
|
+
"array-contains",
|
|
74
|
+
];
|
|
75
|
+
/**
|
|
76
|
+
* MCP tool names are `[a-z0-9_-]`; collection names additionally allow capitals.
|
|
77
|
+
* Lowercasing and folding `-` to `_` is what makes `dues-demands` addressable,
|
|
78
|
+
* and is also how two collections can collide - which is caught, not resolved,
|
|
79
|
+
* because silently renaming one would make the tool a model calls depend on
|
|
80
|
+
* declaration order.
|
|
81
|
+
*/
|
|
82
|
+
function toolBaseName(collection) {
|
|
83
|
+
return collection.toLowerCase().replace(/-/g, "_");
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* How many index shapes are worth spelling out before the listing costs more
|
|
87
|
+
* than it teaches.
|
|
88
|
+
*
|
|
89
|
+
* A real declaration is not a handful. club's ledger declares around forty
|
|
90
|
+
* shapes, and writing all of them into both the list and aggregate tools was
|
|
91
|
+
* most of a 79 KB `tools/list` on its own - paid on every turn, for a listing a
|
|
92
|
+
* model reads poorly anyway. Past this many, the description states the rule
|
|
93
|
+
* and leaves the specifics to the refusal, which names the exact index a
|
|
94
|
+
* rejected query needed and costs no round trip to get.
|
|
95
|
+
*/
|
|
96
|
+
const MAX_DESCRIBED_INDEXES = 12;
|
|
97
|
+
/** The servable query shapes, spelled out for the model rather than left to be discovered. */
|
|
98
|
+
function describeIndexes(collection) {
|
|
99
|
+
if (collection.indexes.length === 0) {
|
|
100
|
+
return "No composite indexes are declared, so a query may use at most one filter, ordered only by that same field.";
|
|
101
|
+
}
|
|
102
|
+
const preamble = "A query using more than one filter, or ordering by a field it does not filter on, must match a declared composite index";
|
|
103
|
+
if (collection.indexes.length > MAX_DESCRIBED_INDEXES) {
|
|
104
|
+
return `${preamble}. ${collection.indexes.length} are declared - too many to list here. Issue the query you want: if no index covers it you get back the exact index it would need, before anything is read.`;
|
|
105
|
+
}
|
|
106
|
+
const shapes = collection.indexes.map((index) => ` - ${(0, store_js_1.storeIndexSignature)(index.fields)}`).join("\n");
|
|
107
|
+
return `${preamble}:\n${shapes}`;
|
|
108
|
+
}
|
|
109
|
+
function readDescription(collection, verb) {
|
|
110
|
+
const head = `${verb} ${collection.name}. ${collection.description}`;
|
|
111
|
+
// Nothing declared queryable means the collection can only be read whole -
|
|
112
|
+
// which is the right shape for the one-document-per-key collections (a
|
|
113
|
+
// settings block, a counter), and saying so beats offering a filter argument
|
|
114
|
+
// that has no legal value.
|
|
115
|
+
if (collection.queryableFields.length === 0) {
|
|
116
|
+
return `${head}\n\nThis collection declares no queryable fields, so it cannot be filtered or ordered - it is read whole.`;
|
|
117
|
+
}
|
|
118
|
+
return [head, "", `Queryable fields: ${collection.queryableFields.join(", ")}.`, describeIndexes(collection)].join("\n");
|
|
119
|
+
}
|
|
120
|
+
const filtersSchema = (collection) => ({
|
|
121
|
+
type: "array",
|
|
122
|
+
description: "Filters, ANDed together.",
|
|
123
|
+
items: {
|
|
124
|
+
type: "object",
|
|
125
|
+
properties: {
|
|
126
|
+
field: { type: "string", enum: [...collection.queryableFields] },
|
|
127
|
+
op: { type: "string", enum: QUERY_OPERATORS },
|
|
128
|
+
value: {
|
|
129
|
+
description: "The value to compare against. Dates are ISO-8601 strings; a calendar day is YYYY-MM-DD.",
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
required: ["field", "op", "value"],
|
|
133
|
+
additionalProperties: false,
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
/**
|
|
137
|
+
* Documents come back with Connect's `_`-prefixed bookkeeping on them, and
|
|
138
|
+
* every write refuses those fields. Saying so on the write tools is what stops
|
|
139
|
+
* a model reading a document, editing one field and posting the whole thing
|
|
140
|
+
* back - which fails, correctly, but wastes a turn learning it.
|
|
141
|
+
*/
|
|
142
|
+
const WRITE_DATA_NOTE = "Fields beginning with `_` are written by Connect and are refused here - strip `_createdAt`, `_updatedAt`, `_createdBy`, `_updatedBy` and `_updateTime` from a document you read before writing it back.";
|
|
143
|
+
function buildReadTools(collection, base, call, data, workspaceId) {
|
|
144
|
+
return [
|
|
145
|
+
{
|
|
146
|
+
name: `${base}_get`,
|
|
147
|
+
title: `Get one ${collection.name} document`,
|
|
148
|
+
description: `Read one document from ${collection.name} by id. ${collection.description}`,
|
|
149
|
+
inputSchema: {
|
|
150
|
+
type: "object",
|
|
151
|
+
properties: { id: { type: "string", description: "The document id." } },
|
|
152
|
+
required: ["id"],
|
|
153
|
+
additionalProperties: false,
|
|
154
|
+
},
|
|
155
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
156
|
+
execute: (args) => call((asUser) => data.get({ workspaceId, collection: collection.name, id: String(args.id), asUser })),
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: `${base}_list`,
|
|
160
|
+
title: `List ${collection.name}`,
|
|
161
|
+
description: readDescription(collection, "List documents in"),
|
|
162
|
+
inputSchema: {
|
|
163
|
+
type: "object",
|
|
164
|
+
properties: {
|
|
165
|
+
...(collection.queryableFields.length > 0
|
|
166
|
+
? {
|
|
167
|
+
filters: filtersSchema(collection),
|
|
168
|
+
orderBy: {
|
|
169
|
+
type: "object",
|
|
170
|
+
properties: {
|
|
171
|
+
field: { type: "string", enum: [...collection.queryableFields] },
|
|
172
|
+
direction: { type: "string", enum: ["asc", "desc"] },
|
|
173
|
+
},
|
|
174
|
+
required: ["field", "direction"],
|
|
175
|
+
additionalProperties: false,
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
: {}),
|
|
179
|
+
limit: {
|
|
180
|
+
type: "integer",
|
|
181
|
+
minimum: 1,
|
|
182
|
+
maximum: MAX_QUERY_LIMIT,
|
|
183
|
+
description: `Page size. Defaults to ${DEFAULT_QUERY_LIMIT}, caps at ${MAX_QUERY_LIMIT}.`,
|
|
184
|
+
},
|
|
185
|
+
cursor: {
|
|
186
|
+
type: "string",
|
|
187
|
+
description: "The previous page's nextCursor. A null nextCursor is the end of the collection.",
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
additionalProperties: false,
|
|
191
|
+
},
|
|
192
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
193
|
+
execute: async (args) => {
|
|
194
|
+
const filters = args.filters ?? [];
|
|
195
|
+
const orderBy = args.orderBy ?? null;
|
|
196
|
+
const rejection = (0, store_schema_js_1.describeQueryRejection)(collection, { filters, orderBy });
|
|
197
|
+
if (rejection)
|
|
198
|
+
throw new Error(rejection);
|
|
199
|
+
return call((asUser) => data.query({
|
|
200
|
+
workspaceId,
|
|
201
|
+
collection: collection.name,
|
|
202
|
+
filters,
|
|
203
|
+
orderBy: orderBy ?? undefined,
|
|
204
|
+
limit: args.limit,
|
|
205
|
+
cursor: args.cursor ?? null,
|
|
206
|
+
asUser,
|
|
207
|
+
}));
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: `${base}_aggregate`,
|
|
212
|
+
title: `Count, sum or average ${collection.name}`,
|
|
213
|
+
description: [
|
|
214
|
+
readDescription(collection, "Aggregate"),
|
|
215
|
+
"",
|
|
216
|
+
"Counts, sums and averages are computed in the database rather than by reading rows back, so prefer this over listing a collection to add it up. A sum or average over nothing answers null, not 0.",
|
|
217
|
+
].join("\n"),
|
|
218
|
+
inputSchema: {
|
|
219
|
+
type: "object",
|
|
220
|
+
properties: {
|
|
221
|
+
aggregations: {
|
|
222
|
+
type: "array",
|
|
223
|
+
minItems: 1,
|
|
224
|
+
items: {
|
|
225
|
+
type: "object",
|
|
226
|
+
properties: {
|
|
227
|
+
op: {
|
|
228
|
+
type: "string",
|
|
229
|
+
// `sum` and `avg` need a field, and every field they could
|
|
230
|
+
// name must be queryable - so a collection declaring none can
|
|
231
|
+
// only be counted.
|
|
232
|
+
enum: collection.queryableFields.length > 0 ? ["count", "sum", "avg"] : ["count"],
|
|
233
|
+
},
|
|
234
|
+
...(collection.queryableFields.length > 0
|
|
235
|
+
? {
|
|
236
|
+
field: {
|
|
237
|
+
type: "string",
|
|
238
|
+
enum: [...collection.queryableFields],
|
|
239
|
+
description: "Required for sum and avg; omitted for count, which counts documents.",
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
: {}),
|
|
243
|
+
alias: { type: "string", description: "What the result is keyed by." },
|
|
244
|
+
},
|
|
245
|
+
required: ["op", "alias"],
|
|
246
|
+
additionalProperties: false,
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
...(collection.queryableFields.length > 0 ? { filters: filtersSchema(collection) } : {}),
|
|
250
|
+
},
|
|
251
|
+
required: ["aggregations"],
|
|
252
|
+
additionalProperties: false,
|
|
253
|
+
},
|
|
254
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
255
|
+
execute: async (args) => {
|
|
256
|
+
const filters = args.filters ?? [];
|
|
257
|
+
// An aggregation has no ordering, so the index it needs is the one its
|
|
258
|
+
// filters need - the same check `list` makes, minus the sort.
|
|
259
|
+
const rejection = (0, store_schema_js_1.describeQueryRejection)(collection, { filters, orderBy: null });
|
|
260
|
+
if (rejection)
|
|
261
|
+
throw new Error(rejection);
|
|
262
|
+
return call((asUser) => data.aggregate({
|
|
263
|
+
workspaceId,
|
|
264
|
+
collection: collection.name,
|
|
265
|
+
aggregations: args.aggregations,
|
|
266
|
+
filters,
|
|
267
|
+
asUser,
|
|
268
|
+
}));
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
function buildWriteTools(collection, base, call, data, workspaceId, writes) {
|
|
274
|
+
if (writes === "none")
|
|
275
|
+
return [];
|
|
276
|
+
const tools = [
|
|
277
|
+
{
|
|
278
|
+
name: `${base}_create`,
|
|
279
|
+
title: `Create a ${collection.name} document`,
|
|
280
|
+
description: `Create a document in ${collection.name}. ${collection.description}\n\n${WRITE_DATA_NOTE}`,
|
|
281
|
+
inputSchema: {
|
|
282
|
+
type: "object",
|
|
283
|
+
properties: {
|
|
284
|
+
data: { type: "object", description: "The document's fields." },
|
|
285
|
+
id: { type: "string", description: "Choose the id, or omit for an allocated one. An existing id is a conflict." },
|
|
286
|
+
},
|
|
287
|
+
required: ["data"],
|
|
288
|
+
additionalProperties: false,
|
|
289
|
+
},
|
|
290
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
291
|
+
execute: (args) => call((asUser) => data.create({
|
|
292
|
+
workspaceId,
|
|
293
|
+
collection: collection.name,
|
|
294
|
+
data: args.data,
|
|
295
|
+
id: args.id,
|
|
296
|
+
asUser,
|
|
297
|
+
})),
|
|
298
|
+
},
|
|
299
|
+
];
|
|
300
|
+
// An append-only collection refuses a merge and a delete for every caller,
|
|
301
|
+
// this application's own key included, so deriving those tools would offer a
|
|
302
|
+
// model two calls that can only ever fail.
|
|
303
|
+
if (writes === "all" && !collection.appendOnly) {
|
|
304
|
+
tools.push({
|
|
305
|
+
name: `${base}_update`,
|
|
306
|
+
title: `Update a ${collection.name} document`,
|
|
307
|
+
description: `Merge top-level fields into an existing ${collection.name} document. Fields not named are left alone.\n\n${WRITE_DATA_NOTE}`,
|
|
308
|
+
inputSchema: {
|
|
309
|
+
type: "object",
|
|
310
|
+
properties: {
|
|
311
|
+
id: { type: "string" },
|
|
312
|
+
data: { type: "object", description: "The fields to merge." },
|
|
313
|
+
},
|
|
314
|
+
required: ["id", "data"],
|
|
315
|
+
additionalProperties: false,
|
|
316
|
+
},
|
|
317
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
318
|
+
execute: (args) => call((asUser) => data.update({
|
|
319
|
+
workspaceId,
|
|
320
|
+
collection: collection.name,
|
|
321
|
+
id: String(args.id),
|
|
322
|
+
data: args.data,
|
|
323
|
+
asUser,
|
|
324
|
+
})),
|
|
325
|
+
}, {
|
|
326
|
+
name: `${base}_delete`,
|
|
327
|
+
title: `Delete a ${collection.name} document`,
|
|
328
|
+
description: `Delete one document from ${collection.name}. Idempotent - deleting a document that is not there succeeds.`,
|
|
329
|
+
inputSchema: {
|
|
330
|
+
type: "object",
|
|
331
|
+
properties: { id: { type: "string" } },
|
|
332
|
+
required: ["id"],
|
|
333
|
+
additionalProperties: false,
|
|
334
|
+
},
|
|
335
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
|
|
336
|
+
execute: (args) => call((asUser) => data
|
|
337
|
+
.delete({ workspaceId, collection: collection.name, id: String(args.id), asUser })
|
|
338
|
+
.then(() => ({ deleted: String(args.id) }))),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return tools;
|
|
342
|
+
}
|
|
343
|
+
/** Every tool one declaration yields, in a stable order. */
|
|
344
|
+
function deriveStoreTools(options) {
|
|
345
|
+
const { collections, data, workspaceId, writes = "none", prefix = "", include } = options;
|
|
346
|
+
const resolveUser = typeof options.asUser === "function" ? options.asUser : () => options.asUser;
|
|
347
|
+
const call = async (run) => run(await resolveUser());
|
|
348
|
+
const wanted = include ? new Set(include) : null;
|
|
349
|
+
const selected = collections.filter((collection) => collection.kind === "data" && (!wanted || wanted.has(collection.name)));
|
|
350
|
+
if (wanted) {
|
|
351
|
+
const declared = new Set(collections.map((collection) => collection.name));
|
|
352
|
+
const missing = [...wanted].filter((name) => !declared.has(name));
|
|
353
|
+
if (missing.length > 0) {
|
|
354
|
+
throw new Error(`No declaration for ${missing.join(", ")}. Declared collections: ${[...declared].join(", ")}.`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const byBaseName = new Map();
|
|
358
|
+
const tools = [];
|
|
359
|
+
for (const collection of selected) {
|
|
360
|
+
const base = `${prefix}${toolBaseName(collection.name)}`;
|
|
361
|
+
const clash = byBaseName.get(base);
|
|
362
|
+
if (clash) {
|
|
363
|
+
throw new Error(`"${collection.name}" and "${clash}" both derive the tool prefix "${base}". Rename one, or give one its own toolset with a prefix.`);
|
|
364
|
+
}
|
|
365
|
+
byBaseName.set(base, collection.name);
|
|
366
|
+
tools.push(...buildReadTools(collection, base, call, data, workspaceId), ...buildWriteTools(collection, base, call, data, workspaceId, writes));
|
|
367
|
+
}
|
|
368
|
+
return tools;
|
|
369
|
+
}
|
|
@@ -334,6 +334,13 @@ function createConnectHandler(options) {
|
|
|
334
334
|
const upstream = await client.ai.chat({
|
|
335
335
|
workspaceId: workspaceId,
|
|
336
336
|
messages: body.messages,
|
|
337
|
+
// Forwarded unvalidated on purpose. These are your application's own
|
|
338
|
+
// description of itself and of where the user is - Connect bounds and
|
|
339
|
+
// sanitises them on arrival, and uses them only to write the answer and
|
|
340
|
+
// to validate action links. Nothing here reaches an authorization
|
|
341
|
+
// decision, so there is nothing for this layer to check more strictly.
|
|
342
|
+
screenContext: body.screenContext,
|
|
343
|
+
screens: body.screens,
|
|
337
344
|
// Connect re-derives the workspace from this token, so the line above
|
|
338
345
|
// is belt to its braces - the browser cannot reach another tenant's
|
|
339
346
|
// Context Index even if this route were changed to trust the request.
|
|
@@ -30,7 +30,14 @@ class AiResource {
|
|
|
30
30
|
// it from the header. Passing a signed-in user's session token makes
|
|
31
31
|
// Connect derive the workspace from *that* rather than from
|
|
32
32
|
// `workspaceId`, which is what the bridge relies on.
|
|
33
|
-
json: {
|
|
33
|
+
json: {
|
|
34
|
+
workspaceId: input.workspaceId,
|
|
35
|
+
messages: input.messages,
|
|
36
|
+
// Omitted entirely when absent, rather than sent as `undefined` - an
|
|
37
|
+
// application that describes no screens should send no field.
|
|
38
|
+
...(input.screens ? { screens: input.screens } : {}),
|
|
39
|
+
...(input.screenContext ? { screenContext: input.screenContext } : {}),
|
|
40
|
+
},
|
|
34
41
|
headers: (0, acting_as_js_1.actingAs)(input.asUser),
|
|
35
42
|
raw: true,
|
|
36
43
|
signal: options.signal,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.storeIndexCovers = storeIndexCovers;
|
|
4
|
+
exports.describeQueryRejection = describeQueryRejection;
|
|
5
|
+
const store_js_1 = require("./store.js");
|
|
6
|
+
/**
|
|
7
|
+
* The signature of an index scanned backwards.
|
|
8
|
+
*
|
|
9
|
+
* Firestore serves a query from an index in either direction as long as *every*
|
|
10
|
+
* ordered field is flipped, so `[a asc, b desc]` also answers `[a desc, b asc]`.
|
|
11
|
+
* Mirrored by Connect's own `indexCovers`; kept in step with it deliberately,
|
|
12
|
+
* because a check here that is stricter than the server's would refuse queries
|
|
13
|
+
* that actually work, and a looser one would promise queries that do not.
|
|
14
|
+
*/
|
|
15
|
+
function reversedIndexSignature(fields) {
|
|
16
|
+
return (0, store_js_1.storeIndexSignature)(fields.map((entry) => entry.direction === "array-contains"
|
|
17
|
+
? entry
|
|
18
|
+
: { field: entry.field, direction: entry.direction === "asc" ? "desc" : "asc" }));
|
|
19
|
+
}
|
|
20
|
+
/** Whether one of `declared` can serve a query needing exactly `required`. */
|
|
21
|
+
function storeIndexCovers(declared, required) {
|
|
22
|
+
const wanted = (0, store_js_1.storeIndexSignature)(required);
|
|
23
|
+
const mirrored = reversedIndexSignature(required);
|
|
24
|
+
return declared.some((index) => {
|
|
25
|
+
const signature = (0, store_js_1.storeIndexSignature)(index.fields);
|
|
26
|
+
return signature === wanted || signature === mirrored;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Why a collection cannot serve a query, in words a caller can act on - or
|
|
31
|
+
* `null` when it can.
|
|
32
|
+
*
|
|
33
|
+
* Runs the same two rules Connect runs, in the same order: every field must be
|
|
34
|
+
* declared queryable, and the shape must be inside an automatic single-field
|
|
35
|
+
* index or covered by a declared composite one. Checking here rather than
|
|
36
|
+
* letting the call fail is not about saving a round trip - it is so the refusal
|
|
37
|
+
* can name the index to declare while the caller still has the query in hand.
|
|
38
|
+
*/
|
|
39
|
+
function describeQueryRejection(collection, query) {
|
|
40
|
+
const filters = query.filters ?? [];
|
|
41
|
+
const orderBy = query.orderBy ?? null;
|
|
42
|
+
const queryable = new Set(collection.queryableFields);
|
|
43
|
+
const undeclared = [
|
|
44
|
+
...new Set([...filters.map((filter) => filter.field), ...(orderBy ? [orderBy.field] : [])]),
|
|
45
|
+
].filter((field) => !queryable.has(field));
|
|
46
|
+
if (undeclared.length > 0) {
|
|
47
|
+
return `"${collection.name}" cannot be filtered or ordered on ${undeclared.join(", ")}. Queryable fields are: ${collection.queryableFields.join(", ")}.`;
|
|
48
|
+
}
|
|
49
|
+
let required;
|
|
50
|
+
try {
|
|
51
|
+
required = (0, store_js_1.storeIndexFor)({ filters, orderBy });
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
// Firestore cannot serve the shape at all - two `array-contains`, ranges
|
|
55
|
+
// over two fields, a range ordered by another field. No index would help,
|
|
56
|
+
// so the thrown message is the whole answer.
|
|
57
|
+
return error instanceof Error ? error.message : String(error);
|
|
58
|
+
}
|
|
59
|
+
if (!required)
|
|
60
|
+
return null;
|
|
61
|
+
if (storeIndexCovers(collection.indexes, required))
|
|
62
|
+
return null;
|
|
63
|
+
return `"${collection.name}" has no index for this query. It would need one over ${required
|
|
64
|
+
.map((entry) => `${entry.field} (${entry.direction})`)
|
|
65
|
+
.join(", ")}.`;
|
|
66
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Matching a live path against the screens your application declared.
|
|
3
|
+
*
|
|
4
|
+
* The same rule Connect uses for its own routes, and it has to be here too
|
|
5
|
+
* because only this side knows what your app's routes are: a dynamic segment
|
|
6
|
+
* matches exactly one path segment, and where several patterns fit, the most
|
|
7
|
+
* *literal* wins - so `/deals/new` resolves to itself rather than to
|
|
8
|
+
* `/deals/[dealId]` merely because that also fits.
|
|
9
|
+
*
|
|
10
|
+
* Plain functions with no React and no imports beyond the type, so the panel,
|
|
11
|
+
* the action links and any host that wants to reuse the matching can share one
|
|
12
|
+
* implementation.
|
|
13
|
+
*/
|
|
14
|
+
/** Trims the query string, the hash and any trailing slash. */
|
|
15
|
+
function normalise(path) {
|
|
16
|
+
const withoutQuery = path.split(/[?#]/)[0] ?? "";
|
|
17
|
+
return withoutQuery.length > 1 ? withoutQuery.replace(/\/+$/, "") : withoutQuery;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The declared screen a live path is on, or `null` if none matches.
|
|
21
|
+
*
|
|
22
|
+
* `null` is a normal outcome, not an error: an application need only describe
|
|
23
|
+
* the screens it wants the assistant to know about, and an undescribed one
|
|
24
|
+
* simply costs the assistant its orientation rather than costing the user an
|
|
25
|
+
* answer.
|
|
26
|
+
*/
|
|
27
|
+
export function matchConnectScreen(screens, path) {
|
|
28
|
+
const segments = normalise(path).split("/").filter(Boolean);
|
|
29
|
+
let best = null;
|
|
30
|
+
for (const screen of screens) {
|
|
31
|
+
const pattern = normalise(screen.path).split("/").filter(Boolean);
|
|
32
|
+
if (pattern.length !== segments.length)
|
|
33
|
+
continue;
|
|
34
|
+
let literals = 0;
|
|
35
|
+
let matches = true;
|
|
36
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
37
|
+
const part = pattern[i];
|
|
38
|
+
if (part.startsWith("[") && part.endsWith("]"))
|
|
39
|
+
continue;
|
|
40
|
+
if (part !== segments[i]) {
|
|
41
|
+
matches = false;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
literals++;
|
|
45
|
+
}
|
|
46
|
+
if (matches && (!best || literals > best.literals))
|
|
47
|
+
best = { screen, literals };
|
|
48
|
+
}
|
|
49
|
+
return best?.screen ?? null;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether an href the model produced may be rendered as a link.
|
|
53
|
+
*
|
|
54
|
+
* Same reasoning as Connect's own `showActions` check, and the same conclusion:
|
|
55
|
+
* an assistant that can emit an arbitrary URL is a redirect the model can be
|
|
56
|
+
* talked into, and retrieved content shares a context window with its
|
|
57
|
+
* tool-calling authority. So a link is drawn only when it resolves to a screen
|
|
58
|
+
* *your application declared* - which makes an external destination structurally
|
|
59
|
+
* impossible, since no route pattern has a scheme or a host.
|
|
60
|
+
*
|
|
61
|
+
* The protocol-relative form is refused separately: `//evil.example.com` has no
|
|
62
|
+
* scheme for a path matcher to trip over, and would otherwise reach an anchor
|
|
63
|
+
* as an absolute destination.
|
|
64
|
+
*/
|
|
65
|
+
export function isLinkableConnectScreen(screens, href) {
|
|
66
|
+
if (!href.startsWith("/") || href.startsWith("//"))
|
|
67
|
+
return false;
|
|
68
|
+
return matchConnectScreen(screens, href) !== null;
|
|
69
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import { useMemo, useState } from "react";
|
|
3
|
+
import { Fragment, useMemo, useState } from "react";
|
|
4
4
|
import { Button } from "@heroui/react";
|
|
5
5
|
import { ArrowDown, ArrowUp, ChevronsUpDown, Download, ExternalLink, FileText, Minus, TrendingDown, TrendingUp } from "lucide-react";
|
|
6
|
+
import { isLinkableConnectScreen } from "./ai-screens.js";
|
|
6
7
|
import { AI_EMPTY_VALUE, AI_TONE_CHIP, AI_TONE_DOT, aiStatusTone, formatAiValue, isNumericAiFormat, } from "./ai-format.js";
|
|
7
8
|
/**
|
|
8
9
|
* What Ask AI draws.
|
|
@@ -221,3 +222,38 @@ export function AiSources({ sources }) {
|
|
|
221
222
|
return (_jsx("li", { children: source.uri ? (_jsxs("a", { href: source.uri, target: "_blank", rel: "noopener noreferrer", className: "flex gap-1.5 rounded-sm hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus", children: [label, _jsx(ExternalLink, { className: "mt-0.5 size-3 shrink-0 text-muted", "aria-hidden": true })] })) : (_jsx("span", { className: "flex gap-1.5", children: label })) }, `${source.uri ?? source.title}-${index}`));
|
|
222
223
|
}) })] }));
|
|
223
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Where to go next, as links into *your* application.
|
|
227
|
+
*
|
|
228
|
+
* ## Why links and not navigation
|
|
229
|
+
*
|
|
230
|
+
* The obvious version of "the assistant can act" moves the user. It is also the
|
|
231
|
+
* version that breaks the property the whole design rests on: nothing happens
|
|
232
|
+
* without a click. Retrieved content shares a context window with the model's
|
|
233
|
+
* tool-calling authority, so an auto-navigate is a redirect primitive that
|
|
234
|
+
* content can reach. A link cannot be, and loses nothing - the user was going
|
|
235
|
+
* to click something anyway.
|
|
236
|
+
*
|
|
237
|
+
* ## Why every href is checked against your declared screens
|
|
238
|
+
*
|
|
239
|
+
* The model can emit any string. Only your application knows which strings are
|
|
240
|
+
* real routes, which is why `screens` is a prop rather than something Connect
|
|
241
|
+
* could supply. A link that does not resolve is *dropped* rather than rendered
|
|
242
|
+
* broken - the prose around it still stands, so a missing link reads as terse
|
|
243
|
+
* rather than wrong.
|
|
244
|
+
*
|
|
245
|
+
* `renderLink` is how this stays router-agnostic. Given one, a link is your
|
|
246
|
+
* framework's - a Next `<Link>`, a TanStack `<Link>` - and navigation is a
|
|
247
|
+
* client transition. Without one it falls back to an anchor, which works
|
|
248
|
+
* everywhere and costs a document load.
|
|
249
|
+
*/
|
|
250
|
+
export function AiActions({ title, actions, screens, renderLink, }) {
|
|
251
|
+
const resolved = actions.filter((action) => isLinkableConnectScreen(screens, action.href));
|
|
252
|
+
if (resolved.length === 0)
|
|
253
|
+
return null;
|
|
254
|
+
const className = "group flex items-center gap-2 rounded-lg border border-separator bg-surface px-3 py-2 no-underline transition-colors hover:border-accent";
|
|
255
|
+
return (_jsxs("div", { className: "my-3", children: [title ? _jsx("p", { className: "mb-1.5 text-xs font-medium text-muted", children: title }) : null, _jsx("div", { className: "flex flex-col gap-1.5", children: resolved.map((action) => {
|
|
256
|
+
const body = (_jsxs("span", { className: "min-w-0 flex-1", children: [_jsx("span", { className: "block truncate text-xs font-medium text-foreground", children: action.label }), action.detail ? (_jsx("span", { className: "block truncate text-[11px] text-muted", children: action.detail })) : null] }));
|
|
257
|
+
return (_jsx(Fragment, { children: renderLink ? (renderLink({ href: action.href, className, children: body })) : (_jsx("a", { href: action.href, className: className, children: body })) }, action.href));
|
|
258
|
+
}) })] }));
|
|
259
|
+
}
|