@colixsystems/widget-sdk 0.147.0 → 0.148.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/README.md +25 -1
- package/dist/contract.cjs +61 -1
- package/dist/contract.js +61 -1
- package/dist/hooks.js +190 -0
- package/dist/index.d.ts +54 -3
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/dist/linter.cjs +20 -0
- package/dist/linter.js +20 -0
- package/dist/property-schema.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,6 +47,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
47
47
|
| **DATASTORE** | `useDatastoreSchema(tableId)` | `{ schema, loading, error, refetch }` | `schema(tableId)` — `datastore.read:<table>` |
|
|
48
48
|
| **DATASTORE** | `useBoundColumns(tableId, shape, props)` | `{ columns, resolved, missing, loading, error }` | `schema(tableId)` (built on `useDatastoreSchema`) — `datastore.read:<table>`. Resolves author-bound column NAMES from `props` by exact name → case-insensitive name → first unclaimed column matching `shape[key].dataType`, so a column an author renamed after install still resolves instead of `record[props.titleField]` reading `undefined`. `columns` holds the resolved NAME (`record[columns.titleField]`); `resolved` holds the full `Column`; `missing` lists non-`optional` keys that never resolved. Falsy `tableId` collapses to `{ columns: {}, resolved: {}, missing: Object.keys(shape), loading: false, error: null }`. |
|
|
49
49
|
| **DATASTORE** | `useInterpretDraft(tableId)` | `{ interpret, interpreting, error, result, available }` | `interpret(tableId, body)` — `datastore.read:<table>`. Turns ONE sentence a user typed ("walk at 11 am tomorrow") into DRAFT column values so a form can prefill itself. IMPERATIVE: call `interpret(text, { fields?, timeZone? })` from an event handler, never on mount. It DRAFTS and writes nothing — show the values for review, then submit through `useDatastoreMutation().create`. Resolves to `{ values, unresolved }`; `values` is keyed by column NAME (the shape `create()` takes) and `unresolved` names the fields the sentence did not state. Only text / number / boolean / date / datetime / array columns are drafted — `FILE`, `RELATION`, `USER` and `USER_GROUP` carry ids and are never guessed. Fails closed to an empty draft. **Every call spends the workspace's AI credits** and is rate-limited per actor, so call it once per user action (never on mount or in a render loop); once the workspace runs out the call is refused with a generic 429 — an app user is deliberately **not** told the workspace's billing state, since they have never heard of an AI credit and cannot buy one. Never surface a raw error to the person filling the form: say drafting is unavailable and keep every field editable by hand. `available` is false where the host brokers no interpreter. |
|
|
50
|
+
| **AGENTS** | `useAgent(agentRef)` | `{ send, reset, messages, sending, error, available }` | `ctx.agents` — `ai.invoke:*`. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). IMPERATIVE: call `send(text)` from an event handler, never on mount — the first send opens the conversation, so a widget nobody types into costs nothing. `messages` is the running transcript, oldest first. You **cannot** set or read the system prompt, replay an edited transcript, or pick a model: the server assembles every turn from the stored prompt plus the stored history, and `content` is the only thing that reaches the wire. `reset()` drops the thread (a "new chat" affordance). **Every send spends the workspace's AI credits** and is rate-limited per caller AND per workspace. Rejects with `AgentError`; branch on `.code` — `AI_QUOTA_EXCEEDED` (429) is the workspace's credit ceiling and will NOT clear on a retry, unlike `RATE_LIMITED`, so never offer "try again" for it, and `AGENT_DISABLED` (409) means the author turned it off, so hide the affordance. Never surface a raw error or the workspace's billing state to an app user. `available` is false when no agent is bound or the host brokers none — render a visible unbound state, never a blank box. |
|
|
50
51
|
| **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*`. **To CLEAR a cell, pass it empty** — `update(id, { when: null })` (also `""` or `[]`) empties that cell and it reads back as `null` (`[]` on an array column). There is no separate clear call, so never write a sentinel such as `0` or a placeholder date to mean "no value". `false` and `0` are values, not clears, and a REQUIRED column cannot be cleared. |
|
|
51
52
|
| **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the REQ-RT-07 socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
|
|
52
53
|
| **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
|
|
@@ -71,7 +72,29 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
71
72
|
|
|
72
73
|
## Status
|
|
73
74
|
|
|
74
|
-
`v0.
|
|
75
|
+
`v0.148.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
|
|
76
|
+
|
|
77
|
+
### What's new in 0.148.0 (contract 1.116.0)
|
|
78
|
+
|
|
79
|
+
**New `useAgent(agentRef)` hook — a widget can talk to the WORKSPACE's own AI agent (sc-7991).** A new `agents` host client (`@colixsystems/agents-client`), a new `agentRef` propertySchema type and a new `ai.invoke:*` scope. Everything is additive; nothing existing changes shape.
|
|
80
|
+
|
|
81
|
+
A workspace **agent** is a name plus a system prompt its author wrote in the Studio. This hook lets any widget hold a conversation with one, without touching HTTP and without ever holding an API key.
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
const { send, reset, messages, sending, error, available } = useAgent(props.agentId);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**What a widget deliberately cannot do.** You cannot set the system prompt, send your own transcript, or pick a model, and no API surface returns the prompt. The server assembles every turn from the stored prompt plus the stored history, and `content` is the only field that reaches the wire — not because the rest is filtered out, but because nothing else is ever sent. That guarantee is about the WIRE, not about secrecy: a model can be talked into repeating its own prompt, so an agent's prompt is not a secret store — never put credentials, or data the app's users may not see, in one. `messages` is the transcript this widget has seen; the real one is the server's.
|
|
88
|
+
|
|
89
|
+
**Imperative, like `useInterpretDraft`.** It never fires on mount, and the FIRST `send` opens the conversation — so a widget an author places and nobody types into costs the workspace nothing.
|
|
90
|
+
|
|
91
|
+
**Bind it with an `agentRef` property.** Declare `{ type: "agentRef" }` in your `propertySchema` and the Studio renders an agent picker; the value is the agent id you pass to `useAgent`. It is a bare UUID like `pageRef` / `groupRef`, so Copy Workspace carries a bound widget over to the copied workspace's own agent.
|
|
92
|
+
|
|
93
|
+
**Every send spends the workspace's AI credits**, and is rate-limited per caller *and* per workspace. Branch on `AgentError.code`: `AI_QUOTA_EXCEEDED` (429) is the credit ceiling and will **not** clear on a retry — offering "try again" there is worse than useless — where `RATE_LIMITED` (429) will. `AGENT_DISABLED` (409) means the author switched the agent off, so hide the affordance rather than retrying. Never show an app user a raw error or the workspace's billing state: they have not heard of an AI credit and cannot buy one.
|
|
94
|
+
|
|
95
|
+
**`available: false` is a render state, not an error.** It means no agent is bound, or this host brokers no agents client (an unbound canvas preview). Render a visible unbound state — never a blank box.
|
|
96
|
+
|
|
97
|
+
The linter rejects `useAgent()` unless your manifest declares `ai.invoke:*` in `requestedScopes`.
|
|
75
98
|
|
|
76
99
|
### What's new in 0.147.0 (contract 1.115.0)
|
|
77
100
|
|
|
@@ -84,6 +107,7 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
84
107
|
|
|
85
108
|
**No shipped app moved.** A backfill (`20270123000000_sc8033_footer_tokens_uncouple`) materialised into each workspace's `footer` block exactly what it had been inheriting, and split every stored `activeColor` onto the key that says which half it meant. Nothing is written where the old chain ended in a default the new one also ends in.
|
|
86
109
|
|
|
110
|
+
|
|
87
111
|
### What's new in 0.146.0 (contract 1.114.0)
|
|
88
112
|
|
|
89
113
|
**The chrome divider's width is bounded in the resolver, so both hosts agree (sc-7965).** `resolveFooterTokens` and `resolveSidebarTokens` returned `borderWidth` as any finite number. Only the export bounded it — on its way into the StyleSheet — so a stored `1e9` ruled a **billion-pixel border across the published web app** and a normal one in the exported app. The clamp moved into the shared resolver, where both hosts inherit it.
|
package/dist/contract.cjs
CHANGED
|
@@ -1172,6 +1172,47 @@ const HOOKS = [
|
|
|
1172
1172
|
requiredContextSlice: ["datastore.interpret"],
|
|
1173
1173
|
scopes: ["datastore.read:<table>"],
|
|
1174
1174
|
},
|
|
1175
|
+
{
|
|
1176
|
+
name: "useAgent",
|
|
1177
|
+
signature: "useAgent(agentRef)",
|
|
1178
|
+
description:
|
|
1179
|
+
"sc-7991 — hold a conversation with one of the WORKSPACE's own AI " +
|
|
1180
|
+
"agents (an author-written name + system prompt). IMPERATIVE: call " +
|
|
1181
|
+
"send(text) from an event handler, never on mount — the first send " +
|
|
1182
|
+
"opens the conversation, so a widget nobody types into costs nothing. " +
|
|
1183
|
+
"`messages` is the running transcript, oldest first, each " +
|
|
1184
|
+
"{ id, role, content, created_at } with role 'user' | 'assistant'. " +
|
|
1185
|
+
"You CANNOT set the system prompt, replay an edited transcript, " +
|
|
1186
|
+
"or pick a model. No API returns the prompt — but that is a guarantee " +
|
|
1187
|
+
"about the WIRE, not secrecy: a model can be talked into repeating its " +
|
|
1188
|
+
"own prompt, so never put credentials or hidden data in one. " +
|
|
1189
|
+
"or pick a model: the server assembles every turn from the stored " +
|
|
1190
|
+
"prompt plus the stored history, and `content` is the only thing that " +
|
|
1191
|
+
"goes on the wire. `agentRef` is an agent id an author bound through an " +
|
|
1192
|
+
"`agentRef` property — when it is missing, or the host brokers no " +
|
|
1193
|
+
"agents client, `available` is false and send() rejects UNAVAILABLE; " +
|
|
1194
|
+
"render a visible unbound state, NEVER a blank box. reset() drops the " +
|
|
1195
|
+
"thread and clears the transcript (a 'new chat' affordance). " +
|
|
1196
|
+
"Every send spends the workspace's AI CREDITS and is rate-limited per " +
|
|
1197
|
+
"caller AND per workspace, so call it once per user action. " +
|
|
1198
|
+
"AgentError.code distinguishes the refusals that share a status: " +
|
|
1199
|
+
"AI_QUOTA_EXCEEDED (429) is the workspace's credit ceiling and will " +
|
|
1200
|
+
"NOT clear on a retry — never offer 'try again' for it — where " +
|
|
1201
|
+
"RATE_LIMITED (429) will; AGENT_DISABLED (409) means the author turned " +
|
|
1202
|
+
"it off, so hide the affordance rather than retrying. NEVER surface a " +
|
|
1203
|
+
"raw error or the workspace's billing state to an app user. " +
|
|
1204
|
+
"Reads ctx.agents.",
|
|
1205
|
+
returnShape: {
|
|
1206
|
+
send: "(text) => Promise<{ id, role, content, created_at } | null>",
|
|
1207
|
+
reset: "() => void",
|
|
1208
|
+
messages: "Array<{ id, conversation_id, role, content, created_at }>",
|
|
1209
|
+
sending: "boolean",
|
|
1210
|
+
error: "AgentError | null",
|
|
1211
|
+
available: "boolean // false when no agent is bound, or the host brokers none",
|
|
1212
|
+
},
|
|
1213
|
+
requiredContextSlice: ["agents.conversations"],
|
|
1214
|
+
scopes: ["ai.invoke:*"],
|
|
1215
|
+
},
|
|
1175
1216
|
{
|
|
1176
1217
|
name: "useDatastoreMutation",
|
|
1177
1218
|
signature: "useDatastoreMutation(tableId)",
|
|
@@ -2271,6 +2312,21 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2271
2312
|
required: false,
|
|
2272
2313
|
fields: { params: "object", set: "function" },
|
|
2273
2314
|
},
|
|
2315
|
+
agents: {
|
|
2316
|
+
description:
|
|
2317
|
+
"sc-7991 — injected @colixsystems/agents-client instance. " +
|
|
2318
|
+
"{ conversations(agentId) -> { create(), list(query), remove(conversationId), " +
|
|
2319
|
+
"messages(conversationId) -> { list(query), send({ content }) } } }. " +
|
|
2320
|
+
"Backs useAgent(). The workspace's agent owns the system prompt and the " +
|
|
2321
|
+
"server owns the transcript, so `send` puts ONLY `content` on the wire — " +
|
|
2322
|
+
"a prompt, a history or a model id cannot be smuggled through. " +
|
|
2323
|
+
"A conversation belongs to the CALLER (the app user, or the studio user " +
|
|
2324
|
+
"previewing on the canvas); another user's id is a 404, never a 403. " +
|
|
2325
|
+
"Absent on a host that brokers no agents (an unbound preview), where " +
|
|
2326
|
+
"useAgent() degrades to available: false rather than throwing.",
|
|
2327
|
+
required: false,
|
|
2328
|
+
fields: { conversations: "function" },
|
|
2329
|
+
},
|
|
2274
2330
|
datastore: {
|
|
2275
2331
|
description:
|
|
2276
2332
|
"Injected @colixsystems/datastore-client instance. " +
|
|
@@ -4054,6 +4110,10 @@ const CONTRACT = deepFreeze({
|
|
|
4054
4110
|
// and the inherited colours are hex-guarded on the way out as well as in.
|
|
4055
4111
|
// Every colour still falls back to the sidebar's: an untouched workspace is
|
|
4056
4112
|
// unchanged.
|
|
4113
|
+
// 1.116.0: additive (sc-7991) — `useAgent(agentRef)` + the `agents` host
|
|
4114
|
+
// client + the `agentRef` propertySchema type + the `ai.invoke:*` scope. A
|
|
4115
|
+
// widget can hold a conversation with one of the WORKSPACE's own AI agents.
|
|
4116
|
+
// Nothing existing changes shape.
|
|
4057
4117
|
// 1.114.0: behaviour (sc-7965) — `resolveFooterTokens` / `resolveSidebarTokens`
|
|
4058
4118
|
// now return a `borderWidth` CLAMPED to 1-8 instead of any finite number.
|
|
4059
4119
|
// The bound lived only in the export (`coerceBorderWidth(...) ?? 1`), so a
|
|
@@ -4080,7 +4140,7 @@ const CONTRACT = deepFreeze({
|
|
|
4080
4140
|
// where the rail's and the header's keep sc-7965's 1-8 clamp. A backfill
|
|
4081
4141
|
// (`20270123000000_sc8033_footer_tokens_uncouple`) materialised what each
|
|
4082
4142
|
// workspace was inheriting, so no shipped app moved.
|
|
4083
|
-
version: "1.
|
|
4143
|
+
version: "1.116.0",
|
|
4084
4144
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4085
4145
|
hooks: HOOKS,
|
|
4086
4146
|
primitives: PRIMITIVES,
|
package/dist/contract.js
CHANGED
|
@@ -1172,6 +1172,47 @@ const HOOKS = [
|
|
|
1172
1172
|
requiredContextSlice: ["datastore.interpret"],
|
|
1173
1173
|
scopes: ["datastore.read:<table>"],
|
|
1174
1174
|
},
|
|
1175
|
+
{
|
|
1176
|
+
name: "useAgent",
|
|
1177
|
+
signature: "useAgent(agentRef)",
|
|
1178
|
+
description:
|
|
1179
|
+
"sc-7991 — hold a conversation with one of the WORKSPACE's own AI " +
|
|
1180
|
+
"agents (an author-written name + system prompt). IMPERATIVE: call " +
|
|
1181
|
+
"send(text) from an event handler, never on mount — the first send " +
|
|
1182
|
+
"opens the conversation, so a widget nobody types into costs nothing. " +
|
|
1183
|
+
"`messages` is the running transcript, oldest first, each " +
|
|
1184
|
+
"{ id, role, content, created_at } with role 'user' | 'assistant'. " +
|
|
1185
|
+
"You CANNOT set the system prompt, replay an edited transcript, " +
|
|
1186
|
+
"or pick a model. No API returns the prompt — but that is a guarantee " +
|
|
1187
|
+
"about the WIRE, not secrecy: a model can be talked into repeating its " +
|
|
1188
|
+
"own prompt, so never put credentials or hidden data in one. " +
|
|
1189
|
+
"or pick a model: the server assembles every turn from the stored " +
|
|
1190
|
+
"prompt plus the stored history, and `content` is the only thing that " +
|
|
1191
|
+
"goes on the wire. `agentRef` is an agent id an author bound through an " +
|
|
1192
|
+
"`agentRef` property — when it is missing, or the host brokers no " +
|
|
1193
|
+
"agents client, `available` is false and send() rejects UNAVAILABLE; " +
|
|
1194
|
+
"render a visible unbound state, NEVER a blank box. reset() drops the " +
|
|
1195
|
+
"thread and clears the transcript (a 'new chat' affordance). " +
|
|
1196
|
+
"Every send spends the workspace's AI CREDITS and is rate-limited per " +
|
|
1197
|
+
"caller AND per workspace, so call it once per user action. " +
|
|
1198
|
+
"AgentError.code distinguishes the refusals that share a status: " +
|
|
1199
|
+
"AI_QUOTA_EXCEEDED (429) is the workspace's credit ceiling and will " +
|
|
1200
|
+
"NOT clear on a retry — never offer 'try again' for it — where " +
|
|
1201
|
+
"RATE_LIMITED (429) will; AGENT_DISABLED (409) means the author turned " +
|
|
1202
|
+
"it off, so hide the affordance rather than retrying. NEVER surface a " +
|
|
1203
|
+
"raw error or the workspace's billing state to an app user. " +
|
|
1204
|
+
"Reads ctx.agents.",
|
|
1205
|
+
returnShape: {
|
|
1206
|
+
send: "(text) => Promise<{ id, role, content, created_at } | null>",
|
|
1207
|
+
reset: "() => void",
|
|
1208
|
+
messages: "Array<{ id, conversation_id, role, content, created_at }>",
|
|
1209
|
+
sending: "boolean",
|
|
1210
|
+
error: "AgentError | null",
|
|
1211
|
+
available: "boolean // false when no agent is bound, or the host brokers none",
|
|
1212
|
+
},
|
|
1213
|
+
requiredContextSlice: ["agents.conversations"],
|
|
1214
|
+
scopes: ["ai.invoke:*"],
|
|
1215
|
+
},
|
|
1175
1216
|
{
|
|
1176
1217
|
name: "useDatastoreMutation",
|
|
1177
1218
|
signature: "useDatastoreMutation(tableId)",
|
|
@@ -2267,6 +2308,21 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2267
2308
|
required: false,
|
|
2268
2309
|
fields: { params: "object", set: "function" },
|
|
2269
2310
|
},
|
|
2311
|
+
agents: {
|
|
2312
|
+
description:
|
|
2313
|
+
"sc-7991 — injected @colixsystems/agents-client instance. " +
|
|
2314
|
+
"{ conversations(agentId) -> { create(), list(query), remove(conversationId), " +
|
|
2315
|
+
"messages(conversationId) -> { list(query), send({ content }) } } }. " +
|
|
2316
|
+
"Backs useAgent(). The workspace's agent owns the system prompt and the " +
|
|
2317
|
+
"server owns the transcript, so `send` puts ONLY `content` on the wire — " +
|
|
2318
|
+
"a prompt, a history or a model id cannot be smuggled through. " +
|
|
2319
|
+
"A conversation belongs to the CALLER (the app user, or the studio user " +
|
|
2320
|
+
"previewing on the canvas); another user's id is a 404, never a 403. " +
|
|
2321
|
+
"Absent on a host that brokers no agents (an unbound preview), where " +
|
|
2322
|
+
"useAgent() degrades to available: false rather than throwing.",
|
|
2323
|
+
required: false,
|
|
2324
|
+
fields: { conversations: "function" },
|
|
2325
|
+
},
|
|
2270
2326
|
datastore: {
|
|
2271
2327
|
description:
|
|
2272
2328
|
"Injected @colixsystems/datastore-client instance. " +
|
|
@@ -4050,6 +4106,10 @@ const CONTRACT = deepFreeze({
|
|
|
4050
4106
|
// and the inherited colours are hex-guarded on the way out as well as in.
|
|
4051
4107
|
// Every colour still falls back to the sidebar's: an untouched workspace is
|
|
4052
4108
|
// unchanged.
|
|
4109
|
+
// 1.116.0: additive (sc-7991) — `useAgent(agentRef)` + the `agents` host
|
|
4110
|
+
// client + the `agentRef` propertySchema type + the `ai.invoke:*` scope. A
|
|
4111
|
+
// widget can hold a conversation with one of the WORKSPACE's own AI agents.
|
|
4112
|
+
// Nothing existing changes shape.
|
|
4053
4113
|
// 1.114.0: behaviour (sc-7965) — `resolveFooterTokens` / `resolveSidebarTokens`
|
|
4054
4114
|
// now return a `borderWidth` CLAMPED to 1-8 instead of any finite number.
|
|
4055
4115
|
// The bound lived only in the export (`coerceBorderWidth(...) ?? 1`), so a
|
|
@@ -4076,7 +4136,7 @@ const CONTRACT = deepFreeze({
|
|
|
4076
4136
|
// where the rail's and the header's keep sc-7965's 1-8 clamp. A backfill
|
|
4077
4137
|
// (`20270123000000_sc8033_footer_tokens_uncouple`) materialised what each
|
|
4078
4138
|
// workspace was inheriting, so no shipped app moved.
|
|
4079
|
-
version: "1.
|
|
4139
|
+
version: "1.116.0",
|
|
4080
4140
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4081
4141
|
hooks: HOOKS,
|
|
4082
4142
|
primitives: PRIMITIVES,
|
package/dist/hooks.js
CHANGED
|
@@ -2694,6 +2694,196 @@ export function useInterpretDraft(tableId) {
|
|
|
2694
2694
|
return { interpret, interpreting, error, result, available };
|
|
2695
2695
|
}
|
|
2696
2696
|
|
|
2697
|
+
/**
|
|
2698
|
+
* sc-7991 — structured error thrown by `useAgent().send`. Carries a stable
|
|
2699
|
+
* `code` so widgets branch without parsing message strings; mirrors the shape
|
|
2700
|
+
* of `TranslateError` / `NotificationError`.
|
|
2701
|
+
*
|
|
2702
|
+
* `code` is one of:
|
|
2703
|
+
* - "UNAVAILABLE" — the host brokers no agents client, or no agent
|
|
2704
|
+
* is bound (the widget should render its unbound
|
|
2705
|
+
* state, not an error)
|
|
2706
|
+
* - "AGENT_NOT_FOUND" — 404 (the bound agent was deleted)
|
|
2707
|
+
* - "AGENT_DISABLED" — 409 (the author turned it off; it still exists)
|
|
2708
|
+
* - "CONVERSATION_NOT_FOUND"— 404 (a stale thread; `reset()` starts a new one)
|
|
2709
|
+
* - "INVALID_MESSAGE" — 400 (empty, or past the length cap)
|
|
2710
|
+
* - "AI_QUOTA_EXCEEDED" — 429 (the WORKSPACE is out of AI credit; this
|
|
2711
|
+
* does NOT clear by retrying — say so)
|
|
2712
|
+
* - "RATE_LIMITED" — 429 (too many turns — retry shortly)
|
|
2713
|
+
* - "AI_UNAVAILABLE" — 503 (no AI provider configured)
|
|
2714
|
+
* - "AI_GENERATION_FAILED" — 502 (the provider did not answer)
|
|
2715
|
+
* - "AUTH_REQUIRED" — 401 (no signed-in app user)
|
|
2716
|
+
* - "INTERNAL" — anything else (network, 5xx)
|
|
2717
|
+
*/
|
|
2718
|
+
export class AgentError extends Error {
|
|
2719
|
+
constructor(code, message, opts) {
|
|
2720
|
+
super(message);
|
|
2721
|
+
this.name = "AgentError";
|
|
2722
|
+
this.code = code;
|
|
2723
|
+
if (opts && opts.cause) this.cause = opts.cause;
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
function toAgentError(err) {
|
|
2728
|
+
if (err instanceof AgentError) return err;
|
|
2729
|
+
const status =
|
|
2730
|
+
err && err.response && typeof err.response.status === "number"
|
|
2731
|
+
? err.response.status
|
|
2732
|
+
: err && typeof err.status === "number"
|
|
2733
|
+
? err.status
|
|
2734
|
+
: null;
|
|
2735
|
+
const bodyCode =
|
|
2736
|
+
(err && err.response && err.response.data && err.response.data.code) ||
|
|
2737
|
+
(err && typeof err.code === "string" ? err.code : null);
|
|
2738
|
+
const bodyMessage =
|
|
2739
|
+
(err && err.response && err.response.data && err.response.data.message) ||
|
|
2740
|
+
(err && err.message) ||
|
|
2741
|
+
"The agent could not answer";
|
|
2742
|
+
let code = "INTERNAL";
|
|
2743
|
+
if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
|
|
2744
|
+
else if (status === 401) code = "AUTH_REQUIRED";
|
|
2745
|
+
else if (status === 429) code = "RATE_LIMITED";
|
|
2746
|
+
else if (status === 400) code = "INVALID_MESSAGE";
|
|
2747
|
+
else if (status === 409) code = "AGENT_DISABLED";
|
|
2748
|
+
else if (status === 404) code = "AGENT_NOT_FOUND";
|
|
2749
|
+
else if (status === 502) code = "AI_GENERATION_FAILED";
|
|
2750
|
+
else if (status === 503) code = "AI_UNAVAILABLE";
|
|
2751
|
+
return new AgentError(code, bodyMessage, { cause: err });
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
/**
|
|
2755
|
+
* sc-7991 — hold a conversation with a workspace-authored AI agent.
|
|
2756
|
+
* Returns `{ send, reset, messages, sending, error, available }`.
|
|
2757
|
+
*
|
|
2758
|
+
* send(text) → Promise<{ role, content }> the agent's reply
|
|
2759
|
+
*
|
|
2760
|
+
* The hook is IMPERATIVE — it NEVER fires on mount; the widget calls `send`
|
|
2761
|
+
* from an event handler. The first `send` opens the conversation, so an author
|
|
2762
|
+
* who places the widget and never types costs the workspace nothing.
|
|
2763
|
+
*
|
|
2764
|
+
* `messages` is the running transcript this widget has seen, oldest first,
|
|
2765
|
+
* each `{ id, role, content, created_at }` with `role` of "user" | "assistant".
|
|
2766
|
+
* The SERVER owns the real transcript and the system prompt: the widget sends
|
|
2767
|
+
* one message and can neither read nor set the prompt.
|
|
2768
|
+
*
|
|
2769
|
+
* `agentRef` is the agent id an author bound through an `agentRef` property.
|
|
2770
|
+
* When it is absent, or the host brokers no agents client (an unbound canvas
|
|
2771
|
+
* preview), `available` is false and `send` rejects UNAVAILABLE rather than
|
|
2772
|
+
* throwing at render — the widget must render a visible unbound state, never a
|
|
2773
|
+
* blank box (CLAUDE.md §8).
|
|
2774
|
+
*
|
|
2775
|
+
* Routes through the injected `@colixsystems/agents-client` at `ctx.agents`,
|
|
2776
|
+
* so the Player and the Expo export resolve against the identical client.
|
|
2777
|
+
*/
|
|
2778
|
+
export function useAgent(agentRef) {
|
|
2779
|
+
const ctx = useWidgetContextOrThrow("useAgent");
|
|
2780
|
+
const available =
|
|
2781
|
+
Boolean(agentRef) &&
|
|
2782
|
+
Boolean(ctx.agents) &&
|
|
2783
|
+
typeof ctx.agents.conversations === "function";
|
|
2784
|
+
|
|
2785
|
+
const clientRef = useRef(available ? ctx.agents : null);
|
|
2786
|
+
clientRef.current = available ? ctx.agents : null;
|
|
2787
|
+
const agentIdRef = useRef(agentRef);
|
|
2788
|
+
agentIdRef.current = agentRef;
|
|
2789
|
+
// The open thread, held as the in-flight PROMISE rather than the id.
|
|
2790
|
+
//
|
|
2791
|
+
// A ref alone only fixes the SEQUENTIAL case. `if (!id) { id = await create() }`
|
|
2792
|
+
// reads the guard before the await and writes it after, so two overlapping
|
|
2793
|
+
// sends — a double-tap, or a widget sending on each keystroke — both see null,
|
|
2794
|
+
// both open a conversation, and both spend a paid turn. Latching the promise
|
|
2795
|
+
// makes the second caller await the FIRST create instead of starting another.
|
|
2796
|
+
const conversationRef = useRef(null);
|
|
2797
|
+
|
|
2798
|
+
const [messages, setMessages] = useState([]);
|
|
2799
|
+
const [sending, setSending] = useState(false);
|
|
2800
|
+
const [error, setError] = useState(null);
|
|
2801
|
+
|
|
2802
|
+
// Set during render when the binding changes; consumed below. A setState in
|
|
2803
|
+
// the render body would be a cascading re-render, so the flag is applied to
|
|
2804
|
+
// the value this render returns instead.
|
|
2805
|
+
const pendingResetRef = useRef(false);
|
|
2806
|
+
|
|
2807
|
+
// A changed binding is a different agent, so neither the open thread nor the
|
|
2808
|
+
// transcript carries — leaving the messages would render agent A's
|
|
2809
|
+
// conversation underneath agent B.
|
|
2810
|
+
const boundRef = useRef(agentRef);
|
|
2811
|
+
if (boundRef.current !== agentRef) {
|
|
2812
|
+
boundRef.current = agentRef;
|
|
2813
|
+
conversationRef.current = null;
|
|
2814
|
+
pendingResetRef.current = true;
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
const reset = useCallback(() => {
|
|
2818
|
+
conversationRef.current = null;
|
|
2819
|
+
setMessages([]);
|
|
2820
|
+
setError(null);
|
|
2821
|
+
}, []);
|
|
2822
|
+
|
|
2823
|
+
const send = useCallback(async (text) => {
|
|
2824
|
+
const client = clientRef.current;
|
|
2825
|
+
const agentId = agentIdRef.current;
|
|
2826
|
+
if (!client || !agentId) {
|
|
2827
|
+
throw new AgentError("UNAVAILABLE", "No agent is available for this host");
|
|
2828
|
+
}
|
|
2829
|
+
const content = typeof text === "string" ? text.trim() : "";
|
|
2830
|
+
if (!content) {
|
|
2831
|
+
throw new AgentError("INVALID_MESSAGE", "A message is required");
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
setSending(true);
|
|
2835
|
+
setError(null);
|
|
2836
|
+
try {
|
|
2837
|
+
const thread = client.conversations(agentId);
|
|
2838
|
+
if (!conversationRef.current) {
|
|
2839
|
+
// Assigned BEFORE the first await, so a concurrent send joins this one.
|
|
2840
|
+
conversationRef.current = thread.create().then((created) => {
|
|
2841
|
+
const id = created && created.id ? created.id : null;
|
|
2842
|
+
if (!id) {
|
|
2843
|
+
throw new AgentError("INTERNAL", "The conversation could not be opened");
|
|
2844
|
+
}
|
|
2845
|
+
return id;
|
|
2846
|
+
});
|
|
2847
|
+
}
|
|
2848
|
+
let conversationId;
|
|
2849
|
+
try {
|
|
2850
|
+
conversationId = await conversationRef.current;
|
|
2851
|
+
} catch (err) {
|
|
2852
|
+
// A failed open must not wedge the widget on a rejected promise.
|
|
2853
|
+
conversationRef.current = null;
|
|
2854
|
+
throw err;
|
|
2855
|
+
}
|
|
2856
|
+
const turn = await thread.messages(conversationId).send({ content });
|
|
2857
|
+
const userMessage = turn && turn.user_message ? turn.user_message : null;
|
|
2858
|
+
const reply = turn && turn.reply ? turn.reply : null;
|
|
2859
|
+
setMessages((prior) =>
|
|
2860
|
+
prior.concat(userMessage ? [userMessage] : [], reply ? [reply] : []),
|
|
2861
|
+
);
|
|
2862
|
+
setSending(false);
|
|
2863
|
+
return reply;
|
|
2864
|
+
} catch (err) {
|
|
2865
|
+
const e = toAgentError(err);
|
|
2866
|
+
// A stale thread is recoverable on the next send rather than permanently
|
|
2867
|
+
// broken: drop it so the widget opens a fresh one.
|
|
2868
|
+
if (e.code === "CONVERSATION_NOT_FOUND") conversationRef.current = null;
|
|
2869
|
+
setError(e);
|
|
2870
|
+
setSending(false);
|
|
2871
|
+
throw e;
|
|
2872
|
+
}
|
|
2873
|
+
}, []);
|
|
2874
|
+
|
|
2875
|
+
// The binding changed this render: hand back the empty transcript now rather
|
|
2876
|
+
// than one frame of the previous agent's conversation.
|
|
2877
|
+
let visibleMessages = messages;
|
|
2878
|
+
if (pendingResetRef.current) {
|
|
2879
|
+
pendingResetRef.current = false;
|
|
2880
|
+
visibleMessages = [];
|
|
2881
|
+
if (messages.length > 0) setMessages([]);
|
|
2882
|
+
}
|
|
2883
|
+
|
|
2884
|
+
return { send, reset, messages: visibleMessages, sending, error, available };
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2697
2887
|
/**
|
|
2698
2888
|
* Datastore mutation hook. Returns { create, update, delete }, each method
|
|
2699
2889
|
* returning a Promise. Routes through the injected
|
package/dist/index.d.ts
CHANGED
|
@@ -663,6 +663,8 @@ export interface WidgetContext<TProps = unknown> {
|
|
|
663
663
|
notifications: NotificationsClient;
|
|
664
664
|
/** Injected @colixsystems/identification-client; backs useIdentification. */
|
|
665
665
|
identification: IdentificationClient;
|
|
666
|
+
/** sc-7991 — injected @colixsystems/agents-client backing useAgent(). */
|
|
667
|
+
agents?: { conversations(agentId: string): unknown };
|
|
666
668
|
/** Host child-node renderer; backs WidgetTree / useChildRenderer. */
|
|
667
669
|
renderer: { renderNode(node: unknown): unknown };
|
|
668
670
|
events: { emit(eventName: string, payload?: unknown): void };
|
|
@@ -1204,6 +1206,36 @@ export function useInterpretDraft(
|
|
|
1204
1206
|
tableId: string | null | undefined,
|
|
1205
1207
|
): InterpretDraftApi;
|
|
1206
1208
|
|
|
1209
|
+
export interface AgentMessage {
|
|
1210
|
+
id: string;
|
|
1211
|
+
conversation_id: string;
|
|
1212
|
+
/** The system prompt is NOT part of the transcript — it lives on the agent. */
|
|
1213
|
+
role: "user" | "assistant";
|
|
1214
|
+
content: string;
|
|
1215
|
+
created_at: string;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
export interface AgentApi {
|
|
1219
|
+
/** Imperative — call from an event handler. The first send opens the thread. */
|
|
1220
|
+
send(text: string): Promise<AgentMessage | null>;
|
|
1221
|
+
/** Drop the open thread and clear the transcript this widget has seen. */
|
|
1222
|
+
reset(): void;
|
|
1223
|
+
/** Oldest first. The SERVER owns the real transcript. */
|
|
1224
|
+
messages: AgentMessage[];
|
|
1225
|
+
sending: boolean;
|
|
1226
|
+
error: AgentError | null;
|
|
1227
|
+
/** False when no agent is bound, or the host brokers no agents client. */
|
|
1228
|
+
available: boolean;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* sc-7991 — hold a conversation with a workspace-authored AI agent.
|
|
1233
|
+
* IMPERATIVE: never fires on mount, so an unused widget costs nothing. The
|
|
1234
|
+
* widget sends ONE message; the system prompt and the real transcript stay
|
|
1235
|
+
* server-side and are never readable or settable from here.
|
|
1236
|
+
*/
|
|
1237
|
+
export function useAgent(agentRef: string | null | undefined): AgentApi;
|
|
1238
|
+
|
|
1207
1239
|
// REQ-RT-07 realtime subscription transport state.
|
|
1208
1240
|
export type DatastoreSubscriptionStatus =
|
|
1209
1241
|
| "connecting"
|
|
@@ -1926,10 +1958,29 @@ export class DirectoryError extends Error {
|
|
|
1926
1958
|
}
|
|
1927
1959
|
|
|
1928
1960
|
/**
|
|
1929
|
-
* sc-
|
|
1930
|
-
*
|
|
1931
|
-
*
|
|
1961
|
+
* sc-7991 — structured error thrown by `useAgent().send`. Branch on `code`:
|
|
1962
|
+
* `AI_QUOTA_EXCEEDED` is the workspace's credit ceiling and will NOT clear on
|
|
1963
|
+
* a retry, unlike `RATE_LIMITED`; `AGENT_DISABLED` means the author switched
|
|
1964
|
+
* the agent off; `AI_AGENTS_DISABLED` means the feature is not released for
|
|
1965
|
+
* this workspace. Never show an app user a raw code or the billing state.
|
|
1932
1966
|
*/
|
|
1967
|
+
export class AgentError extends Error {
|
|
1968
|
+
code:
|
|
1969
|
+
| "UNAVAILABLE"
|
|
1970
|
+
| "AGENT_NOT_FOUND"
|
|
1971
|
+
| "AGENT_DISABLED"
|
|
1972
|
+
| "CONVERSATION_NOT_FOUND"
|
|
1973
|
+
| "INVALID_MESSAGE"
|
|
1974
|
+
| "AI_QUOTA_EXCEEDED"
|
|
1975
|
+
| "RATE_LIMITED"
|
|
1976
|
+
| "AI_UNAVAILABLE"
|
|
1977
|
+
| "AI_GENERATION_FAILED"
|
|
1978
|
+
| "AUTH_REQUIRED"
|
|
1979
|
+
| "INTERNAL"
|
|
1980
|
+
| (string & {});
|
|
1981
|
+
constructor(code: string, message: string, opts?: { cause?: unknown });
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1933
1984
|
export class TranslateError extends Error {
|
|
1934
1985
|
code:
|
|
1935
1986
|
| "UNSUPPORTED"
|
package/dist/index.js
CHANGED
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
NotificationError,
|
|
29
29
|
DirectoryError,
|
|
30
30
|
PermissionError,
|
|
31
|
+
AgentError,
|
|
31
32
|
useDatastoreQuery,
|
|
32
33
|
useDatastoreRecord,
|
|
33
34
|
useDatastoreSchema,
|
|
@@ -35,6 +36,7 @@ export {
|
|
|
35
36
|
useStableQuery,
|
|
36
37
|
useCanWrite,
|
|
37
38
|
useInterpretDraft,
|
|
39
|
+
useAgent,
|
|
38
40
|
useAsset,
|
|
39
41
|
useAssetsByTag,
|
|
40
42
|
useFilestoreFiles,
|
package/dist/index.native.js
CHANGED
|
@@ -28,6 +28,7 @@ export {
|
|
|
28
28
|
NotificationError,
|
|
29
29
|
DirectoryError,
|
|
30
30
|
PermissionError,
|
|
31
|
+
AgentError,
|
|
31
32
|
useDatastoreQuery,
|
|
32
33
|
useDatastoreRecord,
|
|
33
34
|
useDatastoreSchema,
|
|
@@ -35,6 +36,7 @@ export {
|
|
|
35
36
|
useStableQuery,
|
|
36
37
|
useCanWrite,
|
|
37
38
|
useInterpretDraft,
|
|
39
|
+
useAgent,
|
|
38
40
|
useAsset,
|
|
39
41
|
useAssetsByTag,
|
|
40
42
|
useFilestoreFiles,
|
package/dist/linter.cjs
CHANGED
|
@@ -565,6 +565,26 @@ function _scopeRules(source, manifest) {
|
|
|
565
565
|
});
|
|
566
566
|
}
|
|
567
567
|
}
|
|
568
|
+
|
|
569
|
+
// sc-7991 — every send spends the workspace's AI credits, so the manifest has
|
|
570
|
+
// to declare the capability. This gate is ADVISORY, not an authorization
|
|
571
|
+
// boundary: unlike users/groups (minted into X-Widget-Scopes and checked
|
|
572
|
+
// server-side), nothing re-checks ai.invoke at call time, so a widget that
|
|
573
|
+
// evades this regex still reaches the endpoint. What actually bounds abuse is
|
|
574
|
+
// the app-user JWT, the per-actor and per-tenant limiters and the AI quota
|
|
575
|
+
// gate. The scope's real job is DISCLOSURE — it is what the marketplace
|
|
576
|
+
// install consent screen shows.
|
|
577
|
+
if (/\buseAgent\s*\(/.test(source)) {
|
|
578
|
+
const invokes = declared.has("ai.invoke:*") || declared.has("ai.invoke");
|
|
579
|
+
if (!invokes) {
|
|
580
|
+
findings.push({
|
|
581
|
+
rule: "scope-required-for-useAgent",
|
|
582
|
+
label: "useAgent() requires `ai.invoke:*` in manifest.requestedScopes",
|
|
583
|
+
line: 0,
|
|
584
|
+
snippet: "",
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
568
588
|
const lines = source.split(/\r?\n/);
|
|
569
589
|
let firedUsersWrite = false;
|
|
570
590
|
let firedUsersDelete = false;
|
package/dist/linter.js
CHANGED
|
@@ -528,6 +528,26 @@ function _scopeRules(source, manifest) {
|
|
|
528
528
|
});
|
|
529
529
|
}
|
|
530
530
|
}
|
|
531
|
+
|
|
532
|
+
// sc-7991 — every send spends the workspace's AI credits, so the manifest has
|
|
533
|
+
// to declare the capability. This gate is ADVISORY, not an authorization
|
|
534
|
+
// boundary: unlike users/groups (minted into X-Widget-Scopes and checked
|
|
535
|
+
// server-side), nothing re-checks ai.invoke at call time, so a widget that
|
|
536
|
+
// evades this regex still reaches the endpoint. What actually bounds abuse is
|
|
537
|
+
// the app-user JWT, the per-actor and per-tenant limiters and the AI quota
|
|
538
|
+
// gate. The scope's real job is DISCLOSURE — it is what the marketplace
|
|
539
|
+
// install consent screen shows.
|
|
540
|
+
if (/\buseAgent\s*\(/.test(source)) {
|
|
541
|
+
const invokes = declared.has("ai.invoke:*") || declared.has("ai.invoke");
|
|
542
|
+
if (!invokes) {
|
|
543
|
+
findings.push({
|
|
544
|
+
rule: "scope-required-for-useAgent",
|
|
545
|
+
label: "useAgent() requires `ai.invoke:*` in manifest.requestedScopes",
|
|
546
|
+
line: 0,
|
|
547
|
+
snippet: "",
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}
|
|
531
551
|
const lines = source.split(/\r?\n/);
|
|
532
552
|
let firedUsersWrite = false;
|
|
533
553
|
let firedUsersDelete = false;
|
package/dist/property-schema.js
CHANGED
|
@@ -23,6 +23,11 @@ const VALID_TYPES = new Set([
|
|
|
23
23
|
// value is the raw uuid string so the tenant-copy walker can remap it
|
|
24
24
|
// through `_remapJson` without per-prop hooks.
|
|
25
25
|
"groupRef",
|
|
26
|
+
// sc-7991: `agentRef` is a picker for one of the workspace's AI agents,
|
|
27
|
+
// emitting a bare TenantAiAgent UUID into the page JSON. A widget reads it
|
|
28
|
+
// and calls `useAgent(agentRef)`. Bare uuid string like `groupRef`/`pageRef`,
|
|
29
|
+
// so tenant-copy remaps it through the layout walk's merged id map.
|
|
30
|
+
"agentRef",
|
|
26
31
|
// REQ-WDG-PAGEREF: `pageRef` is a page picker that emits a bare AppPage
|
|
27
32
|
// UUID into the page JSON. Renders a page `<select>` in the Studio
|
|
28
33
|
// Properties Panel; a widget reads it and navigates via
|
|
@@ -148,6 +153,7 @@ function coerceLeaf(def, value, path, errors) {
|
|
|
148
153
|
case "recordBinding":
|
|
149
154
|
case "groupRef":
|
|
150
155
|
case "pageRef":
|
|
156
|
+
case "agentRef":
|
|
151
157
|
case "richText":
|
|
152
158
|
case "asset":
|
|
153
159
|
case "folderRef":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.148.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|