@assembly-js/app-foundation 0.6.1 → 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 CHANGED
@@ -19,6 +19,9 @@ 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
+
22
25
  ## 0.6.1
23
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.
24
27
 
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -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<string> {
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
- From a client component: `const summary = await callAction(() => summarize(text), { idempotent: true });` — the call writes nothing, so it is safe to re-run after a bearer renewal.
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
- if (!response.ok) throw new Error(`AI request failed: ${response.status}`);
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
- if (!response.ok) throw new Error(`AI request failed: ${response.status}`);
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