@elytracms/studio 0.0.55 → 0.0.57

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.
Files changed (55) hide show
  1. package/convex/auth.ts +152 -2
  2. package/convex/users.ts +41 -14
  3. package/dist/components/ai/ai-chat-panel-lazy.js +3 -3
  4. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
  5. package/dist/components/ai/ai-chat-panel.d.ts +0 -9
  6. package/dist/components/ai/ai-chat-panel.js +161 -60
  7. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  8. package/dist/components/ai/chat-ui.d.ts +62 -10
  9. package/dist/components/ai/chat-ui.js +130 -23
  10. package/dist/components/ai/chat-ui.js.map +1 -1
  11. package/dist/lib/ai/anthropic-provider.d.ts +0 -12
  12. package/dist/lib/ai/anthropic-provider.js +3 -11
  13. package/dist/lib/ai/anthropic-provider.js.map +1 -1
  14. package/dist/lib/ai/chat-context.d.ts +58 -0
  15. package/dist/lib/ai/chat-context.js +71 -0
  16. package/dist/lib/ai/chat-context.js.map +1 -0
  17. package/dist/lib/ai/chat-loop.d.ts +70 -0
  18. package/dist/lib/ai/chat-loop.js +46 -0
  19. package/dist/lib/ai/chat-loop.js.map +1 -0
  20. package/dist/lib/ai/chat-state.d.ts +70 -57
  21. package/dist/lib/ai/chat-state.js +49 -90
  22. package/dist/lib/ai/chat-state.js.map +1 -1
  23. package/dist/lib/ai/fake-provider.d.ts +84 -6
  24. package/dist/lib/ai/fake-provider.js +193 -8
  25. package/dist/lib/ai/fake-provider.js.map +1 -1
  26. package/dist/lib/ai/gateway-provider.d.ts +30 -0
  27. package/dist/lib/ai/gateway-provider.js +73 -0
  28. package/dist/lib/ai/gateway-provider.js.map +1 -0
  29. package/dist/lib/ai/index.d.ts +3 -1
  30. package/dist/lib/ai/index.js +3 -1
  31. package/dist/lib/ai/index.js.map +1 -1
  32. package/dist/lib/ai/node-handler.d.ts +13 -4
  33. package/dist/lib/ai/node-handler.js +33 -3
  34. package/dist/lib/ai/node-handler.js.map +1 -1
  35. package/dist/lib/ai/route-provider.d.ts +35 -10
  36. package/dist/lib/ai/route-provider.js +13 -6
  37. package/dist/lib/ai/route-provider.js.map +1 -1
  38. package/dist/lib/ai/route.d.ts +50 -21
  39. package/dist/lib/ai/route.js +69 -40
  40. package/dist/lib/ai/route.js.map +1 -1
  41. package/dist/lib/ai/tools.d.ts +169 -39
  42. package/dist/lib/ai/tools.js +308 -62
  43. package/dist/lib/ai/tools.js.map +1 -1
  44. package/dist/lib/ai/types.d.ts +11 -16
  45. package/dist/lib/ai/types.js.map +1 -1
  46. package/dist/lib/auth/convex-auth-adapter.d.ts +37 -14
  47. package/dist/lib/auth/convex-auth-adapter.js +72 -16
  48. package/dist/lib/auth/convex-auth-adapter.js.map +1 -1
  49. package/dist/lib/i18n/messages.en.js +9 -0
  50. package/dist/lib/i18n/messages.en.js.map +1 -1
  51. package/dist/ui/badge.d.ts +1 -1
  52. package/package.json +4 -3
  53. package/dist/lib/ai/create-flow.d.ts +0 -132
  54. package/dist/lib/ai/create-flow.js +0 -280
  55. package/dist/lib/ai/create-flow.js.map +0 -1
package/convex/auth.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { convexAuth } from '@convex-dev/auth/server'
2
2
  import { Password } from '@convex-dev/auth/providers/Password'
3
+ import { actionGeneric } from 'convex/server'
3
4
  import type { AnyDataModel, GenericMutationCtx } from 'convex/server'
4
- import { ConvexError } from 'convex/values'
5
+ import { ConvexError, v } from 'convex/values'
5
6
  import type { GenericId } from 'convex/values'
6
7
  import { classifyInviteSignup, normalizeInviteEmail } from '@elytracms/core/persistence'
7
8
  import type { InviteSignupDecision } from '@elytracms/core/persistence'
@@ -9,6 +10,99 @@ import type { InviteSignupDecision } from '@elytracms/core/persistence'
9
10
  /** The provider list `convexAuth` accepts, derived from its own signature. */
10
11
  type ConvexAuthProviders = Parameters<typeof convexAuth>[0]['providers']
11
12
 
