@softr/sdk 0.0.83-alpha.0 → 0.0.84
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/dist/action.d.ts +1 -34
- package/dist/action.js +1 -12
- package/dist/index.d.ts +2 -3
- package/dist/index.js +2 -2
- package/dist/mcp.d.ts +2 -2
- package/dist/package.js +1 -1
- package/dist/route/page.d.ts +3 -1
- package/dist/route/page.js +1 -1
- package/dist/server.d.ts +1 -2
- package/dist/server.js +1 -2
- package/package.json +1 -1
- package/skills/softr-sdk/SKILL.md +4 -12
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
- package/skills/softr-sdk/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
- package/skills/softr-sdk/formatting/SKILL.md +1 -1
- package/skills/softr-sdk/integrations/SKILL.md +13 -9
- package/skills/softr-sdk/mcp/SKILL.md +1 -1
- package/skills/softr-sdk/pwa/SKILL.md +1 -1
- package/skills/softr-sdk/record-mutations/SKILL.md +1 -1
- package/skills/softr-sdk/record-queries/SKILL.md +1 -1
- package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/workflows/SKILL.md +1 -1
- package/dist/core/ai.d.ts +0 -76
- package/dist/core/ai.js +0 -64
- package/skills/softr-sdk/ai/SKILL.md +0 -168
package/dist/action.d.ts
CHANGED
|
@@ -234,38 +234,5 @@ type IntegrationDefinition<TId extends string> = {
|
|
|
234
234
|
* const res = await listEvents({ query: { timeMin: now, singleEvents: "true" } })
|
|
235
235
|
*/
|
|
236
236
|
declare function defineIntegration<const TId extends string>(definition: IntegrationDefinition<TId>): IntegrationAction<TId>;
|
|
237
|
-
type AiResource = {
|
|
238
|
-
type: "SOFTR_AI";
|
|
239
|
-
model: string;
|
|
240
|
-
};
|
|
241
|
-
type AiAction<TId extends string = string> = Action<AiResource> & {
|
|
242
|
-
id: TId;
|
|
243
|
-
};
|
|
244
|
-
type AiDefinition<TId extends string> = {
|
|
245
|
-
id: TId;
|
|
246
|
-
model: string;
|
|
247
|
-
permissions: ActionPermissions;
|
|
248
|
-
};
|
|
249
|
-
/**
|
|
250
|
-
* Declares an AI action: a model call run by Softr on its own provider keys and billed to the
|
|
251
|
-
* workspace's AI credits, so nothing has to be connected. `model` is the id of a model from
|
|
252
|
-
* Softr's AI catalog, chosen when the app is built: read the catalog with the `list_ai_models`
|
|
253
|
-
* tool and copy the id exactly. An id outside the catalog is refused when the action is saved.
|
|
254
|
-
*
|
|
255
|
-
* Every call spends the workspace's credits, so keep the audience to signed-in users unless a
|
|
256
|
-
* public page really needs the call.
|
|
257
|
-
*
|
|
258
|
-
* Pass the returned object to `ai.generate()` or `ai.stream()` at runtime.
|
|
259
|
-
*
|
|
260
|
-
* @example
|
|
261
|
-
* const summarizeDeal = defineAiAction({
|
|
262
|
-
* id: "summarizeDeal",
|
|
263
|
-
* model: "anthropic-claude-sonnet-4-6",
|
|
264
|
-
* permissions: { userGroupTypes: ["LOGGED_IN_USERS"] },
|
|
265
|
-
* })
|
|
266
|
-
*
|
|
267
|
-
* const answer = await ai.generate(summarizeDeal, { system: "...", prompt: facts })
|
|
268
|
-
*/
|
|
269
|
-
declare function defineAiAction<const TId extends string>(definition: AiDefinition<TId>): AiAction<TId>;
|
|
270
237
|
//#endregion
|
|
271
|
-
export { ActionData, ActionPermissions, ActionRecord, ActionShape,
|
|
238
|
+
export { ActionData, ActionPermissions, ActionRecord, ActionShape, DatasourceAction, FieldTypeOf, IntegrationAction, ReadOf, ReadShape, SoftrDatasources, WorkflowAction, defineIntegration, defineMutation, defineQuery, defineWorkflow };
|
package/dist/action.js
CHANGED
|
@@ -56,17 +56,6 @@ function isDatasourceAction(value) {
|
|
|
56
56
|
const resource = candidate.resource;
|
|
57
57
|
return typeof candidate.id === "string" && typeof resource === "object" && resource !== null && resource.type === "DATASOURCE";
|
|
58
58
|
}
|
|
59
|
-
function defineAiAction(definition) {
|
|
60
|
-
const { id, model, permissions } = definition;
|
|
61
|
-
return {
|
|
62
|
-
id,
|
|
63
|
-
resource: {
|
|
64
|
-
type: "SOFTR_AI",
|
|
65
|
-
model
|
|
66
|
-
},
|
|
67
|
-
permissions
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
59
|
|
|
71
60
|
//#endregion
|
|
72
|
-
export {
|
|
61
|
+
export { defineIntegration, defineMutation, defineQuery, defineWorkflow, isDatasourceAction };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { SoftrUserGroupId, SoftrUserGroups, SoftrUserProperties, User } from "./auth/user.js";
|
|
2
|
-
import { ActionPermissions, SoftrDatasources,
|
|
2
|
+
import { ActionPermissions, SoftrDatasources, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
|
|
3
3
|
import { FileAttachment } from "./core/file-upload.js";
|
|
4
4
|
import { FileItem, useFileUpload } from "./core/use-file-upload.js";
|
|
5
|
-
import { AiAnswer, AiFile, AiGenerateInit, AiMessage, AiUsage } from "./core/ai.js";
|
|
6
5
|
import { DateOptions, DateParts, DateSeparator, DateValue, FixedDateFormat, FormattedDate, Formatting, FormattingScope, WeekDay, formatDate, formatDateTime, formatNumber, useFormatting } from "./formatting.js";
|
|
7
6
|
import { SoftrContext } from "./auth/context.js";
|
|
8
7
|
import { AuthSettings } from "./auth/platform.js";
|
|
@@ -18,4 +17,4 @@ import { useUserTimezone } from "./timezone.js";
|
|
|
18
17
|
import { PwaInstall, PwaPlatform, SoftrPwaHead, usePwaInstall } from "./pwa.js";
|
|
19
18
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
20
19
|
import { Audience } from "./route/audience.js";
|
|
21
|
-
export { type ActionPermissions,
|
|
20
|
+
export { type ActionPermissions, Audience, type AuthOptions, type AuthSettings, AuthSettingsProvider, type DateOptions, type DateParts, type DateSeparator, type DateValue, DefaultErrorOverlay, type FieldShape, type FileAttachment, type FileItem, type FixedDateFormat, FormattedDate, type Formatting, FormattingScope, type Page, type PageSettings, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, type SoftrFieldRegistry, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type SoftrUserProperties, type User, type WeekDay, assertRouteAccess, checkAccess, defineIntegration, defineMutation, definePage, defineQuery, defineVisibility, defineWorkflow, fieldShape, formatDate, formatDateTime, formatDuration, formatField, formatNumber, formatValue, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, pageHead, registerSoftrFields, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, useOverrides, usePwaInstall, useUserTimezone };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useFileUpload } from "./core/use-file-upload.js";
|
|
2
|
-
import {
|
|
2
|
+
import { defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
|
|
3
3
|
import { checkAccess, defineVisibility, isAuthorized } from "./route/visibility.js";
|
|
4
4
|
import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./route/route.js";
|
|
5
5
|
import { definePage, pageHead } from "./route/page.js";
|
|
@@ -14,4 +14,4 @@ import { useUserTimezone } from "./timezone.js";
|
|
|
14
14
|
import { DefaultErrorOverlay } from "./default-error-overlay.js";
|
|
15
15
|
import { Audience } from "./route/audience.js";
|
|
16
16
|
|
|
17
|
-
export { Audience, AuthSettingsProvider, DefaultErrorOverlay, FormattedDate, FormattingScope, SoftrPwaHead, assertRouteAccess, checkAccess,
|
|
17
|
+
export { Audience, AuthSettingsProvider, DefaultErrorOverlay, FormattedDate, FormattingScope, SoftrPwaHead, assertRouteAccess, checkAccess, defineIntegration, defineMutation, definePage, defineQuery, defineVisibility, defineWorkflow, fieldShape, formatDate, formatDateTime, formatDuration, formatField, formatNumber, formatValue, getAuthSettings, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, pageHead, registerSoftrFields, softrAuthMiddleware, softrMcpMiddleware, toReturnPath, useAuthSettings, useCurrentUser, useFileUpload, useFormatting, useOverrides, usePwaInstall, useUserTimezone };
|
package/dist/mcp.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DatasourceAction } from "./action.js";
|
|
2
|
-
import * as
|
|
2
|
+
import * as _tanstack_react_start5 from "@tanstack/react-start";
|
|
3
3
|
|
|
4
4
|
//#region src/mcp.d.ts
|
|
5
5
|
|
|
@@ -31,6 +31,6 @@ type SoftrMcpOptions = {
|
|
|
31
31
|
* ],
|
|
32
32
|
* }))
|
|
33
33
|
*/
|
|
34
|
-
declare function softrMcpMiddleware(options?: SoftrMcpOptions):
|
|
34
|
+
declare function softrMcpMiddleware(options?: SoftrMcpOptions): _tanstack_react_start5.RequestMiddlewareAfterServer<{}, undefined, undefined>;
|
|
35
35
|
//#endregion
|
|
36
36
|
export { softrMcpMiddleware };
|
package/dist/package.js
CHANGED
package/dist/route/page.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ type HeadContext = {
|
|
|
14
14
|
};
|
|
15
15
|
type HeadSetting<T> = T | ((ctx: HeadContext) => T | undefined);
|
|
16
16
|
type PageSettings = {
|
|
17
|
+
/** Studio's label for the page, separate from its title. */
|
|
18
|
+
name?: string;
|
|
17
19
|
visibility?: VisibilityRule;
|
|
18
20
|
title?: HeadSetting<string>;
|
|
19
21
|
description?: HeadSetting<string>;
|
|
@@ -29,7 +31,7 @@ type Page<S extends PageSettings = PageSettings> = S & {
|
|
|
29
31
|
readonly [pageBrand]: "page";
|
|
30
32
|
};
|
|
31
33
|
/**
|
|
32
|
-
* Declares a route's settings: who may open it, its title and its SEO tags.
|
|
34
|
+
* Declares a route's settings: its name in Studio, who may open it, its title and its SEO tags.
|
|
33
35
|
*
|
|
34
36
|
* One call per route file, at module scope, with an object literal. Studio
|
|
35
37
|
* reads and rewrites each setting written out as a literal; a computed one is
|
package/dist/route/page.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
//#region src/route/page.ts
|
|
2
2
|
/**
|
|
3
|
-
* Declares a route's settings: who may open it, its title and its SEO tags.
|
|
3
|
+
* Declares a route's settings: its name in Studio, who may open it, its title and its SEO tags.
|
|
4
4
|
*
|
|
5
5
|
* One call per route file, at module scope, with an object literal. Studio
|
|
6
6
|
* reads and rewrites each setting written out as a literal; a computed one is
|
package/dist/server.d.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { upload } from "./core/file-upload.js";
|
|
2
|
-
import { AiError, ai } from "./core/ai.js";
|
|
3
2
|
import { auth } from "./auth/server.js";
|
|
4
3
|
import { FieldChoice, RecordOption, RecordOptions } from "./core/options.js";
|
|
5
4
|
import { ds } from "./datasource/datasource.js";
|
|
6
5
|
import { workflow } from "./core/workflow.js";
|
|
7
6
|
import { integration } from "./core/integration.js";
|
|
8
7
|
import "@tanstack/react-start/server-only";
|
|
9
|
-
export {
|
|
8
|
+
export { type FieldChoice, type RecordOption, type RecordOptions, auth, ds, integration, upload, workflow };
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,6 @@ import { ds } from "./datasource/datasource.js";
|
|
|
3
3
|
import { auth } from "./auth/server.js";
|
|
4
4
|
import { workflow } from "./core/workflow.js";
|
|
5
5
|
import { integration } from "./core/integration.js";
|
|
6
|
-
import { AiError, ai } from "./core/ai.js";
|
|
7
6
|
import "@tanstack/react-start/server-only";
|
|
8
7
|
|
|
9
|
-
export {
|
|
8
|
+
export { auth, ds, integration, upload, workflow };
|
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ description: >-
|
|
|
8
8
|
metadata:
|
|
9
9
|
type: core
|
|
10
10
|
library: softr-sdk
|
|
11
|
-
library_version: 0.0.
|
|
11
|
+
library_version: 0.0.84
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
# Softr SDK core
|
|
@@ -18,7 +18,6 @@ metadata:
|
|
|
18
18
|
> **CRITICAL**: The SDK is split across four entrypoints and the split is enforced. `ds` and `auth` exist only on `@softr/sdk/server` and only run inside a `createServerFn` handler. Importing them anywhere else fails the build.
|
|
19
19
|
> **CRITICAL**: Every route file must declare its audience with `defineVisibility` and guard itself with that rule in `beforeLoad`. There is no default and nothing is inherited from a sibling.
|
|
20
20
|
> **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register `softrAuthMiddleware` and resolve the session. Do not add a second registration of it. `softrMcpMiddleware` is the one thing an app adds to `src/start.ts` itself — load the `softr-sdk/mcp` sub-skill before doing so.
|
|
21
|
-
> **CRITICAL**: `ai.generate` and `ai.stream` live on `@softr/sdk/server` and only run inside a `createServerFn` handler. Use `defineAiAction` from `@softr/sdk` to declare the action with the model it runs on, an id copied from the `ai_list_models` tool. Softr runs the model on its own keys; never ask the user for an API key for the app's own AI feature. Load the `softr-sdk/ai` sub-skill for the full pattern.
|
|
22
21
|
> **CRITICAL**: `workflow.trigger` lives on `@softr/sdk/server` and only runs inside a `createServerFn` handler. Use `defineWorkflow` from `@softr/sdk` to declare the action, then pass it to `workflow.trigger`. Load the `softr-sdk/workflows` sub-skill for the full pattern.
|
|
23
22
|
|
|
24
23
|
Each sub-skill stands alone for its task and repeats the boundary above, so most work needs **one** sub-skill and not this file. Load this one when you are unsure which sub-skill applies, or when the wiring below is what you are touching. Add a second sub-skill only when the work genuinely crosses that boundary: a page that lists records behind a group restriction needs `record-queries` plus `routes-and-visibility`.
|
|
@@ -27,8 +26,8 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
27
26
|
|
|
28
27
|
| Import | Runs | Holds |
|
|
29
28
|
| --- | --- | --- |
|
|
30
|
-
| `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `
|
|
31
|
-
| `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration
|
|
29
|
+
| `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `FormattedDate`, `FormattingScope`, `useFormatting`, `formatDate`, `formatDateTime`, `formatNumber`, `formatValue`, `formatField`, `fieldShape`, `registerSoftrFields`, `useOverrides`, `SoftrPwaHead`, `usePwaInstall`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
|
|
30
|
+
| `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration` |
|
|
32
31
|
| `@softr/sdk/client` | Browser only | `useFileUpload` |
|
|
33
32
|
| `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
|
|
34
33
|
|
|
@@ -44,8 +43,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
|
|
|
44
43
|
| Create, update or delete a record | [record-mutations/SKILL.md](./record-mutations/SKILL.md) |
|
|
45
44
|
| Trigger a Softr workflow | [workflows/SKILL.md](./workflows/SKILL.md) |
|
|
46
45
|
| Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
|
|
47
|
-
|
|
|
48
|
-
| Call a connected third-party API (Slack, Stripe, Gmail, …), or an AI model on the user's own provider account | [integrations/SKILL.md](./integrations/SKILL.md) |
|
|
46
|
+
| Call a connected third-party API (Slack, Stripe, Gmail, …) | [integrations/SKILL.md](./integrations/SKILL.md) |
|
|
49
47
|
| Open the app to AI assistants over MCP | [mcp/SKILL.md](./mcp/SKILL.md) |
|
|
50
48
|
| Let visitors install the app on their home screen | [pwa/SKILL.md](./pwa/SKILL.md) |
|
|
51
49
|
| Show a date, a time or a number | [formatting/SKILL.md](./formatting/SKILL.md) |
|
|
@@ -77,15 +75,9 @@ Triggering a Softr workflow from a button or form?
|
|
|
77
75
|
Wiring a login, signup, or sign-out?
|
|
78
76
|
load softr-sdk/auth
|
|
79
77
|
|
|
80
|
-
Asking an AI model for anything: a summary, fields out of a document, a classification, a draft, a chat?
|
|
81
|
-
load softr-sdk/ai — Softr runs the model on its own keys, nothing to connect; pick the model from ai_list_models
|
|
82
|
-
|
|
83
78
|
Calling a connected third-party API (Slack, Stripe, Gmail, Google Calendar, …)?
|
|
84
79
|
load softr-sdk/integrations
|
|
85
80
|
|
|
86
|
-
Running a model on the user's OWN provider account, because they chose that for the feature?
|
|
87
|
-
load softr-sdk/integrations: its "Calling an AI model" section
|
|
88
|
-
|
|
89
81
|
Opening the app to an AI assistant over MCP?
|
|
90
82
|
load softr-sdk/mcp
|
|
91
83
|
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.84
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.84
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -7,7 +7,7 @@ description: >-
|
|
|
7
7
|
metadata:
|
|
8
8
|
type: sub-skill
|
|
9
9
|
library: softr-sdk
|
|
10
|
-
library_version: 0.0.
|
|
10
|
+
library_version: 0.0.84
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
**Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
|
|
@@ -7,7 +7,7 @@ description: >-
|
|
|
7
7
|
metadata:
|
|
8
8
|
type: sub-skill
|
|
9
9
|
library: softr-sdk
|
|
10
|
-
library_version: 0.0.
|
|
10
|
+
library_version: 0.0.84
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
**Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -127,11 +127,9 @@ Do not add an `Authorization` key to `headers` — Softr injects the integration
|
|
|
127
127
|
|
|
128
128
|
## Calling an AI model
|
|
129
129
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
An AI integration is called like any other. For `ANTHROPIC_AI`, the action is `POST` on
|
|
130
|
+
A button on one record that hands its fields to the model and shows the answer is the `ai-action` pattern:
|
|
131
|
+
it carries this call and the model id, so write the call by hand only for another shape — a batch, a chat,
|
|
132
|
+
a field the model fills on create. An AI integration is called like any other. For `ANTHROPIC_AI`, the action is `POST` on
|
|
135
133
|
`https://api.anthropic.com/v1/messages`, the request carries the `anthropic-version` header and a body of
|
|
136
134
|
`model`, `max_tokens`, `system` and `messages`, and the answer's text is in `content[].text`:
|
|
137
135
|
|
|
@@ -148,7 +146,7 @@ const claude = integration.createFetcher(askClaude)
|
|
|
148
146
|
const res = await claude({
|
|
149
147
|
headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
|
|
150
148
|
body: JSON.stringify({
|
|
151
|
-
model: '
|
|
149
|
+
model: 'claude-opus-5-5',
|
|
152
150
|
max_tokens: 1024,
|
|
153
151
|
system: 'You write a short briefing for a sales team. Use only the facts given.',
|
|
154
152
|
messages: [{ role: 'user', content: facts }],
|
|
@@ -159,7 +157,10 @@ const body = (await res.json()) as { content: Array<{ type: string; text?: strin
|
|
|
159
157
|
const text = body.content.flatMap((block) => (block.type === 'text' && block.text ? [block.text] : [])).join('')
|
|
160
158
|
```
|
|
161
159
|
|
|
162
|
-
|
|
160
|
+
**Use the model id above as it is written here.** Model ids are not derivable: one spelled from memory
|
|
161
|
+
(`claude-3-5-…`, `claude-sonnet-4-…`) names a model that no longer answers, and the error only shows up when
|
|
162
|
+
the button is pressed. `claude-opus-5-5` is the current default; `claude-sonnet-5-5` is the cheaper one for a
|
|
163
|
+
short summary.
|
|
163
164
|
|
|
164
165
|
## Base URLs by integration type
|
|
165
166
|
|
|
@@ -215,13 +216,16 @@ Match the `integrationType` from the attached integrations section to find the b
|
|
|
215
216
|
| XERO | `https://api.xero.com` |
|
|
216
217
|
| ZOOM | `https://api.zoom.us` |
|
|
217
218
|
|
|
219
|
+
`REST_API` is a custom API, so it has no row here. Each connection has its own base URL, and
|
|
220
|
+
`connect-a-service` says where to read it.
|
|
221
|
+
|
|
218
222
|
## When the type is in the table but nothing is attached
|
|
219
223
|
|
|
220
224
|
The service is not out of reach, it is not connected yet. Load `connect-a-service` and ask the user for it: the answer carries the `integrationId` this skill needs.
|
|
221
225
|
|
|
222
226
|
## When the service is not a Softr integration
|
|
223
227
|
|
|
224
|
-
If the API the user needs is not in the table above, call it directly from a server function using the native `fetch`.
|
|
228
|
+
If the API the user needs is not in the table above and needs no key, call it directly from a server function using the native `fetch`. If it needs a key or OAuth, load `connect-a-service` to connect it, then call it like any integration above. A key written into the code is in every code version and in the chat.
|
|
225
229
|
|
|
226
230
|
## Failure modes
|
|
227
231
|
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.84
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `softrMcpMiddleware` lives on `@softr/sdk` and is registered once, in `src/start.ts`. The actions you hand it are the same `defineQuery` / `defineMutation` values the app's own pages use — declared wherever they already live, not duplicated for MCP.
|
|
@@ -8,7 +8,7 @@ description: >-
|
|
|
8
8
|
metadata:
|
|
9
9
|
type: sub-skill
|
|
10
10
|
library: softr-sdk
|
|
11
|
-
library_version: 0.0.
|
|
11
|
+
library_version: 0.0.84
|
|
12
12
|
---
|
|
13
13
|
|
|
14
14
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.84
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. The same holds for any helper that calls `ds`: declare it inside the handler. A function at module level that calls `ds` keeps the server-only import in the browser bundle, and every page stops loading until it moves. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.84
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
**Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere.
|
package/dist/core/ai.d.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { AiAction } from "../action.js";
|
|
2
|
-
|
|
3
|
-
//#region src/core/ai.d.ts
|
|
4
|
-
/** A document or image the model reads, by the URL the app holds it under: one of its own uploads, as a rule. */
|
|
5
|
-
interface AiFile {
|
|
6
|
-
/** An https URL Softr may fetch. */
|
|
7
|
-
url: string;
|
|
8
|
-
/** The file's type when the URL does not say, such as `application/pdf`. */
|
|
9
|
-
mimeType?: string;
|
|
10
|
-
}
|
|
11
|
-
/** One earlier turn of a conversation. */
|
|
12
|
-
interface AiMessage {
|
|
13
|
-
role: 'user' | 'assistant';
|
|
14
|
-
content: string;
|
|
15
|
-
}
|
|
16
|
-
interface AiGenerateInit {
|
|
17
|
-
/** What the model is and the rules it answers under. */
|
|
18
|
-
system?: string;
|
|
19
|
-
/** The earlier turns of a conversation, oldest first, before the prompt. Leave out for a one-shot call. */
|
|
20
|
-
messages?: ReadonlyArray<AiMessage>;
|
|
21
|
-
/** What it is asked, with the facts it is given. */
|
|
22
|
-
prompt: string;
|
|
23
|
-
/**
|
|
24
|
-
* A JSON schema the answer has to match. With it, the model is asked for JSON only and the
|
|
25
|
-
* answer comes back parsed in `json` as well as written in `text`.
|
|
26
|
-
*/
|
|
27
|
-
schema?: Record<string, unknown>;
|
|
28
|
-
/** Documents and images the model reads alongside the prompt. */
|
|
29
|
-
files?: ReadonlyArray<AiFile>;
|
|
30
|
-
/** A cap on the answer's length, in tokens; Softr caps it further. */
|
|
31
|
-
maxOutputTokens?: number;
|
|
32
|
-
/**
|
|
33
|
-
* How hard a reasoning model thinks before it answers. A model that does not reason ignores it;
|
|
34
|
-
* the catalog says which do. Left out, the provider's default applies.
|
|
35
|
-
*/
|
|
36
|
-
reasoning?: 'low' | 'medium' | 'high';
|
|
37
|
-
/** How varied the answer is, 0 to 1: 0 for an extraction, higher for a draft. A low default when left out. */
|
|
38
|
-
temperature?: number;
|
|
39
|
-
signal?: AbortSignal;
|
|
40
|
-
}
|
|
41
|
-
interface AiUsage {
|
|
42
|
-
inputTokens: number;
|
|
43
|
-
outputTokens: number;
|
|
44
|
-
}
|
|
45
|
-
interface AiAnswer {
|
|
46
|
-
/** The answer as the model wrote it. */
|
|
47
|
-
text: string;
|
|
48
|
-
/** The answer parsed as JSON when `schema` was given and the model kept to it; `null` otherwise. */
|
|
49
|
-
json: unknown;
|
|
50
|
-
usage: AiUsage;
|
|
51
|
-
/** The model that answered. */
|
|
52
|
-
model: string;
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Thrown when the model call was refused or failed. `status` is the HTTP status Softr answered
|
|
56
|
-
* with; 402 means the workspace has no AI credits left.
|
|
57
|
-
*/
|
|
58
|
-
declare class AiError extends Error {
|
|
59
|
-
readonly status: number;
|
|
60
|
-
constructor(status: number, message: string);
|
|
61
|
-
}
|
|
62
|
-
/** Runs the action once and answers when the model is done. */
|
|
63
|
-
declare function aiGenerate(action: AiAction, init: AiGenerateInit): Promise<AiAnswer>;
|
|
64
|
-
/**
|
|
65
|
-
* Runs the action and answers while the model writes. The returned `Response` body is a stream of
|
|
66
|
-
* Server-Sent Events, one JSON object per `data:` line: `{ "text": "..." }` for each piece of the
|
|
67
|
-
* answer, then `{ "done": true, "usage": {...}, "model": "..." }`, or `{ "error": "..." }` in its
|
|
68
|
-
* place. Hand it to the browser as a `Response` with `Content-Type: text/event-stream`.
|
|
69
|
-
*/
|
|
70
|
-
declare function aiStream(action: AiAction, init: AiGenerateInit): Promise<Response>;
|
|
71
|
-
declare const ai: {
|
|
72
|
-
generate: typeof aiGenerate;
|
|
73
|
-
stream: typeof aiStream;
|
|
74
|
-
};
|
|
75
|
-
//#endregion
|
|
76
|
-
export { AiAnswer, AiError, AiFile, AiGenerateInit, AiMessage, AiUsage, ai };
|
package/dist/core/ai.js
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { fetch } from "../fetch.js";
|
|
2
|
-
import { readSessionToken } from "../auth/session.js";
|
|
3
|
-
import { describeFailure, dsUrl, refererHeader } from "../datasource/record.js";
|
|
4
|
-
|
|
5
|
-
//#region src/core/ai.ts
|
|
6
|
-
/**
|
|
7
|
-
* Thrown when the model call was refused or failed. `status` is the HTTP status Softr answered
|
|
8
|
-
* with; 402 means the workspace has no AI credits left.
|
|
9
|
-
*/
|
|
10
|
-
var AiError = class extends Error {
|
|
11
|
-
status;
|
|
12
|
-
constructor(status, message) {
|
|
13
|
-
super(message);
|
|
14
|
-
this.name = "AiError";
|
|
15
|
-
this.status = status;
|
|
16
|
-
}
|
|
17
|
-
};
|
|
18
|
-
function body(init) {
|
|
19
|
-
return JSON.stringify({
|
|
20
|
-
system: init.system,
|
|
21
|
-
messages: init.messages,
|
|
22
|
-
prompt: init.prompt,
|
|
23
|
-
schema: init.schema,
|
|
24
|
-
files: init.files,
|
|
25
|
-
maxOutputTokens: init.maxOutputTokens,
|
|
26
|
-
reasoning: init.reasoning,
|
|
27
|
-
temperature: init.temperature
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
async function post(action, path, init) {
|
|
31
|
-
if (init.signal?.aborted) throw init.signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
|
|
32
|
-
const res = await fetch(`${dsUrl(action.id)}/${path}`, {
|
|
33
|
-
method: "POST",
|
|
34
|
-
headers: {
|
|
35
|
-
"Content-Type": "application/json",
|
|
36
|
-
"Authorization": `Bearer ${readSessionToken()}`,
|
|
37
|
-
...refererHeader()
|
|
38
|
-
},
|
|
39
|
-
body: body(init),
|
|
40
|
-
signal: init.signal
|
|
41
|
-
});
|
|
42
|
-
if (!res.ok) throw new AiError(res.status, `AI call "${action.id}" failed: ${await describeFailure(res)}`);
|
|
43
|
-
return res;
|
|
44
|
-
}
|
|
45
|
-
/** Runs the action once and answers when the model is done. */
|
|
46
|
-
async function aiGenerate(action, init) {
|
|
47
|
-
return await (await post(action, "softr-ai/generate", init)).json();
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Runs the action and answers while the model writes. The returned `Response` body is a stream of
|
|
51
|
-
* Server-Sent Events, one JSON object per `data:` line: `{ "text": "..." }` for each piece of the
|
|
52
|
-
* answer, then `{ "done": true, "usage": {...}, "model": "..." }`, or `{ "error": "..." }` in its
|
|
53
|
-
* place. Hand it to the browser as a `Response` with `Content-Type: text/event-stream`.
|
|
54
|
-
*/
|
|
55
|
-
async function aiStream(action, init) {
|
|
56
|
-
return post(action, "softr-ai/generate/stream", init);
|
|
57
|
-
}
|
|
58
|
-
const ai = {
|
|
59
|
-
generate: aiGenerate,
|
|
60
|
-
stream: aiStream
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
//#endregion
|
|
64
|
-
export { AiError, ai };
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: ai
|
|
3
|
-
description: >-
|
|
4
|
-
Run an AI model from the app on Softr's own keys, billed to the workspace's
|
|
5
|
-
AI credits, with nothing to connect: defineAiAction from @softr/sdk declares
|
|
6
|
-
the action with the catalog model it runs on and its permissions;
|
|
7
|
-
ai.generate from @softr/sdk/server runs it with a system text, a prompt, an
|
|
8
|
-
optional JSON schema for an answer that is data, and optional files;
|
|
9
|
-
ai.stream runs it for an answer the user watches being written. Load this
|
|
10
|
-
for a summary, an extraction, a classification, a draft, or a chat the app
|
|
11
|
-
asks a model for, before you decide whether a feature runs on Softr AI
|
|
12
|
-
or on the user's own account, and before you change the model it runs on.
|
|
13
|
-
metadata:
|
|
14
|
-
type: sub-skill
|
|
15
|
-
library: softr-sdk
|
|
16
|
-
library_version: 0.0.79-alpha.0
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
**Boundary.** `ai` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineAiAction` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
20
|
-
|
|
21
|
-
# AI
|
|
22
|
-
|
|
23
|
-
An app asks a model through an AI action. Softr runs the call on its own provider keys and bills the workspace's AI credits, so there is no key to ask the user for and nothing to connect. This is the default for every AI feature of the app's own — a summary of a record, fields extracted from a document, a classification, a draft, a chat. The other path is the user's own provider account, through a connected integration: the `integrations` sub-skill. An app can hold both: each feature carries its own choice in its code.
|
|
24
|
-
|
|
25
|
-
## Decide who runs it first
|
|
26
|
-
|
|
27
|
-
Never choose silently between Softr AI and the user's own account. Decide for each AI feature, before you write its code, in this order:
|
|
28
|
-
|
|
29
|
-
1. The user said who runs it: follow it. "My OpenAI key" or "our Anthropic account", with exactly one integration of that type in `integration_list`: use that one, without asking. A new key of a provider they named — they asked for a new one, or the workspace holds none of that type — is one `requestinput` of type `integration` for that type: its card connects one. A connected key runs the feature; a skipped request means Softr AI, and you say so. Several keys of that type, or "my own key" with no provider: ask (step 4). A provider or a model named without "my" or "our" ("use Claude", "summarise with GPT") is a model preference on Softr AI, not a reason to ask.
|
|
30
|
-
2. The app already has AI features: follow the choice they made, and say so in one line.
|
|
31
|
-
3. `integration_list` shows no `OPEN_AI`, `ANTHROPIC_AI` or `GEMINI_AI` integration: use Softr AI, and say so in one line. Call the tool; do not assume the answer.
|
|
32
|
-
4. Otherwise ask with one `requestinput` of type `aiProvider`. Its card lists every key the workspace holds and every provider to connect, unfiltered. The answer is Softr AI, or the integration with its id and type: follow it, even when it is another provider than the one the user named. A declined request means Softr AI: use it, and say so in one line.
|
|
33
|
-
|
|
34
|
-
Never ask who runs a feature, or which key, with the `question` tool: its free-text answer invites the user to type a key into the chat.
|
|
35
|
-
|
|
36
|
-
Ask at most once per turn. A turn that builds several AI features sends one request, and its `feature` names every one of them in the user's words: "Deal summary and lead tagging". It holds the names only, never the provider, the key or how the feature runs, because the card asks exactly that: "Product chat", not "Product chat, using an OpenAI key".
|
|
37
|
-
|
|
38
|
-
An own-account answer leaves this skill: the call goes through `defineIntegration`, as the `integrations` sub-skill describes.
|
|
39
|
-
|
|
40
|
-
## Choose the model
|
|
41
|
-
|
|
42
|
-
The model is chosen when the app is built and frozen in its code. Before you declare an action, read Softr's catalog with the `ai_list_models` tool: the most recent models of each provider. Each entry carries the `id` the action names, the `provider`, `bestFor`, what to pick it for, `reads`, what it reads beside text, and `reasoning`, whether it thinks before it answers. (`providerModelId` is for the own-account path of the `integrations` sub-skill, never for `defineAiAction`.)
|
|
43
|
-
|
|
44
|
-
- by default — a chat, an assistant, a summary, a briefing, a draft: a mid-tier model, `anthropic`'s newest one between its cheapest and strongest. When the user named another provider, that provider's mid-tier model;
|
|
45
|
-
- an extraction or a classification: the cheapest model whose `reads` covers what it is given — an image or a PDF needs `IMAGE`;
|
|
46
|
-
- the strongest model listed only when the user asks for more quality.
|
|
47
|
-
|
|
48
|
-
Copy the `id` exactly from the tool's answer. An id outside the catalog is refused when the action is saved, and a model spelled from memory, or taken from an example, never is in it.
|
|
49
|
-
|
|
50
|
-
## Changing it later
|
|
51
|
-
|
|
52
|
-
A model change asked in chat ("use a cheaper model", "try Gemini") edits the `model` of the feature's action, or, on the user's own account, the `providerModelId` its call sends. Read `ai_list_models` again and copy the new id. It never sends an `aiProvider` request. On the user's own account the new model is one of that account's provider: a model of another provider, or a switch to the user's own key, changes who runs it, so follow step 1 of the order above.
|
|
53
|
-
|
|
54
|
-
## Say it
|
|
55
|
-
|
|
56
|
-
The plan and the closing message give one line per AI feature built or changed: who runs it, its model, what it bills — Softr AI uses the workspace's AI credits, the user's own key is billed by its provider — and that the user can ask to switch the model or to use their own OpenAI, Anthropic or Gemini key. For example: "Deal summary runs on Softr AI with <the model> and uses your workspace's AI credits. Ask me to switch the model or to use your own OpenAI, Anthropic or Gemini key."
|
|
57
|
-
|
|
58
|
-
## Declare the action
|
|
59
|
-
|
|
60
|
-
```ts
|
|
61
|
-
const summarizeDeal = defineAiAction({
|
|
62
|
-
id: 'summarizeDeal',
|
|
63
|
-
model: '<an id from ai_list_models>',
|
|
64
|
-
permissions: { userGroupTypes: ['LOGGED_IN_USERS'] },
|
|
65
|
-
})
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
Write the object inline in `defineAiAction`. Passing it through a variable widens the types.
|
|
69
|
-
|
|
70
|
-
**Every call spends the workspace's credits.** Keep the audience to `['LOGGED_IN_USERS']`, narrowed with `customGroups` where the feature is for some users only. Open an AI action to visitors only when a public page really needs it, and say so to the user: anyone on the internet can then bill the workspace.
|
|
71
|
-
|
|
72
|
-
Declare one action per feature — the summary and the extraction are two actions, each with its own `id`, its own model and its own audience.
|
|
73
|
-
|
|
74
|
-
## Core pattern: an answer the server reads
|
|
75
|
-
|
|
76
|
-
`ai.generate(action, init)` runs the action and answers when the model is done. `init` takes:
|
|
77
|
-
|
|
78
|
-
- `prompt` — what the model is asked, with the facts it is given. Required.
|
|
79
|
-
- `system` — what the model is and the rules it answers under.
|
|
80
|
-
- `schema` — a JSON schema the answer has to match. With it the model is asked for JSON only, and the answer comes back parsed in `json` as well as written in `text`.
|
|
81
|
-
- `files` — documents and images the model reads, each as `{ url, mimeType? }`: an `https` URL Softr may fetch, one of the app's own uploads as a rule.
|
|
82
|
-
- `maxOutputTokens` — a cap on the answer's length. Softr caps it further.
|
|
83
|
-
- `messages` — the earlier turns of a conversation, oldest first, each `{ role: 'user' | 'assistant', content }`. The prompt is the newest turn. Leave out for a one-shot call.
|
|
84
|
-
- `reasoning` — `'low'`, `'medium'` or `'high'`: how hard a reasoning model thinks before it answers. The catalog says which models reason; the others ignore it. Leave out for the provider's default.
|
|
85
|
-
- `temperature` — 0 to 1: how varied the answer is. `0` for an extraction or a classification, around `0.7` for a draft. A low default when left out.
|
|
86
|
-
|
|
87
|
-
The answer is `{ text, json, usage, model }`: `text` as the model wrote it, `json` parsed when a schema was given and the model kept to it, `null` otherwise.
|
|
88
|
-
|
|
89
|
-
```ts
|
|
90
|
-
const summarize = createServerFn({ method: 'POST' })
|
|
91
|
-
.validator(z.object({ recordId: z.string() }))
|
|
92
|
-
.handler(async ({ data }) => {
|
|
93
|
-
const deal = await ds.get(dealQuery, { id: data.recordId })
|
|
94
|
-
if (deal === null) throw new Error('This deal no longer exists')
|
|
95
|
-
const facts = [`Name: ${fieldText(deal.fields.name)}`, `Stage: ${fieldText(deal.fields.stage)}`].join('\n')
|
|
96
|
-
const answer = await ai.generate(summarizeDeal, {
|
|
97
|
-
system: 'You brief a sales team. Use only the facts given; say what is missing rather than invent it.',
|
|
98
|
-
prompt: facts,
|
|
99
|
-
maxOutputTokens: 800,
|
|
100
|
-
})
|
|
101
|
-
return answer.text
|
|
102
|
-
})
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
## Extraction: an answer that is data
|
|
106
|
-
|
|
107
|
-
A document dropped on the app, fields pulled out of it, a record created — the model reads the file and answers in the shape the record needs. Give it the schema and the file; validate the parsed answer before writing it, since a model may stray from the shape.
|
|
108
|
-
|
|
109
|
-
```ts
|
|
110
|
-
const InvoiceFields = z.object({ vendor: z.string(), total: z.number(), issuedOn: z.string() })
|
|
111
|
-
|
|
112
|
-
const extractInvoice = createServerFn({ method: 'POST' })
|
|
113
|
-
.validator(z.object({ fileUrl: z.string().url() }))
|
|
114
|
-
.handler(async ({ data }) => {
|
|
115
|
-
const answer = await ai.generate(readInvoice, {
|
|
116
|
-
system: 'Read the invoice and answer with its fields. Dates as YYYY-MM-DD, amounts as numbers.',
|
|
117
|
-
prompt: 'Extract the vendor, the total and the issue date.',
|
|
118
|
-
schema: {
|
|
119
|
-
type: 'object',
|
|
120
|
-
properties: { vendor: { type: 'string' }, total: { type: 'number' }, issuedOn: { type: 'string' } },
|
|
121
|
-
required: ['vendor', 'total', 'issuedOn'],
|
|
122
|
-
},
|
|
123
|
-
files: [{ url: data.fileUrl, mimeType: 'application/pdf' }],
|
|
124
|
-
})
|
|
125
|
-
const parsed = InvoiceFields.safeParse(answer.json)
|
|
126
|
-
if (!parsed.success) throw new Error('The invoice could not be read. Try a clearer scan.')
|
|
127
|
-
return ds.create(createInvoice, { data: parsed.data })
|
|
128
|
-
})
|
|
129
|
-
```
|
|
130
|
-
|
|
131
|
-
The file's URL comes from the app's own upload: load the Softr SDK `file-uploads` guidance for that half. `readInvoice` names a model whose `bestFor` reads PDF documents.
|
|
132
|
-
|
|
133
|
-
## Streaming: an answer the user watches being written
|
|
134
|
-
|
|
135
|
-
A chat, an assistant, a long draft shown as it is typed — never buffer these: the user would watch a blank panel until the last token. A chat box the user types into is always this case, never `ai.generate`. `ai.stream(action, init)` takes the same `init` and answers with a `Response` whose body is a stream of Server-Sent Events. Hand it to the browser from the server function, unchanged:
|
|
136
|
-
|
|
137
|
-
```ts
|
|
138
|
-
const draftReply = createServerFn({ method: 'POST' })
|
|
139
|
-
.validator(z.object({ prompt: z.string() }))
|
|
140
|
-
.handler(async ({ data }) => {
|
|
141
|
-
const res = await ai.stream(draftAction, { system: 'You write replies for a support team.', prompt: data.prompt })
|
|
142
|
-
return new Response(res.body, { headers: { 'Content-Type': 'text/event-stream' } })
|
|
143
|
-
})
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
Each event is one JSON object on a `data:` line:
|
|
147
|
-
|
|
148
|
-
- `{ "text": "..." }` — the next piece of the answer; append it.
|
|
149
|
-
- `{ "done": true, "usage": { "inputTokens", "outputTokens" }, "model": "..." }` — the answer is complete.
|
|
150
|
-
- `{ "error": "..." }` — in place of `done`, when the call ended before its answer: show the message.
|
|
151
|
-
|
|
152
|
-
On the client, call the server function's URL with `fetch`, read `response.body` with a stream reader, split on blank lines, and parse the JSON after `data:`. A conversation sends its earlier turns in `messages`; the app keeps them, the model holds nothing between calls.
|
|
153
|
-
|
|
154
|
-
## Failure modes
|
|
155
|
-
|
|
156
|
-
**Calling from client code.** `ai` is server-only. Importing it into a component or calling it outside a `createServerFn` handler fails the build or has no request context to act on.
|
|
157
|
-
|
|
158
|
-
**Spelling a model id.** `model` is copied from `ai_list_models`, never written from memory. An id outside the catalog fails the save of the action, so the app cannot be published with it.
|
|
159
|
-
|
|
160
|
-
**No credits left.** `ai.generate` and `ai.stream` throw an `AiError` whose `status` is `402` when the workspace has no AI credits. Show its message to the user; the workspace owner tops the credits up in Softr. Any other refusal carries its status and reason the same way.
|
|
161
|
-
|
|
162
|
-
**Trusting `json` without checking it.** A model may stray from the schema; `json` is then `null`, or a shape close to the one asked. Validate it with `zod` before writing it to a record, and keep `text` as the fallback the user can read.
|
|
163
|
-
|
|
164
|
-
**Buffering a chat.** A chat built on `ai.generate` shows nothing until the whole reply is done, and the user reads "replies appear all at once" in your closing message. A chat streams.
|
|
165
|
-
|
|
166
|
-
**Reading a streamed body with `res.json()`.** The body of `ai.stream` is an event stream, not one JSON document. Return `res.body` in a `Response` or read it with a stream reader.
|
|
167
|
-
|
|
168
|
-
**Asking for the user's API key.** An AI feature of the app's own needs none. Only when the user chose their own provider account for the feature does the call go through a connected integration — load the `integrations` sub-skill then.
|