@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.
- package/convex/auth.ts +152 -2
- package/convex/users.ts +41 -14
- package/dist/components/ai/ai-chat-panel-lazy.js +3 -3
- package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
- package/dist/components/ai/ai-chat-panel.d.ts +0 -9
- package/dist/components/ai/ai-chat-panel.js +161 -60
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/ai/chat-ui.d.ts +62 -10
- package/dist/components/ai/chat-ui.js +130 -23
- package/dist/components/ai/chat-ui.js.map +1 -1
- package/dist/lib/ai/anthropic-provider.d.ts +0 -12
- package/dist/lib/ai/anthropic-provider.js +3 -11
- package/dist/lib/ai/anthropic-provider.js.map +1 -1
- package/dist/lib/ai/chat-context.d.ts +58 -0
- package/dist/lib/ai/chat-context.js +71 -0
- package/dist/lib/ai/chat-context.js.map +1 -0
- package/dist/lib/ai/chat-loop.d.ts +70 -0
- package/dist/lib/ai/chat-loop.js +46 -0
- package/dist/lib/ai/chat-loop.js.map +1 -0
- package/dist/lib/ai/chat-state.d.ts +70 -57
- package/dist/lib/ai/chat-state.js +49 -90
- package/dist/lib/ai/chat-state.js.map +1 -1
- package/dist/lib/ai/fake-provider.d.ts +84 -6
- package/dist/lib/ai/fake-provider.js +193 -8
- package/dist/lib/ai/fake-provider.js.map +1 -1
- package/dist/lib/ai/gateway-provider.d.ts +30 -0
- package/dist/lib/ai/gateway-provider.js +73 -0
- package/dist/lib/ai/gateway-provider.js.map +1 -0
- package/dist/lib/ai/index.d.ts +3 -1
- package/dist/lib/ai/index.js +3 -1
- package/dist/lib/ai/index.js.map +1 -1
- package/dist/lib/ai/node-handler.d.ts +13 -4
- package/dist/lib/ai/node-handler.js +33 -3
- package/dist/lib/ai/node-handler.js.map +1 -1
- package/dist/lib/ai/route-provider.d.ts +35 -10
- package/dist/lib/ai/route-provider.js +13 -6
- package/dist/lib/ai/route-provider.js.map +1 -1
- package/dist/lib/ai/route.d.ts +50 -21
- package/dist/lib/ai/route.js +69 -40
- package/dist/lib/ai/route.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +169 -39
- package/dist/lib/ai/tools.js +308 -62
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/ai/types.d.ts +11 -16
- package/dist/lib/ai/types.js.map +1 -1
- package/dist/lib/auth/convex-auth-adapter.d.ts +37 -14
- package/dist/lib/auth/convex-auth-adapter.js +72 -16
- package/dist/lib/auth/convex-auth-adapter.js.map +1 -1
- package/dist/lib/i18n/messages.en.js +9 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/ui/badge.d.ts +1 -1
- package/package.json +4 -3
- package/dist/lib/ai/create-flow.d.ts +0 -132
- package/dist/lib/ai/create-flow.js +0 -280
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
7
|
-
* and everything it imports — the
|
|
8
|
-
*
|
|
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
|
|
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 {
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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 [
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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 ||
|
|
238
|
+
if (text.length === 0 || busy || !ready)
|
|
155
239
|
return;
|
|
156
240
|
setInput('');
|
|
157
|
-
dispatch({ type: '
|
|
158
|
-
void
|
|
159
|
-
}, [input,
|
|
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',
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|