13
+ /**
14
+ * EC-363 — stable, secret-free sign-in failure codes. A PRODUCTION Convex
15
+ * deployment redacts a thrown error's `.message` to a generic "Server Error"
16
+ * before it reaches the client; only a `ConvexError`'s `.data` survives. But
17
+ * `@convex-dev/auth@0.0.94` throws its credential/env failures as PLAIN
18
+ * `Error`s, so on prod every wrong password collapsed onto the client's
19
+ * honest-generic fallback (EC-362's key finding). These codes ride in
20
+ * `ConvexError.data` across that redaction so the sign-in screen can tell the
21
+ * truth. Codes ONLY — never the raw library message (it can carry the account
22
+ * id / env-var names).
23
+ *
24
+ * EC-377 widened the family beyond sign-in: `users:changePassword` codes its
25
+ * provider throws with the same table (a wrong CURRENT password is an
26
+ * `InvalidSecret` → `invalid-credentials`), plus `weak-password` for its own
27
+ * min-length guard (the reset flow validates it, `modifyAccountCredentials`
28
+ * does not — and the client UI doesn't pre-validate).
29
+ */
30
+ export type StudioAuthErrorCode =
31
+ | 'invalid-credentials'
32
+ | 'too-many-attempts'
33
+ | 'account-exists'
34
+ | 'deployment-misconfigured'
35
+ | 'weak-password'
36
+
37
+ /**
38
+ * Map a `@convex-dev/auth` plain-`Error` message to a stable code — or `null`
39
+ * when unrecognized (the caller then rethrows the original error untouched, so
40
+ * unknown failures keep today's honest-generic behavior; never misclassify).
41
+ *
42
+ * The matched throw sites, pinned in the installed `@convex-dev/auth@0.0.94`:
43
+ *
44
+ * - `retrieveAccount` → `throw new Error(result)` with `result` ∈
45
+ * `"InvalidSecret" | "InvalidAccountId" | "TooManyFailedAttempts"`
46
+ * (`dist/server/implementation/index.js`; the strings come from
47
+ * `mutations/retrieveAccountWithCredentials.js`). Thrown INSIDE the signIn
48
+ * action.
49
+ * - The `Password` provider's own in-action throws: `"Invalid password"`
50
+ * (default password requirements at sign-up) and `"Invalid credentials"`
51
+ * (`dist/providers/Password.js`).
52
+ * - `requireEnv` → ``throw new Error(`Missing environment variable \`${name}\``)``
53
+ * (`dist/server/utils.js`) and jose's `importPKCS8` PKCS8/private-key parse
54
+ * errors — the EC-257 misconfigured-deployment family. These fire inside the
55
+ * library's `auth:store` MUTATION (`generateToken`); the backend propagates
56
+ * sub-call errors to the calling action UNREDACTED even on prod (redaction is
57
+ * client-boundary only — `RedactedJsError` is constructed solely in the
58
+ * sync-worker/HTTP-API crates), so the action-seam catch still sees the token.
59
+ * - `createAccountFromCredentials` → ``Account ${account.id} already exists``
60
+ * (`dist/server/implementation/mutations/createAccountFromCredentials.js`),
61
+ * also via the store mutation.
62
+ *
63
+ * Order matters: the env/signing-key family is checked FIRST (those throws fire
64
+ * after password validation and must never degrade to a credential code —
65
+ * mirrors the client ladder, EC-257).
66
+ */
67
+ export function classifyAuthLibraryError(message: string): StudioAuthErrorCode | null {
68
+ if (/Missing environment variable|JWT_PRIVATE_KEY|JWKS|PKCS8|private key/i.test(message)) {
69
+ return 'deployment-misconfigured'
70
+ }
71
+ if (/TooManyFailedAttempts/.test(message)) return 'too-many-attempts'
72
+ if (/already exists/.test(message)) return 'account-exists'
73
+ if (/InvalidSecret|InvalidAccountId|Invalid credentials|Invalid password/.test(message)) {
74
+ return 'invalid-credentials'
75
+ }
76
+ return null
77
+ }
78
+
79
+ /**
80
+ * EC-363 — the coding step for the signIn wrap: return a `ConvexError` whose
81
+ * `.data` carries a {@link StudioAuthErrorCode} for a recognized library throw,
82
+ * or the ORIGINAL error untouched for everything else.
83
+ *
84
+ * - An error already carrying `.data` (a `ConvexError`, e.g. the invite gate's
85
+ * `NOT_INVITED_REASON` crossing the store-mutation boundary) passes through
86
+ * unchanged — its data already survives prod redaction. Duck-typed, not
87
+ * `instanceof` (dual module instances, EC-357/EC-362).
88
+ * - Unrecognized errors pass through unchanged: which cases fail and how they
89
+ * read today is preserved exactly; when in doubt, the client's honest generic.
90
+ */
91
+ export function toCodedSignInError(error: unknown): unknown {
92
+ if (
93
+ typeof error === 'object' &&
94
+ error !== null &&
95
+ 'data' in error &&
96
+ (error as { data: unknown }).data !== undefined
97
+ ) {
98
+ return error
99
+ }
100
+ const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
101
+ const code = classifyAuthLibraryError(message)
102
+ if (code === null) return error
103
+ return new ConvexError({ code })
104
+ }
105
+
12
106
  /** The (non-schema-bound) mutation ctx the `convexAuth` callbacks receive. */
13
107
  type AuthCallbackCtx = GenericMutationCtx<AnyDataModel>
14
108
 
