@assembly-js/app-foundation 0.6.0 → 0.6.2
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 +6 -0
- package/CLAUDE.md +1 -0
- package/bin/client-safe-sdk-methods.mjs +9 -3
- package/package.json +1 -1
- package/skills/assembly-ai/SKILL.md +70 -11
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,12 @@ A patch release never changes what an app's build requires; a minor release may
|
|
|
19
19
|
not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
|
|
20
20
|
`bump: minor` change file.
|
|
21
21
|
|
|
22
|
+
## 0.6.2
|
|
23
|
+
- [guidance] The build agent now returns a failed AI call as `{ ok: false, message }` with the proxy's error instead of throwing, and saves a chat's question and answer together only once the answer has arrived.
|
|
24
|
+
|
|
25
|
+
## 0.6.1
|
|
26
|
+
- [fix] The client-call scan no longer reports a `fetch` to the AI proxy (`POST /v1/ai/openai/...`) as breaking: the platform now admits a client bearer on that route. Nothing in the app changes.
|
|
27
|
+
|
|
22
28
|
## 0.6.0
|
|
23
29
|
- [new] `AuthUnavailableError` carries a fixed digest (`AUTH_UNAVAILABLE_DIGEST`), so an app's error boundary can recognise a platform outage after production has redacted the error.
|
|
24
30
|
- [action] A platform outage reaches the retry screen from every page, not only the root layout: `app/error.tsx` renders it on that digest and no helper or route may swallow the error; the renewal limiter earlier updates copied into the app is removed now that the package bounds renewal itself.
|
package/CLAUDE.md
CHANGED
|
@@ -1490,6 +1490,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
|
|
|
1490
1490
|
- ❌ Don't include `companyId` (or any other unchanged field) in `updateClient.requestBody` "for completeness". Each field in `requestBody` is treated as a write — passing `companyId` triggers a company-reassignment path that clients can't perform, and the whole update fails. Pass ONLY the fields the user is actually changing.
|
|
1491
1491
|
- ❌ Don't hardcode mutation error messages like "Failed to save preference". Read `error` from the response body and surface it to the user.
|
|
1492
1492
|
- ❌ Don't `throw new Error` with a sentence the user should read from a server action. Production redacts that throw, so the toast shows a generic React error and the response is HTTP 500. Return `{ ok: false, message }` and toast `message`. Let `TokenInvalidError` and unexpected failures throw. See `assembly-action-results`.
|
|
1493
|
+
- ❌ Don't let a failed AI call throw out of a server action, and don't create a conversation or message row before the answer exists. Check `response.ok`, return `{ ok: false, message }` with the proxy's error so the UI can show it with a retry, and write the question and the answer together in one `db.batch` once the answer has arrived. See "Conversations" in `assembly-ai`.
|
|
1493
1494
|
- ❌ Don't `Promise.all` a list-sized write fan-out (one `createNotification` / `markNotificationRead` / update per recipient or record). Writes cap at 5 concurrent per token; firing a whole list 429s and silently drops the overflow. Use `sendThrottled` from `@assembly-js/app-foundation/server`. A fixed handful of calls is the only exception. See "Write rate limit" above.
|
|
1494
1495
|
- ❌ Don't re-notify a still-true condition from a cron. Mark a recipient sent only after `createNotification` succeeds, and clear that record when the condition ends unless the alert is once per billing period; keep those successful outcomes until the period resets, even if usage dips below the threshold. See "Recurring conditions" in `assembly-notifications`.
|
|
1495
1496
|
- ❌ Don't use title case — see "Labels, copy, and dropdowns" above.
|
|
@@ -14,6 +14,7 @@ export const CLIENT_SAFE_ROUTES = Object.freeze([
|
|
|
14
14
|
{ method: 'POST', path: '/v1/notifications/{id}/read', sdkMethod: 'markNotificationRead' },
|
|
15
15
|
{ method: 'POST', path: '/v1/notifications/{id}/unread', sdkMethod: 'markNotificationUnread' },
|
|
16
16
|
{ method: 'GET', path: '/v1/session/flags', sdkMethod: null },
|
|
17
|
+
{ method: 'POST', path: '/v1/ai/openai/*proxyPath', sdkMethod: null }, // the AI proxy; reached through `fetch`
|
|
17
18
|
].map(Object.freeze));
|
|
18
19
|
|
|
19
20
|
// retrieveClient is allowlisted by name: whether the id is the viewer's own is a runtime
|
|
@@ -27,10 +28,15 @@ export const CLIENT_SAFE_SDK_METHODS = Object.freeze(
|
|
|
27
28
|
// Marked client-safe routes that no SDK method reaches yet.
|
|
28
29
|
export const CLIENT_SAFE_ROUTES_WITHOUT_SDK_METHOD = CLIENT_SAFE_ROUTES.filter((r) => !r.sdkMethod).length;
|
|
29
30
|
|
|
30
|
-
// `{id}` stands for exactly one non-empty path segment
|
|
31
|
+
// `{id}` stands for exactly one non-empty path segment; a trailing `*name` stands for the rest of
|
|
32
|
+
// the path, as the platform's wildcard does.
|
|
31
33
|
// The id segment excludes `.` and `%` so `.`, `..` and `%2e` forms that URL normalisation resolves elsewhere fail closed.
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
+
const segmentPattern = (seg) => {
|
|
35
|
+
if (seg === '{id}') return '[A-Za-z0-9_-]+';
|
|
36
|
+
if (seg.startsWith('*')) return '.+';
|
|
37
|
+
return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
+
};
|
|
39
|
+
const routePattern = (route) => new RegExp(`^${route.path.split('/').map(segmentPattern).join('/')}$`);
|
|
34
40
|
const ROUTE_MATCHERS = CLIENT_SAFE_ROUTES.map((route) => ({ method: route.method, pattern: routePattern(route) }));
|
|
35
41
|
|
|
36
42
|
// Whether `fetch(path, { method })` hits a client-safe route; `path` has no query string.
|
package/package.json
CHANGED
|
@@ -28,9 +28,17 @@ export async function chatCompletion(messages: Array<{ role: string; content: st
|
|
|
28
28
|
stream: options?.stream || false,
|
|
29
29
|
}),
|
|
30
30
|
});
|
|
31
|
-
if (!response.ok) throw new Error(`AI request failed: ${response.status}`);
|
|
32
31
|
return response;
|
|
33
32
|
}
|
|
33
|
+
|
|
34
|
+
// The proxy refuses with { error: string }; an OpenAI error passes through as { error: { message } }.
|
|
35
|
+
export async function aiErrorMessage(response: Response): Promise<string> {
|
|
36
|
+
const body = await response.json().catch(() => null);
|
|
37
|
+
const error = body?.error;
|
|
38
|
+
if (typeof error === 'string') return error;
|
|
39
|
+
if (typeof error?.message === 'string') return error.message;
|
|
40
|
+
return `AI request failed (${response.status})`;
|
|
41
|
+
}
|
|
34
42
|
```
|
|
35
43
|
|
|
36
44
|
`assembly.fetch()` automatically prepends the base URL and injects auth headers (`Authorization: Bearer` from `getAssembly()`, plus `X-Assembly-SDK-Version`). It never sends `X-API-Key` on data calls. You only provide the path and request body.
|
|
@@ -44,18 +52,34 @@ Expose an AI call to the client as a server action — its arguments are type-ch
|
|
|
44
52
|
```typescript
|
|
45
53
|
// app/actions/summarize.ts
|
|
46
54
|
'use server';
|
|
47
|
-
import { chatCompletion } from '@/lib/ai';
|
|
55
|
+
import { aiErrorMessage, chatCompletion } from '@/lib/ai';
|
|
56
|
+
|
|
57
|
+
export type SummarizeResult = { ok: true; text: string } | { ok: false; message: string };
|
|
48
58
|
|
|
49
|
-
export async function summarize(text: string): Promise<
|
|
59
|
+
export async function summarize(text: string): Promise<SummarizeResult> {
|
|
50
60
|
const response = await chatCompletion([
|
|
51
61
|
{ role: 'user', content: `Summarize the following:\n\n${text}` },
|
|
52
62
|
]);
|
|
63
|
+
if (!response.ok) return { ok: false, message: await aiErrorMessage(response) };
|
|
53
64
|
const data = await response.json();
|
|
54
|
-
return data.choices[0].message.content;
|
|
65
|
+
return { ok: true, text: data.choices[0].message.content };
|
|
55
66
|
}
|
|
56
67
|
```
|
|
57
68
|
|
|
58
|
-
|
|
69
|
+
A failed AI call is an expected outcome, not a crash: the workspace runs out of quota (429 "AI quota exceeded for this workspace"), the proxy refuses the path (403), or OpenAI is down or slow (502/504). Check `response.ok` after every AI call and return the proxy's message as `{ ok: false, message }` (see `assembly-action-results`). Never throw it: production redacts a thrown server-action error, so the user sees a generic error and the response is HTTP 500.
|
|
70
|
+
|
|
71
|
+
From a client component:
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
const result = await callAction(() => summarize(text), { idempotent: true });
|
|
75
|
+
if (!result.ok) {
|
|
76
|
+
setError(result.message); // show it next to a Retry button
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
setSummary(result.text);
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The call writes nothing, so `idempotent: true` is safe to re-run after a bearer renewal.
|
|
59
83
|
|
|
60
84
|
## Vision (image inputs)
|
|
61
85
|
|
|
@@ -78,8 +102,7 @@ export async function analyzeImage(base64DataUrl: string, prompt: string) {
|
|
|
78
102
|
stream: false,
|
|
79
103
|
}),
|
|
80
104
|
});
|
|
81
|
-
|
|
82
|
-
return response;
|
|
105
|
+
return response; // the caller checks response.ok, as in summarize above
|
|
83
106
|
}
|
|
84
107
|
```
|
|
85
108
|
|
|
@@ -113,8 +136,7 @@ export async function transcribeAudio(file: File) {
|
|
|
113
136
|
method: 'POST',
|
|
114
137
|
body,
|
|
115
138
|
});
|
|
116
|
-
|
|
117
|
-
return response;
|
|
139
|
+
return response; // the caller checks response.ok, as in summarize above
|
|
118
140
|
}
|
|
119
141
|
```
|
|
120
142
|
|
|
@@ -134,19 +156,56 @@ For chat or long-form generation, pass `stream: true` and forward the SSE stream
|
|
|
134
156
|
export async function POST(request: NextRequest) {
|
|
135
157
|
const { messages } = await request.json();
|
|
136
158
|
const response = await chatCompletion(messages, { stream: true });
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
return NextResponse.json({ error: await aiErrorMessage(response) }, { status: response.status });
|
|
161
|
+
}
|
|
137
162
|
return new Response(response.body, {
|
|
138
163
|
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
|
|
139
164
|
});
|
|
140
165
|
}
|
|
141
166
|
```
|
|
142
167
|
|
|
143
|
-
The proxy forwards OpenAI's SSE stream directly. Parse `data:` lines on the client as usual.
|
|
168
|
+
The proxy forwards OpenAI's SSE stream directly. Parse `data:` lines on the client as usual. A refusal arrives before the stream starts, so check `res.ok` on the client and show `error` from the JSON body.
|
|
144
169
|
|
|
145
170
|
`assembly.fetch()` returns a standard `Response`, so `response.body` is a `ReadableStream` you can pipe directly.
|
|
146
171
|
|
|
147
172
|
## Quota
|
|
148
173
|
|
|
149
|
-
Usage is metered against the workspace's monthly AI quota. Do not implement your own rate limiting — Assembly handles this automatically. If the quota is exceeded, the proxy returns HTTP 429.
|
|
174
|
+
Usage is metered against the workspace's monthly AI quota. Do not implement your own rate limiting — Assembly handles this automatically. If the quota is exceeded, the proxy returns HTTP 429 with `{ error: "AI quota exceeded for this workspace" }`. Show that message; don't replace it with "Something went wrong".
|
|
175
|
+
|
|
176
|
+
## Conversations: persist the question and answer together
|
|
177
|
+
|
|
178
|
+
A chat feature (an assistant, a Q&A thread) must never leave a conversation with a question and no answer, or a titled conversation with no messages. Call the model first. Write the user turn and the assistant turn in one atomic write, and only after the answer has arrived. If the AI call fails, write nothing and return `{ ok: false, message }`. The UI then shows the message with a Retry button that sends the same question again.
|
|
179
|
+
|
|
180
|
+
```typescript
|
|
181
|
+
// app/actions/chat.ts
|
|
182
|
+
'use server';
|
|
183
|
+
|
|
184
|
+
export type SendQuestionResult =
|
|
185
|
+
| { ok: true; conversationId: string; answer: string }
|
|
186
|
+
| { ok: false; message: string };
|
|
187
|
+
|
|
188
|
+
export async function sendQuestion(conversationId: string | null, question: string): Promise<SendQuestionResult> {
|
|
189
|
+
const history = conversationId ? await loadMessages(conversationId) : [];
|
|
190
|
+
const response = await chatCompletion([...history, { role: 'user', content: question }]);
|
|
191
|
+
if (!response.ok) return { ok: false, message: await aiErrorMessage(response) };
|
|
192
|
+
const answer = (await response.json()).choices[0].message.content;
|
|
193
|
+
|
|
194
|
+
const id = conversationId ?? crypto.randomUUID();
|
|
195
|
+
await db.batch([
|
|
196
|
+
...(conversationId ? [] : [db.insert(conversations).values({ id, title: question.slice(0, 80) })]),
|
|
197
|
+
db.insert(messages).values([
|
|
198
|
+
{ conversationId: id, role: 'user', content: question },
|
|
199
|
+
{ conversationId: id, role: 'assistant', content: answer },
|
|
200
|
+
]),
|
|
201
|
+
]);
|
|
202
|
+
return { ok: true, conversationId: id, answer };
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`db.batch` commits the conversation row and both messages together or not at all (see `assembly-db-transactions`). Keep the question in client state until the action returns, so the chat shows it while the answer loads and Retry can resend it. This action writes, so call it without `{ idempotent: true }`.
|
|
207
|
+
|
|
208
|
+
When streaming, write the rows when the stream completes, not when it starts: collect the streamed text, then save the conversation and both turns in one `db.batch` from the stream's final step. A stream that errors or is cancelled saves nothing.
|
|
150
209
|
|
|
151
210
|
## What NOT to do
|
|
152
211
|
|