@elytracms/studio 0.0.56 → 0.0.58
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 +7 -0
- 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 +199 -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 +139 -23
- package/dist/components/ai/chat-ui.js.map +1 -1
- package/dist/components/ai/proposal-card.d.ts +30 -7
- package/dist/components/ai/proposal-card.js +81 -21
- package/dist/components/ai/proposal-card.js.map +1 -1
- package/dist/components/cms/detail-editor.js +12 -0
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/document-diff-view.d.ts +12 -1
- package/dist/components/cms/document-diff-view.js +119 -42
- package/dist/components/cms/document-diff-view.js.map +1 -1
- package/dist/components/cms/rich-text-preview.d.ts +15 -0
- package/dist/components/cms/rich-text-preview.js +81 -0
- package/dist/components/cms/rich-text-preview.js.map +1 -0
- package/dist/components/cms/version-compare-text.d.ts +26 -0
- package/dist/components/cms/version-compare-text.js +60 -0
- package/dist/components/cms/version-compare-text.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/apply-content.js +52 -10
- package/dist/lib/ai/apply-content.js.map +1 -1
- package/dist/lib/ai/chat-context.d.ts +74 -0
- package/dist/lib/ai/chat-context.js +100 -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 +73 -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 +139 -6
- package/dist/lib/ai/fake-provider.js +261 -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 +5 -1
- package/dist/lib/ai/index.js +5 -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/rich-text-input.d.ts +27 -0
- package/dist/lib/ai/rich-text-input.js +25 -0
- package/dist/lib/ai/rich-text-input.js.map +1 -0
- 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/shape.d.ts +12 -1
- package/dist/lib/ai/shape.js +46 -20
- package/dist/lib/ai/shape.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +242 -37
- package/dist/lib/ai/tools.js +548 -63
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/ai/types.d.ts +31 -16
- package/dist/lib/ai/types.js.map +1 -1
- package/dist/lib/ai/update-fields.d.ts +68 -0
- package/dist/lib/ai/update-fields.js +296 -0
- package/dist/lib/ai/update-fields.js.map +1 -0
- package/dist/lib/ai-current-document.d.ts +53 -0
- package/dist/lib/ai-current-document.js +54 -0
- package/dist/lib/ai-current-document.js.map +1 -0
- package/dist/lib/auth/convex-auth-adapter.d.ts +9 -1
- package/dist/lib/auth/convex-auth-adapter.js +28 -2
- package/dist/lib/auth/convex-auth-adapter.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +6 -6
- package/dist/lib/i18n/messages.en.js +19 -1
- 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
|
@@ -20,12 +20,19 @@ type ConvexAuthProviders = Parameters<typeof convexAuth>[0]['providers']
|
|
|
20
20
|
* `ConvexError.data` across that redaction so the sign-in screen can tell the
|
|
21
21
|
* truth. Codes ONLY — never the raw library message (it can carry the account
|
|
22
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).
|
|
23
29
|
*/
|
|
24
30
|
export type StudioAuthErrorCode =
|
|
25
31
|
| 'invalid-credentials'
|
|
26
32
|
| 'too-many-attempts'
|
|
27
33
|
| 'account-exists'
|
|
28
34
|
| 'deployment-misconfigured'
|
|
35
|
+
| 'weak-password'
|
|
29
36
|
|
|
30
37
|
/**
|
|
31
38
|
* Map a `@convex-dev/auth` plain-`Error` message to a stable code — or `null`
|
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,64 @@
|
|
|
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, renderCurrentDocumentSection, resolveAiConfig, resolveChatTarget, resolveKnowledge, withStepCap, } from '@elytracms/studio/lib/ai';
|
|
13
|
+
import { getAiCurrentDocument } from '@elytracms/studio/lib/ai-current-document';
|
|
11
14
|
import { useCms } from '../cms/cms-store.js';
|
|
12
15
|
import { validateComposition, validateRichTextEmbeds } from '../cms/cms-composition-validators.js';
|
|
13
|
-
import { AssistantTurn, Conversation, ConversationEmpty, PromptInput, ToolStatus, UserTurn, } from './chat-ui.js';
|
|
16
|
+
import { AssistantTurn, ChatErrorNote, Conversation, ConversationEmpty, PromptInput, ToolStatus, UserTurn, WorkingIndicator, describeToolPart, messageText, segmentMessageParts, } from './chat-ui.js';
|
|
14
17
|
import { ProposalCard } from './proposal-card.js';
|
|
15
18
|
import { setAiPanelOpen } from '@elytracms/studio/lib/ai-panel';
|
|
16
19
|
/**
|
|
17
|
-
* The AI assistant chat panel (EC-199
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* The AI assistant chat panel (EC-199, moved onto the SDK's native protocol by
|
|
21
|
+
* EC-334) — the right-hand collapsible surface in the CMS area. `useChat` +
|
|
22
|
+
* `DefaultChatTransport` own the conversation against the route's native chat
|
|
23
|
+
* branch (visible token streaming); `onToolCall` dispatches into the client
|
|
24
|
+
* executor map (operations reads + terminal engines, all with the user's own
|
|
25
|
+
* session); `sendAutomaticallyWhen` is the SDK predicate wrapped with the
|
|
26
|
+
* per-turn step cap. What the SDK does not own lives in the pure review reducer:
|
|
27
|
+
* full proposals keyed by `toolCallId` (reviewed as a diff, applied as a DRAFT
|
|
28
|
+
* via operations upsert + a provenance-tagged snapshot — the EC-199 trust
|
|
29
|
+
* boundary, unmoved) and blocking errors. EC-380: the "new conversation"
|
|
30
|
+
* affordance clears busy-safely (stop → new chat id + review reset; pending
|
|
31
|
+
* proposals are discarded with reject semantics).
|
|
32
|
+
*
|
|
33
|
+
* NEVER a modal (house rule). The whole module (including `ai` + `@ai-sdk/react`)
|
|
34
|
+
* sits behind the lazy boundary (`ai-chat-panel-lazy`) — pinned in bundle tests.
|
|
24
35
|
*/
|
|
36
|
+
/** Adapt a `RouteHeaders` (static or thunk) to the transport's headers resolvable. */
|
|
37
|
+
function toTransportHeaders(headers) {
|
|
38
|
+
return async () => {
|
|
39
|
+
const resolved = typeof headers === 'function' ? await headers() : headers;
|
|
40
|
+
const record = {};
|
|
41
|
+
new Headers(resolved ?? {}).forEach((value, key) => {
|
|
42
|
+
record[key] = value;
|
|
43
|
+
});
|
|
44
|
+
return record;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
25
47
|
export function AiChatPanel() {
|
|
26
48
|
const cms = useCms();
|
|
27
49
|
const navigate = useNavigate();
|
|
28
|
-
const [
|
|
50
|
+
const [review, dispatch] = useReducer(chatReviewReducer, initialChatReviewState);
|
|
29
51
|
const [input, setInput] = useState('');
|
|
52
|
+
// EC-380: the conversation counter — bumping it mints a fresh `useChat` id
|
|
53
|
+
// (a brand-new conversation) and a fresh relation-candidate store.
|
|
54
|
+
const [conversation, setConversation] = useState(1);
|
|
30
55
|
const projectId = cms.projectId;
|
|
31
56
|
const activeLocale = cms.activeLocale;
|
|
32
57
|
// Resolve the AI platform config once (presence + capability gate). The panel only
|
|
33
58
|
// mounts when configured, but a misconfigured provider still fails loud here.
|
|
34
59
|
const resolved = useMemo(() => resolveAiConfig(resolveDeploymentAi()), []);
|
|
35
60
|
const provider = resolved.resolved?.provider ?? null;
|
|
36
|
-
const knowledgeSources = resolved.resolved?.knowledge ?? [];
|
|
61
|
+
const knowledgeSources = useMemo(() => resolved.resolved?.knowledge ?? [], [resolved]);
|
|
37
62
|
const maxRepairAttempts = resolved.resolved?.limits?.maxRepairAttempts;
|
|
38
63
|
const ready = provider !== null && resolved.ok && projectId !== undefined;
|
|
39
64
|
const registry = useMemo(() => createCollectionRegistry(cms.collections), [cms.collections]);
|
|
@@ -43,7 +68,7 @@ export function AiChatPanel() {
|
|
|
43
68
|
...(doc.localized?.[activeLocale] ?? {}),
|
|
44
69
|
}), [activeLocale]);
|
|
45
70
|
/** Operations-backed tool readers — client-side, with the user's own session. */
|
|
46
|
-
const
|
|
71
|
+
const readers = useMemo(() => {
|
|
47
72
|
const matches = (query, ...fields) => !query || fields.some((f) => f?.toLowerCase().includes(query.toLowerCase()));
|
|
48
73
|
return {
|
|
49
74
|
async searchDocuments({ collection, query }) {
|
|
@@ -74,7 +99,7 @@ export function AiChatPanel() {
|
|
|
74
99
|
.map((asset) => ({ id: asset.id, title: asset.title ?? asset.filename }))
|
|
75
100
|
.filter((r) => matches(query, r.title, r.id));
|
|
76
101
|
},
|
|
77
|
-
//
|
|
102
|
+
// Full document (draft values + localized) — the getDocument/translate tools.
|
|
78
103
|
async getDocument({ collection, id }) {
|
|
79
104
|
if (projectId === undefined)
|
|
80
105
|
return null;
|
|
@@ -110,57 +135,154 @@ export function AiChatPanel() {
|
|
|
110
135
|
})
|
|
111
136
|
.filter((item) => item.content.trim().length > 0);
|
|
112
137
|
}, [projectId, cms, localeValues]);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
138
|
+
// The knowledge snapshot the executors read — refreshed by the body thunk on
|
|
139
|
+
// every request, so the terminal engines see what the model saw this turn.
|
|
140
|
+
const knowledgeRef = useRef({ items: [] });
|
|
141
|
+
// The chat transport target: the configured route provider's endpoint +
|
|
142
|
+
// session-header thunk (EC-371), re-evaluated per request.
|
|
143
|
+
const chatTarget = useMemo(() => resolveChatTarget(provider), [provider]);
|
|
144
|
+
const transport = useMemo(() => new DefaultChatTransport({
|
|
145
|
+
api: chatTarget.endpoint,
|
|
146
|
+
headers: toTransportHeaders(chatTarget.headers),
|
|
147
|
+
// The `body` THUNK — re-evaluated per request (fresh context every turn,
|
|
148
|
+
// continuations included): collections-catalog summary + client-resolved
|
|
149
|
+
// knowledge + EC-378's current-document block (read from the seam store
|
|
150
|
+
// at REQUEST time, so every turn sees what is open NOW). The route folds
|
|
151
|
+
// these sections into the system prompt; it resolves no data itself
|
|
152
|
+
// (zero-data-access proxy). The block is advisory — the deterministic
|
|
153
|
+
// omitted-target fallback lives in the executor.
|
|
154
|
+
body: async () => {
|
|
117
155
|
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
|
-
|
|
156
|
+
knowledgeRef.current = knowledge;
|
|
157
|
+
const current = getAiCurrentDocument();
|
|
158
|
+
return {
|
|
159
|
+
context: buildChatContext({
|
|
160
|
+
collections: cms.collections,
|
|
161
|
+
knowledge,
|
|
162
|
+
...(current
|
|
163
|
+
? {
|
|
164
|
+
extraSections: [
|
|
165
|
+
renderCurrentDocumentSection({
|
|
166
|
+
collection: current.collection,
|
|
167
|
+
id: current.id,
|
|
168
|
+
title: current.title,
|
|
169
|
+
locale: activeLocale,
|
|
170
|
+
}),
|
|
171
|
+
],
|
|
172
|
+
}
|
|
173
|
+
: {}),
|
|
174
|
+
}),
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
}), [chatTarget, knowledgeSources, readCollection, cms.collections, activeLocale]);
|
|
178
|
+
// Per-conversation relation-candidate store (EC-380: replaced on clear, so a
|
|
179
|
+
// fresh conversation cannot reference the previous one's searched ids).
|
|
180
|
+
const candidatesRef = useRef({
|
|
181
|
+
conversation,
|
|
182
|
+
store: createChatCandidateStore(),
|
|
183
|
+
});
|
|
184
|
+
if (candidatesRef.current.conversation !== conversation) {
|
|
185
|
+
candidatesRef.current = { conversation, store: createChatCandidateStore() };
|
|
186
|
+
}
|
|
187
|
+
const candidateStore = candidatesRef.current.store;
|
|
188
|
+
const executor = useMemo(() => {
|
|
189
|
+
if (!provider)
|
|
190
|
+
return null;
|
|
191
|
+
return createChatToolExecutor({
|
|
192
|
+
provider,
|
|
193
|
+
registry,
|
|
194
|
+
collections: cms.collections,
|
|
195
|
+
readers,
|
|
196
|
+
mintDocumentId,
|
|
197
|
+
candidates: candidateStore,
|
|
198
|
+
onProposal: (toolCallId, proposal) => dispatch({ type: 'propose', toolCallId, proposal }),
|
|
199
|
+
knowledge: () => knowledgeRef.current,
|
|
200
|
+
// EC-378: the current-document seam, read at EXECUTION time — the
|
|
201
|
+
// deterministic fallback when the model omits a tool's target.
|
|
202
|
+
currentDocument: () => getAiCurrentDocument(),
|
|
203
|
+
locale: activeLocale,
|
|
204
|
+
...(cms.localeConfig ? { localeConfig: cms.localeConfig } : {}),
|
|
205
|
+
validateRichTextEmbeds,
|
|
206
|
+
validateComposition,
|
|
207
|
+
...(maxRepairAttempts !== undefined ? { maxRepairAttempts } : {}),
|
|
208
|
+
});
|
|
141
209
|
}, [
|
|
142
210
|
provider,
|
|
143
|
-
knowledgeSources,
|
|
144
|
-
readCollection,
|
|
145
|
-
tools,
|
|
146
211
|
registry,
|
|
147
212
|
cms.collections,
|
|
148
|
-
|
|
213
|
+
cms.localeConfig,
|
|
214
|
+
readers,
|
|
149
215
|
mintDocumentId,
|
|
216
|
+
activeLocale,
|
|
150
217
|
maxRepairAttempts,
|
|
218
|
+
candidateStore,
|
|
151
219
|
]);
|
|
220
|
+
const chat = useChat({
|
|
221
|
+
// A new conversation is a new chat id — `useChat` recreates its state.
|
|
222
|
+
id: `elytra-assistant-${conversation}`,
|
|
223
|
+
transport,
|
|
224
|
+
onToolCall: async ({ toolCall }) => {
|
|
225
|
+
if (!executor)
|
|
226
|
+
return;
|
|
227
|
+
const outcome = await executor.execute({
|
|
228
|
+
toolCallId: toolCall.toolCallId,
|
|
229
|
+
toolName: toolCall.toolName,
|
|
230
|
+
input: toolCall.input,
|
|
231
|
+
});
|
|
232
|
+
// Deliberately NOT awaited: `onToolCall` runs inside the chat's serial
|
|
233
|
+
// stream job, and `addToolOutput` enqueues on the same executor —
|
|
234
|
+
// awaiting it here deadlocks the stream (verified against ai@7.0.22).
|
|
235
|
+
if (outcome.ok) {
|
|
236
|
+
void chat.addToolOutput({
|
|
237
|
+
tool: toolCall.toolName,
|
|
238
|
+
toolCallId: toolCall.toolCallId,
|
|
239
|
+
output: outcome.output,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
void chat.addToolOutput({
|
|
244
|
+
state: 'output-error',
|
|
245
|
+
tool: toolCall.toolName,
|
|
246
|
+
toolCallId: toolCall.toolCallId,
|
|
247
|
+
errorText: outcome.errorText,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
// The SDK predicate wrapped with the per-user-turn step cap: a capped turn
|
|
252
|
+
// stops cleanly with a visible note — never a silent stall (EC-334).
|
|
253
|
+
sendAutomaticallyWhen: withStepCap(lastAssistantMessageIsCompleteWithToolCalls, {
|
|
254
|
+
maxSteps: DEFAULT_MAX_STEPS,
|
|
255
|
+
onCap: () => dispatch({ type: 'fail', message: translate('ai.error.stepCap', { max: DEFAULT_MAX_STEPS }) }),
|
|
256
|
+
}),
|
|
257
|
+
onError: () => dispatch({ type: 'fail', message: translate('ai.error.route') }),
|
|
258
|
+
});
|
|
259
|
+
const busy = chat.status === 'submitted' || chat.status === 'streaming';
|
|
152
260
|
const submit = useCallback(() => {
|
|
153
261
|
const text = input.trim();
|
|
154
|
-
if (text.length === 0 ||
|
|
262
|
+
if (text.length === 0 || busy || !ready)
|
|
155
263
|
return;
|
|
156
264
|
setInput('');
|
|
157
|
-
dispatch({ type: '
|
|
158
|
-
void
|
|
159
|
-
}, [input,
|
|
265
|
+
dispatch({ type: 'clear-error' });
|
|
266
|
+
void chat.sendMessage({ text });
|
|
267
|
+
}, [input, busy, ready, chat]);
|
|
268
|
+
// EC-380: clear / new topic — busy-safe (stop first), pending proposals
|
|
269
|
+
// discarded with reject semantics (the review state resets; nothing was applied).
|
|
270
|
+
const startNewConversation = useCallback(() => {
|
|
271
|
+
void clearConversation({ status: chat.status, stop: chat.stop }, () => {
|
|
272
|
+
dispatch({ type: 'clear' });
|
|
273
|
+
setInput('');
|
|
274
|
+
setConversation((current) => current + 1);
|
|
275
|
+
});
|
|
276
|
+
}, [chat.status, chat.stop]);
|
|
160
277
|
const applyProposal = useCallback(async (proposal) => {
|
|
161
278
|
if (projectId === undefined)
|
|
162
279
|
return;
|
|
280
|
+
// For an update/translate of an EXISTING document, spread the stored doc
|
|
281
|
+
// first so document-level extras (alias, defaultLocale) survive the apply —
|
|
282
|
+
// the patch replaces only values/localized.
|
|
283
|
+
const existing = cms.getDocument({ collection: proposal.collection, id: proposal.documentId });
|
|
163
284
|
const doc = {
|
|
285
|
+
...(existing ?? {}),
|
|
164
286
|
collection: proposal.collection,
|
|
165
287
|
id: proposal.documentId,
|
|
166
288
|
values: proposal.patch.values,
|
|
@@ -188,7 +310,16 @@ export function AiChatPanel() {
|
|
|
188
310
|
}
|
|
189
311
|
// Reflect in the session so the new draft shows in the list + is navigable.
|
|
190
312
|
cms.saveDocument(doc);
|
|
191
|
-
|
|
313
|
+
// EC-378: when the applied proposal targets the document OPEN in the editor,
|
|
314
|
+
// adopt it into the editor's draft buffer — otherwise the editor's autosave
|
|
315
|
+
// would write its stale buffer back and silently revert the applied change.
|
|
316
|
+
const current = getAiCurrentDocument();
|
|
317
|
+
if (current &&
|
|
318
|
+
current.collection === proposal.collection &&
|
|
319
|
+
current.id === proposal.documentId) {
|
|
320
|
+
current.adoptDraft(doc);
|
|
321
|
+
}
|
|
322
|
+
dispatch({ type: 'apply', toolCallId: proposal.id });
|
|
192
323
|
}, [projectId, cms]);
|
|
193
324
|
const openProposal = useCallback((proposal) => {
|
|
194
325
|
if (projectId === undefined)
|
|
@@ -198,14 +329,22 @@ export function AiChatPanel() {
|
|
|
198
329
|
params: { projectId, _splat: studioPath.cmsDocument(proposal.collection, proposal.documentId) },
|
|
199
330
|
});
|
|
200
331
|
}, [navigate, projectId]);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
332
|
+
const hasProposal = useCallback((toolCallId) => review.proposals[toolCallId] !== undefined, [review.proposals]);
|
|
333
|
+
const renderAssistantMessage = (message) => {
|
|
334
|
+
const segments = segmentMessageParts(message.parts, hasProposal);
|
|
335
|
+
return segments.map((segment, index) => {
|
|
336
|
+
if (segment.kind === 'text') {
|
|
337
|
+
return (_jsx(AssistantTurn, { children: _jsx(Text, { size: "sm", className: "whitespace-pre-wrap break-words", children: segment.text }) }, `${message.id}-${index}`));
|
|
338
|
+
}
|
|
339
|
+
if (segment.kind === 'tools') {
|
|
340
|
+
return (_jsx(AssistantTurn, { children: _jsx(ToolStatus, { steps: segment.parts.map(describeToolPart) }) }, `${message.id}-${index}`));
|
|
341
|
+
}
|
|
342
|
+
const proposal = review.proposals[segment.toolCallId];
|
|
343
|
+
if (!proposal)
|
|
344
|
+
return null;
|
|
345
|
+
return (_jsx(ProposalCard, { proposal: proposal, onApply: () => void applyProposal(proposal), onDiscard: () => dispatch({ type: 'reject', toolCallId: proposal.id }), onOpen: () => openProposal(proposal) }, segment.toolCallId));
|
|
346
|
+
});
|
|
347
|
+
};
|
|
348
|
+
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
349
|
}
|
|
211
350
|
//# sourceMappingURL=ai-chat-panel.js.map
|