@@ -156,7 +250,7 @@ export async function gateSessionCreation(
156
250
  * project-owned `AuthAdapter`, so swapping providers later is one factory call, not UI.
157
251
  */
158
252
  export function defineStudioAuth(options?: { providers?: ConvexAuthProviders }) {
159
- return convexAuth({
253
+ const api = convexAuth({
160
254
  providers: options?.providers ?? [Password],
161
255
  callbacks: {
162
256
  /** EC-358: reject uninvited account creation BEFORE any row commits. */
@@ -169,4 +263,60 @@ export function defineStudioAuth(options?: { providers?: ConvexAuthProviders })
169
263
  },
170
264
  },
171
265
  })
266
+
267
+ // EC-363: wrap the `auth:signIn` ACTION so the library's plain-`Error`
268
+ // credential/env throws reach the client as `ConvexError`s carrying a stable
269
+ // code (`.data` survives prod redaction; `.message` does not). The wrap is a
270
+ // pure delegate — same args validators, same mechanics, same success values;
271
+ // ONLY the thrown-error shape changes, and only for recognized library throws
272
+ // (see `toCodedSignInError`). Re-registering is required because the
273
+ // registered action's `invokeAction` closes over the ORIGINAL handler
274
+ // (mutating `_handler` after the fact would not take effect).
275
+ //
276
+ // `_handler` is set by every `actionGeneric` registration in the installed
277
+ // convex runtime (`dist/esm/server/impl/registration_impl.js`) but is not in
278
+ // the public type — hence the structural cast. The loud throw below runs at
279
+ // module-definition time, so a convex upgrade that drops the seam fails the
280
+ // functions push visibly instead of silently shipping unwrapped errors.
281
+ const rawSignInHandler = (
282
+ api.signIn as unknown as {
283
+ _handler?: (ctx: unknown, args: unknown) => Promise<unknown>
284
+ }
285
+ )._handler
286
+ if (typeof rawSignInHandler !== 'function') {
287
+ throw new Error(
288
+ 'defineStudioAuth: convexAuth().signIn no longer exposes `_handler` — the EC-363 ' +
289
+ 'coded-auth-error wrap cannot attach. Check the installed convex runtime.',
290
+ )
291
+ }
292
+ const signIn = actionGeneric({
293
+ // Mirrors the library's own `signIn` validators exactly (@convex-dev/auth
294
+ // 0.0.94, `dist/server/implementation/index.js`) so argument validation is
295
+ // unchanged.
296
+ args: {
297
+ provider: v.optional(v.string()),
298
+ params: v.optional(v.any()),
299
+ verifier: v.optional(v.string()),
300
+ refreshToken: v.optional(v.string()),
301
+ calledBy: v.optional(v.string()),
302
+ },
303
+ handler: async (ctx, args) => {
304
+ try {
305
+ return await rawSignInHandler(ctx, args)
306
+ } catch (error) {
307
+ const coded = toCodedSignInError(error)
308
+ if (coded !== error) {
309
+ // Keep the ORIGINAL failure visible in the deployment logs — the
310
+ // client now only receives the stable code.
311
+ console.error('auth:signIn failed:', error)
312
+ }
313
+ throw coded
314
+ }
315
+ },
316
+ // The delegate preserves the original action's runtime contract; align the
317
+ // exported type with it so consumers' `convex/auth.ts` re-exports and
318
+ // codegen see the exact same `signIn` surface as before.
319
+ }) as unknown as typeof api.signIn
320
+
321
+ return { ...api, signIn }
172
322
  }
package/convex/users.ts CHANGED
@@ -4,7 +4,9 @@ import {
4
4
  retrieveAccount,
5
5
  } from '@convex-dev/auth/server'
6
6
  import { makeFunctionReference } from 'convex/server'
7
- import { v } from 'convex/values'
7
+ import { ConvexError, v } from 'convex/values'
8
+ import { toCodedSignInError } from './auth'
9
+ import type { StudioAuthErrorCode } from './auth'
8
10
  import { action, mutation, query } from './functions'
9
11
  import type { QueryCtx } from './functions'
10
12
  import { requireCapability } from './guard'
@@ -146,6 +148,16 @@ const meRef = makeFunctionReference<'query', Record<string, never>, MeResult | n
146
148
  * provider and are NEVER logged or persisted by this code. Self-scoped: it
147
149
  * derives the caller from the JWT and cross-checks the retrieved account is the
148
150
  * caller's own before modifying.
151
+ *
152
+ * EC-377: the provider-call failure path re-throws through the EC-363
153
+ * `toCodedSignInError` wrap — `@convex-dev/auth` throws its failures as PLAIN
154
+ * `Error`s (`retrieveAccount` → `"InvalidSecret"` on a wrong CURRENT password),
155
+ * and a PROD deployment redacts a plain error's `.message` to "Server Error",
156
+ * so without the wrap the client's honest-generic fallback swallowed the truth.
157
+ * Recognized throws become `ConvexError({ code })` (`.data` survives prod
158
+ * redaction; codes ONLY, never the raw message); everything else — including
159
+ * this action's own `unauthorized: …` guards — passes through UNTOUCHED, so
160
+ * which cases fail is unchanged.
149
161
  */
150
162
  export const changePassword = action({
151
163
  args: { currentPassword: v.string(), newPassword: v.string() },
@@ -153,21 +165,36 @@ export const changePassword = action({
153
165
  const userId = await getAuthUserId(ctx)
154
166
  if (userId === null) throw new Error('unauthorized: this function requires an authenticated user')
155
167
  // Enforce the provider's default password policy (min length) — the reset
156
- // flow validates it, but `modifyAccountCredentials` does not.
157
- if (newPassword.length < 8) throw new Error('Password must be at least 8 characters')
168
+ // flow validates it, but `modifyAccountCredentials` does not. Coded (EC-377):
169
+ // the credential panel has no client-side length check, so this guard is
170
+ // user-reachable and must classify truthfully on prod too.
171
+ if (newPassword.length < 8) {
172
+ throw new ConvexError({ code: 'weak-password' satisfies StudioAuthErrorCode })
173
+ }
158
174
  const me = await ctx.runQuery(meRef, {})
159
175
  if (!me?.email) throw new Error('unauthorized: no account email on file')
160
- // Verify the CURRENT password; throws on mismatch (→ invalid-credentials).
161
- const { user } = await retrieveAccount(ctx, {
162
- provider: 'password',
163
- account: { id: me.email, secret: currentPassword },
164
- })
165
- // Defense in depth: the retrieved account MUST be the caller's own.
166
- if (String(user._id) !== String(userId)) throw new Error('unauthorized: account mismatch')
167
- await modifyAccountCredentials(ctx, {
168
- provider: 'password',
169
- account: { id: me.email, secret: newPassword },
170
- })
176
+ try {
177
+ // Verify the CURRENT password; throws on mismatch (→ invalid-credentials).
178
+ const { user } = await retrieveAccount(ctx, {
179
+ provider: 'password',
180
+ account: { id: me.email, secret: currentPassword },
181
+ })
182
+ // Defense in depth: the retrieved account MUST be the caller's own.
183
+ if (String(user._id) !== String(userId)) throw new Error('unauthorized: account mismatch')
184
+ await modifyAccountCredentials(ctx, {
185
+ provider: 'password',
186
+ account: { id: me.email, secret: newPassword },
187
+ })
188
+ } catch (error) {
189
+ const coded = toCodedSignInError(error)
190
+ if (coded !== error) {
191
+ // Keep the ORIGINAL failure visible in the deployment logs — the client
192
+ // only receives the stable code. (`retrieveAccount` throws bare token
193
+ // messages; the raw password values never appear in any error.)
194
+ console.error('users:changePassword failed:', error)
195
+ }
196
+ throw coded
197
+ }
171
198
  return null
172
199
  },
173
200
  })
@@ -3,9 +3,9 @@ import { Suspense, lazy } from 'react';
3
3
  import { Text } from '@elytracms/studio/ui';
4
4
  import { translate } from '@elytracms/studio/lib/i18n';
5
5
  /**
6
- * Lazy boundary for the AI chat panel (EC-199, EC-331 bundle discipline). The panel
7
- * and everything it imports — the creation orchestrator, `applyContentWithShape`, the
8
- * provider adapters (and the AI SDK behind the Anthropic one via dynamic import) — load
6
+ * Lazy boundary for the AI chat panel (EC-199, EC-331/EC-334 bundle discipline). The
7
+ * panel and everything it imports — `useChat` + the AI SDK (`ai` / `@ai-sdk/react`),
8
+ * the client tool executor, `applyContentWithShape`, the provider adapters — load
9
9
  * ONLY when a configured studio actually opens the panel. An unconfigured studio never
10
10
  * renders the toggle, never mounts this, and so pulls none of the AI chunk onto the CMS
11
11
  * critical path (pinned by the `lib/ai` bundle test). Same pattern as
@@ -1 +1 @@
1
- {"version":3,"file":"ai-chat-panel-lazy.js","sourceRoot":"","sources":["../../../src/components/ai/ai-chat-panel-lazy.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AAEtD;;;;;;;;GAQG;AACH,MAAM,eAAe,GAAG,IAAI,CAAC,GAAG,EAAE,CAChC,MAAM,CAAC,iBAAiB,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC,CAC9E,CAAA;AAED,SAAS,eAAe;IACtB,OAAO,CACL,gBAAO,SAAS,EAAC,gFAAgF,YAC/F,cAAK,SAAS,EAAC,4CAA4C,YACzD,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,kBAAkB,CAAC,GACzB,GACH,GACA,CACT,CAAA;AACH,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,OAAO,CACL,KAAC,QAAQ,IAAC,QAAQ,EAAE,KAAC,eAAe,KAAG,YACrC,KAAC,eAAe,KAAG,GACV,CACZ,CAAA;AACH,CAAC","sourcesContent":["import { Suspense, lazy } from 'react'\nimport { Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/**\n * Lazy boundary for the AI chat panel (EC-199, EC-331 bundle discipline). The panel\n * and everything it imports — the creation orchestrator, `applyContentWithShape`, the\n * provider adapters (and the AI SDK behind the Anthropic one via dynamic import) — load\n * ONLY when a configured studio actually opens the panel. An unconfigured studio never\n * renders the toggle, never mounts this, and so pulls none of the AI chunk onto the CMS\n * critical path (pinned by the `lib/ai` bundle test). Same pattern as\n * `version-compare-lazy` / `rich-text/lazy`.\n */\nconst AiChatPanelImpl = lazy(() =>\n import('./ai-chat-panel').then((module) => ({ default: module.AiChatPanel })),\n)\n\nfunction AiPanelFallback() {\n return (\n <aside className=\"flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card\">\n <div className=\"grid min-h-0 flex-1 place-items-center p-6\">\n <Text size=\"sm\" tone=\"muted\">\n {translate('ai.panel.loading')}\n </Text>\n </div>\n </aside>\n )\n}\n\nexport function AiChatPanel() {\n return (\n <Suspense fallback={<AiPanelFallback />}>\n <AiChatPanelImpl />\n </Suspense>\n )\n}\n"]}
1
+ {"version":3,"file":"ai-chat-panel-lazy.js","sourceRoot":"","sources":["../../../src/components/ai/ai-chat-panel-lazy.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,OAAO,CAAA;AACtC,OAAO,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AAEtD;;;;;;;;GAQG;AACH,MAAM,eAAe,GAAG,IAAI,CAAC,GAAG,EAAE,CAChC,MAAM,CAAC,iBAAiB,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,MAAM,CAAC,WAAW,EAAE,CAAC,CAAC,CAC9E,CAAA;AAED,SAAS,eAAe;IACtB,OAAO,CACL,gBAAO,SAAS,EAAC,gFAAgF,YAC/F,cAAK,SAAS,EAAC,4CAA4C,YACzD,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,kBAAkB,CAAC,GACzB,GACH,GACA,CACT,CAAA;AACH,CAAC;AAED,MAAM,UAAU,WAAW;IACzB,OAAO,CACL,KAAC,QAAQ,IAAC,QAAQ,EAAE,KAAC,eAAe,KAAG,YACrC,KAAC,eAAe,KAAG,GACV,CACZ,CAAA;AACH,CAAC","sourcesContent":["import { Suspense, lazy } from 'react'\nimport { Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/**\n * Lazy boundary for the AI chat panel (EC-199, EC-331/EC-334 bundle discipline). The\n * panel and everything it imports — `useChat` + the AI SDK (`ai` / `@ai-sdk/react`),\n * the client tool executor, `applyContentWithShape`, the provider adapters — load\n * ONLY when a configured studio actually opens the panel. An unconfigured studio never\n * renders the toggle, never mounts this, and so pulls none of the AI chunk onto the CMS\n * critical path (pinned by the `lib/ai` bundle test). Same pattern as\n * `version-compare-lazy` / `rich-text/lazy`.\n */\nconst AiChatPanelImpl = lazy(() =>\n import('./ai-chat-panel').then((module) => ({ default: module.AiChatPanel })),\n)\n\nfunction AiPanelFallback() {\n return (\n <aside className=\"flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card\">\n <div className=\"grid min-h-0 flex-1 place-items-center p-6\">\n <Text size=\"sm\" tone=\"muted\">\n {translate('ai.panel.loading')}\n </Text>\n </div>\n </aside>\n )\n}\n\nexport function AiChatPanel() {\n return (\n <Suspense fallback={<AiPanelFallback />}>\n <AiChatPanelImpl />\n </Suspense>\n )\n}\n"]}
@@ -1,10 +1 @@
1
- /**
2
- * The AI assistant chat panel (EC-199) — the right-hand collapsible surface in the CMS
3
- * area. It owns the chat state (the pure `chatReducer`), resolves the EC-331 provider
4
- * from `defineStudio({ ai })`, binds the narrow tools (search/list) to the operations
5
- * layer with the user's session, runs the creation orchestrator, and applies a
6
- * reviewed proposal as a DRAFT (operations upsert + a provenance-tagged version
7
- * snapshot). NEVER a modal (house rule). The whole module sits behind a lazy boundary
8
- * (`ai-chat-panel-lazy`), so an unconfigured studio pulls none of it — and no AI SDK.
9
- */
10
1
  export declare function AiChatPanel(): import("react").JSX.Element;
@@ -1,39 +1,63 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useMemo, useReducer, useState } from 'react';
3
- import { X } from 'lucide-react';
2
+ import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
3
+ import { MessageSquarePlus, X } from 'lucide-react';
4
+ import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai';
5
+ import { useChat } from '@ai-sdk/react';
4
6
  import { createCollectionRegistry, resolveDocumentTitle, } from '@elytracms/core/cms-core';
5
7
  import { Button, Heading, Row, Spacer, Text } from '@elytracms/studio/ui';
6
8
  import { translate } from '@elytracms/studio/lib/i18n';
7
9
  import { STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router';
8
10
  import { resolveDeploymentAi } from '@elytracms/studio/lib/workspace';
9
11
  import { getStudioOperations } from '@elytracms/studio/lib/operations';
10
- import { AiRouteError, chatReducer, initialChatState, resolveAiConfig, resolveKnowledge, runCreateFlow, } from '@elytracms/studio/lib/ai';
12
+ import { DEFAULT_MAX_STEPS, buildChatContext, chatReviewReducer, clearConversation, createChatCandidateStore, createChatToolExecutor, initialChatReviewState, resolveAiConfig, resolveChatTarget, resolveKnowledge, withStepCap, } from '@elytracms/studio/lib/ai';
11
13
  import { useCms } from '../cms/cms-store.js';
12
14
  import { validateComposition, validateRichTextEmbeds } from '../cms/cms-composition-validators.js';
13
- import { AssistantTurn, Conversation, ConversationEmpty, PromptInput, ToolStatus, UserTurn, } from './chat-ui.js';
15
+ import { AssistantTurn, ChatErrorNote, Conversation, ConversationEmpty, PromptInput, ToolStatus, UserTurn, WorkingIndicator, describeToolPart, messageText, segmentMessageParts, } from './chat-ui.js';
14
16
  import { ProposalCard } from './proposal-card.js';
15
17
  import { setAiPanelOpen } from '@elytracms/studio/lib/ai-panel';
16
18
  /**
17
- * The AI assistant chat panel (EC-199) — the right-hand collapsible surface in the CMS
18
- * area. It owns the chat state (the pure `chatReducer`), resolves the EC-331 provider
19
- * from `defineStudio({ ai })`, binds the narrow tools (search/list) to the operations
20
- * layer with the user's session, runs the creation orchestrator, and applies a
21
- * reviewed proposal as a DRAFT (operations upsert + a provenance-tagged version
22
- * snapshot). NEVER a modal (house rule). The whole module sits behind a lazy boundary
23
- * (`ai-chat-panel-lazy`), so an unconfigured studio pulls none of it — and no AI SDK.
19
+ * The AI assistant chat panel (EC-199, moved onto the SDK's native protocol by
20
+ * EC-334) — the right-hand collapsible surface in the CMS area. `useChat` +
21
+ * `DefaultChatTransport` own the conversation against the route's native chat
22
+ * branch (visible token streaming); `onToolCall` dispatches into the client
23
+ * executor map (operations reads + terminal engines, all with the user's own
24
+ * session); `sendAutomaticallyWhen` is the SDK predicate wrapped with the
25
+ * per-turn step cap. What the SDK does not own lives in the pure review reducer:
26
+ * full proposals keyed by `toolCallId` (reviewed as a diff, applied as a DRAFT
27
+ * via operations upsert + a provenance-tagged snapshot — the EC-199 trust
28
+ * boundary, unmoved) and blocking errors. EC-380: the "new conversation"
29
+ * affordance clears busy-safely (stop → new chat id + review reset; pending
30
+ * proposals are discarded with reject semantics).
31
+ *
32
+ * NEVER a modal (house rule). The whole module (including `ai` + `@ai-sdk/react`)
33
+ * sits behind the lazy boundary (`ai-chat-panel-lazy`) — pinned in bundle tests.
24
34
  */
35
+ /** Adapt a `RouteHeaders` (static or thunk) to the transport's headers resolvable. */
36
+ function toTransportHeaders(headers) {
37
+ return async () => {
38
+ const resolved = typeof headers === 'function' ? await headers() : headers;
39
+ const record = {};
40
+ new Headers(resolved ?? {}).forEach((value, key) => {
41
+ record[key] = value;
42
+ });
43
+ return record;
44
+ };
45
+ }
25
46
  export function AiChatPanel() {
26
47
  const cms = useCms();
27
48
  const navigate = useNavigate();
28
- const [state, dispatch] = useReducer(chatReducer, initialChatState);
49
+ const [review, dispatch] = useReducer(chatReviewReducer, initialChatReviewState);
29
50
  const [input, setInput] = useState('');
51
+ // EC-380: the conversation counter — bumping it mints a fresh `useChat` id
52
+ // (a brand-new conversation) and a fresh relation-candidate store.
53
+ const [conversation, setConversation] = useState(1);
30
54
  const projectId = cms.projectId;
31
55
  const activeLocale = cms.activeLocale;
32
56
  // Resolve the AI platform config once (presence + capability gate). The panel only
33
57
  // mounts when configured, but a misconfigured provider still fails loud here.
34
58
  const resolved = useMemo(() => resolveAiConfig(resolveDeploymentAi()), []);
35
59
  const provider = resolved.resolved?.provider ?? null;
36
- const knowledgeSources = resolved.resolved?.knowledge ?? [];
60
+ const knowledgeSources = useMemo(() => resolved.resolved?.knowledge ?? [], [resolved]);
37
61
  const maxRepairAttempts = resolved.resolved?.limits?.maxRepairAttempts;
38
62
  const ready = provider !== null && resolved.ok && projectId !== undefined;
39
63
  const registry = useMemo(() => createCollectionRegistry(cms.collections), [cms.collections]);
@@ -43,7 +67,7 @@ export function AiChatPanel() {
43
67
  ...(doc.localized?.[activeLocale] ?? {}),
44
68
  }), [activeLocale]);
45
69
  /** Operations-backed tool readers — client-side, with the user's own session. */
46
- const tools = useMemo(() => {
70
+ const readers = useMemo(() => {
47
71
  const matches = (query, ...fields) => !query || fields.some((f) => f?.toLowerCase().includes(query.toLowerCase()));
48
72
  return {
49
73
  async searchDocuments({ collection, query }) {
@@ -74,7 +98,7 @@ export function AiChatPanel() {
74
98
  .map((asset) => ({ id: asset.id, title: asset.title ?? asset.filename }))
75
99
  .filter((r) => matches(query, r.title, r.id));
76
100
  },
77
- // EC-332: the translate flow loads a full document (draft values + localized).
101
+ // Full document (draft values + localized) — the getDocument/translate tools.
78
102
  async getDocument({ collection, id }) {
79
103
  if (projectId === undefined)
80
104
  return null;
@@ -110,53 +134,122 @@ export function AiChatPanel() {
110
134
  })
111
135
  .filter((item) => item.content.trim().length > 0);
112
136
  }, [projectId, cms, localeValues]);
113
- const runTurn = useCallback(async (text) => {
114
- if (!provider)
115
- return;
116
- try {
137
+ // The knowledge snapshot the executors read — refreshed by the body thunk on
138
+ // every request, so the terminal engines see what the model saw this turn.
139
+ const knowledgeRef = useRef({ items: [] });
140
+ // The chat transport target: the configured route provider's endpoint +
141
+ // session-header thunk (EC-371), re-evaluated per request.
142
+ const chatTarget = useMemo(() => resolveChatTarget(provider), [provider]);
143
+ const transport = useMemo(() => new DefaultChatTransport({
144
+ api: chatTarget.endpoint,
145
+ headers: toTransportHeaders(chatTarget.headers),
146
+ // The `body` THUNK — re-evaluated per request (fresh context every turn,
147
+ // continuations included): collections-catalog summary + client-resolved
148
+ // knowledge. The route folds these sections into the system prompt; it
149
+ // resolves no data itself (zero-data-access proxy). EC-378's
150
+ // current-document block joins via `extraSections`.
151
+ body: async () => {
117
152
  const knowledge = await resolveKnowledge(knowledgeSources, { readCollection });
118
- const result = await runCreateFlow({
119
- provider,
120
- tools,
121
- registry,
122
- collections: cms.collections,
123
- instruction: text,
124
- knowledge,
125
- locale: activeLocale,
126
- localeConfig: cms.localeConfig,
127
- mintDocumentId,
128
- validateRichTextEmbeds,
129
- validateComposition,
130
- ...(maxRepairAttempts !== undefined ? { maxRepairAttempts } : {}),
131
- onStep: (step) => dispatch({ type: 'step', step }),
132
- });
133
- dispatch({ type: 'result', result });
134
- }
135
- catch (error) {
136
- const message = error instanceof AiRouteError
137
- ? translate('ai.error.route')
138
- : translate('ai.error.generic');
139
- dispatch({ type: 'fail', message });
140
- }
153
+ knowledgeRef.current = knowledge;
154
+ return { context: buildChatContext({ collections: cms.collections, knowledge }) };
155
+ },
156
+ }), [chatTarget, knowledgeSources, readCollection, cms.collections]);
157
+ // Per-conversation relation-candidate store (EC-380: replaced on clear, so a
158
+ // fresh conversation cannot reference the previous one's searched ids).
159
+ const candidatesRef = useRef({
160
+ conversation,
161
+ store: createChatCandidateStore(),
162
+ });
163
+ if (candidatesRef.current.conversation !== conversation) {
164
+ candidatesRef.current = { conversation, store: createChatCandidateStore() };
165
+ }
166
+ const candidateStore = candidatesRef.current.store;
167
+ const executor = useMemo(() => {
168
+ if (!provider)
169
+ return null;
170
+ return createChatToolExecutor({
171
+ provider,
172
+ registry,
173
+ collections: cms.collections,
174
+ readers,
175
+ mintDocumentId,
176
+ candidates: candidateStore,
177
+ onProposal: (toolCallId, proposal) => dispatch({ type: 'propose', toolCallId, proposal }),
178
+ knowledge: () => knowledgeRef.current,
179
+ locale: activeLocale,
180
+ ...(cms.localeConfig ? { localeConfig: cms.localeConfig } : {}),
181
+ validateRichTextEmbeds,
182
+ validateComposition,
183
+ ...(maxRepairAttempts !== undefined ? { maxRepairAttempts } : {}),
184
+ });
141
185
  }, [
142
186
  provider,
143
- knowledgeSources,
144
- readCollection,
145
- tools,
146
187
  registry,
147
188
  cms.collections,
148
- activeLocale,
189
+ cms.localeConfig,
190
+ readers,
149
191
  mintDocumentId,
192
+ activeLocale,
150
193
  maxRepairAttempts,
194
+ candidateStore,
151
195
  ]);
196
+ const chat = useChat({
197
+ // A new conversation is a new chat id — `useChat` recreates its state.
198
+ id: `elytra-assistant-${conversation}`,
199
+ transport,
200
+ onToolCall: async ({ toolCall }) => {
201
+ if (!executor)
202
+ return;
203
+ const outcome = await executor.execute({
204
+ toolCallId: toolCall.toolCallId,
205
+ toolName: toolCall.toolName,
206
+ input: toolCall.input,
207
+ });
208
+ // Deliberately NOT awaited: `onToolCall` runs inside the chat's serial
209
+ // stream job, and `addToolOutput` enqueues on the same executor —
210
+ // awaiting it here deadlocks the stream (verified against ai@7.0.22).
211
+ if (outcome.ok) {
212
+ void chat.addToolOutput({
213
+ tool: toolCall.toolName,
214
+ toolCallId: toolCall.toolCallId,
215
+ output: outcome.output,
216
+ });
217
+ }
218
+ else {
219
+ void chat.addToolOutput({
220
+ state: 'output-error',
221
+ tool: toolCall.toolName,
222
+ toolCallId: toolCall.toolCallId,
223
+ errorText: outcome.errorText,
224
+ });
225
+ }
226
+ },
227
+ // The SDK predicate wrapped with the per-user-turn step cap: a capped turn
228
+ // stops cleanly with a visible note — never a silent stall (EC-334).
229
+ sendAutomaticallyWhen: withStepCap(lastAssistantMessageIsCompleteWithToolCalls, {
230
+ maxSteps: DEFAULT_MAX_STEPS,
231
+ onCap: () => dispatch({ type: 'fail', message: translate('ai.error.stepCap', { max: DEFAULT_MAX_STEPS }) }),
232
+ }),
233
+ onError: () => dispatch({ type: 'fail', message: translate('ai.error.route') }),
234
+ });
235
+ const busy = chat.status === 'submitted' || chat.status === 'streaming';
152
236
  const submit = useCallback(() => {
153
237
  const text = input.trim();
154
- if (text.length === 0 || state.status === 'busy' || !ready)
238
+ if (text.length === 0 || busy || !ready)
155
239
  return;
156
240
  setInput('');
157
- dispatch({ type: 'submit', text });
158
- void runTurn(text);
159
- }, [input, state.status, ready, runTurn]);
241
+ dispatch({ type: 'clear-error' });
242
+ void chat.sendMessage({ text });
243
+ }, [input, busy, ready, chat]);
244
+ // EC-380: clear / new topic — busy-safe (stop first), pending proposals
245
+ // discarded with reject semantics (the review state resets; nothing was applied).
246
+ const startNewConversation = useCallback(() => {
247
+ void clearConversation({ status: chat.status, stop: chat.stop }, () => {
248
+ dispatch({ type: 'clear' });
249
+ setInput('');
250
+ setConversation((current) => current + 1);
251
+ });
252
+ }, [chat.status, chat.stop]);
160
253
  const applyProposal = useCallback(async (proposal) => {
161
254
  if (projectId === undefined)
162
255
  return;
@@ -188,7 +281,7 @@ export function AiChatPanel() {
188
281
  }
189
282
  // Reflect in the session so the new draft shows in the list + is navigable.
190
283
  cms.saveDocument(doc);
191
- dispatch({ type: 'apply', proposalId: proposal.id });
284
+ dispatch({ type: 'apply', toolCallId: proposal.id });
192
285
  }, [projectId, cms]);
193
286
  const openProposal = useCallback((proposal) => {
194
287
  if (projectId === undefined)
@@ -198,14 +291,22 @@ export function AiChatPanel() {
198
291
  params: { projectId, _splat: studioPath.cmsDocument(proposal.collection, proposal.documentId) },
199
292
  });
200
293
  }, [navigate, projectId]);
201
- return (_jsxs("aside", { className: "flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card", children: [_jsxs(Row, { gap: "2", align: "center", className: "h-12 shrink-0 border-b border-border px-3", children: [_jsx(Heading, { level: 2, size: "h4", children: translate('ai.panel.title') }), _jsx(Spacer, {}), _jsx(Button, { size: "icon-sm", variant: "ghost", onClick: () => setAiPanelOpen(false), title: translate('ai.panel.close'), "aria-label": translate('ai.panel.close'), children: _jsx(X, {}) })] }), _jsxs(Conversation, { atBottomKey: state.messages, children: [state.messages.length === 0 ? _jsx(ConversationEmpty, {}) : null, state.messages.map((message) => {
202
- if (message.role === 'user')
203
- return _jsx(UserTurn, { text: message.text }, message.id);
204
- if (message.kind === 'text')
205
- return (_jsx(AssistantTurn, { children: _jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: message.text }) }, message.id));
206
- if (message.kind === 'activity')
207
- return (_jsx(AssistantTurn, { children: _jsx(ToolStatus, { steps: message.steps, active: state.status === 'busy' && state.activityId === message.id }) }, message.id));
208
- return (_jsx(ProposalCard, { proposal: message.proposal, onApply: () => void applyProposal(message.proposal), onDiscard: () => dispatch({ type: 'reject', proposalId: message.proposal.id }), onOpen: () => openProposal(message.proposal) }, message.id));
209
- })] }), ready ? (_jsx(PromptInput, { value: input, onChange: setInput, onSubmit: submit, disabled: state.status === 'busy' })) : (_jsx("div", { className: "border-t border-border p-3", children: _jsx(Text, { size: "xs", tone: "muted", children: translate('ai.panel.unavailable') }) }))] }));
294
+ const hasProposal = useCallback((toolCallId) => review.proposals[toolCallId] !== undefined, [review.proposals]);
295
+ const renderAssistantMessage = (message) => {
296
+ const segments = segmentMessageParts(message.parts, hasProposal);
297
+ return segments.map((segment, index) => {
298
+ if (segment.kind === 'text') {
299
+ return (_jsx(AssistantTurn, { children: _jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: segment.text }) }, `${message.id}-${index}`));
300
+ }
301
+ if (segment.kind === 'tools') {
302
+ return (_jsx(AssistantTurn, { children: _jsx(ToolStatus, { steps: segment.parts.map(describeToolPart) }) }, `${message.id}-${index}`));
303
+ }
304
+ const proposal = review.proposals[segment.toolCallId];
305
+ if (!proposal)
306
+ return null;
307
+ return (_jsx(ProposalCard, { proposal: proposal, onApply: () => void applyProposal(proposal), onDiscard: () => dispatch({ type: 'reject', toolCallId: proposal.id }), onOpen: () => openProposal(proposal) }, segment.toolCallId));
308
+ });
309
+ };
310
+ return (_jsxs("aside", { className: "flex h-full min-h-0 w-[360px] shrink-0 flex-col border-l border-border bg-card", children: [_jsxs(Row, { gap: "2", align: "center", className: "h-12 shrink-0 border-b border-border px-3", children: [_jsx(Heading, { level: 2, size: "h4", children: translate('ai.panel.title') }), _jsx(Spacer, {}), _jsx(Button, { size: "icon-sm", variant: "ghost", onClick: startNewConversation, disabled: chat.messages.length === 0 && review.error === null && !busy, title: translate('ai.panel.newConversation'), "aria-label": translate('ai.panel.newConversation'), children: _jsx(MessageSquarePlus, {}) }), _jsx(Button, { size: "icon-sm", variant: "ghost", onClick: () => setAiPanelOpen(false), title: translate('ai.panel.close'), "aria-label": translate('ai.panel.close'), children: _jsx(X, {}) })] }), _jsxs(Conversation, { atBottomKey: chat.messages, children: [chat.messages.length === 0 ? _jsx(ConversationEmpty, {}) : null, chat.messages.map((message) => message.role === 'user' ? (_jsx(UserTurn, { text: messageText(message) }, message.id)) : (_jsx("div", { className: "contents", children: renderAssistantMessage(message) }, message.id))), chat.status === 'submitted' ? (_jsx(AssistantTurn, { children: _jsx(WorkingIndicator, {}) })) : null, review.error !== null ? (_jsx(AssistantTurn, { children: _jsx(ChatErrorNote, { message: review.error }) })) : null] }), ready ? (_jsx(PromptInput, { value: input, onChange: setInput, onSubmit: submit, disabled: busy })) : (_jsx("div", { className: "border-t border-border p-3", children: _jsx(Text, { size: "xs", tone: "muted", children: translate('ai.panel.unavailable') }) }))] }));
210
311
  }
211
312
  //# sourceMappingURL=ai-chat-panel.js.map