@elytracms/studio 0.0.45 → 0.0.47

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 (74) hide show
  1. package/convex/cms.ts +1 -0
  2. package/convex/schema.ts +3 -0
  3. package/dist/components/ai/ai-chat-panel-lazy.d.ts +1 -0
  4. package/dist/components/ai/ai-chat-panel-lazy.js +21 -0
  5. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -0
  6. package/dist/components/ai/ai-chat-panel.d.ts +10 -0
  7. package/dist/components/ai/ai-chat-panel.js +211 -0
  8. package/dist/components/ai/ai-chat-panel.js.map +1 -0
  9. package/dist/components/ai/ai-cms-panel.d.ts +9 -0
  10. package/dist/components/ai/ai-cms-panel.js +24 -0
  11. package/dist/components/ai/ai-cms-panel.js.map +1 -0
  12. package/dist/components/ai/ai-panel-toggle.d.ts +8 -0
  13. package/dist/components/ai/ai-panel-toggle.js +27 -0
  14. package/dist/components/ai/ai-panel-toggle.js.map +1 -0
  15. package/dist/components/ai/chat-ui.d.ts +36 -0
  16. package/dist/components/ai/chat-ui.js +72 -0
  17. package/dist/components/ai/chat-ui.js.map +1 -0
  18. package/dist/components/ai/proposal-card.d.ts +15 -0
  19. package/dist/components/ai/proposal-card.js +49 -0
  20. package/dist/components/ai/proposal-card.js.map +1 -0
  21. package/dist/components/ai/translate-document-action.d.ts +14 -0
  22. package/dist/components/ai/translate-document-action.js +38 -0
  23. package/dist/components/ai/translate-document-action.js.map +1 -0
  24. package/dist/components/ai/translate-document-dialog.d.ts +5 -0
  25. package/dist/components/ai/translate-document-dialog.js +97 -0
  26. package/dist/components/ai/translate-document-dialog.js.map +1 -0
  27. package/dist/components/app-views/default-modules.js +2 -1
  28. package/dist/components/app-views/default-modules.js.map +1 -1
  29. package/dist/components/cms/detail-editor.js +5 -1
  30. package/dist/components/cms/detail-editor.js.map +1 -1
  31. package/dist/components/cms/document-diff-view.d.ts +41 -0
  32. package/dist/components/cms/document-diff-view.js +299 -0
  33. package/dist/components/cms/document-diff-view.js.map +1 -0
  34. package/dist/components/cms/version-compare.d.ts +7 -13
  35. package/dist/components/cms/version-compare.js +24 -365
  36. package/dist/components/cms/version-compare.js.map +1 -1
  37. package/dist/components/cms/versions-pane.js +1 -1
  38. package/dist/components/cms/versions-pane.js.map +1 -1
  39. package/dist/components/studio-shell.js +2 -1
  40. package/dist/components/studio-shell.js.map +1 -1
  41. package/dist/lib/ai/chat-state.d.ts +81 -0
  42. package/dist/lib/ai/chat-state.js +103 -0
  43. package/dist/lib/ai/chat-state.js.map +1 -0
  44. package/dist/lib/ai/create-flow.d.ts +132 -0
  45. package/dist/lib/ai/create-flow.js +280 -0
  46. package/dist/lib/ai/create-flow.js.map +1 -0
  47. package/dist/lib/ai/index.d.ts +6 -0
  48. package/dist/lib/ai/index.js +6 -0
  49. package/dist/lib/ai/index.js.map +1 -1
  50. package/dist/lib/ai/rich-text-translate.d.ts +45 -0
  51. package/dist/lib/ai/rich-text-translate.js +229 -0
  52. package/dist/lib/ai/rich-text-translate.js.map +1 -0
  53. package/dist/lib/ai/route-provider.d.ts +50 -0
  54. package/dist/lib/ai/route-provider.js +60 -0
  55. package/dist/lib/ai/route-provider.js.map +1 -0
  56. package/dist/lib/ai/route.d.ts +12 -0
  57. package/dist/lib/ai/route.js +42 -3
  58. package/dist/lib/ai/route.js.map +1 -1
  59. package/dist/lib/ai/tools.d.ts +77 -0
  60. package/dist/lib/ai/tools.js +74 -0
  61. package/dist/lib/ai/tools.js.map +1 -0
  62. package/dist/lib/ai/translate-document.d.ts +53 -0
  63. package/dist/lib/ai/translate-document.js +316 -0
  64. package/dist/lib/ai/translate-document.js.map +1 -0
  65. package/dist/lib/ai-panel.d.ts +20 -0
  66. package/dist/lib/ai-panel.js +48 -0
  67. package/dist/lib/ai-panel.js.map +1 -0
  68. package/dist/lib/cms-views/view-model.d.ts +3 -3
  69. package/dist/lib/i18n/messages.en.js +57 -0
  70. package/dist/lib/i18n/messages.en.js.map +1 -1
  71. package/dist/lib/persistence/convex-adapter.js +2 -0
  72. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  73. package/dist/ui/badge.d.ts +1 -1
  74. package/package.json +2 -2
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Structure-preserving rich-text translation (EC-332).
3
+ *
4
+ * The load-bearing invariant: **the model never emits node structure.** Each
5
+ * translatable block's inline content is serialized to a single plain string in which
6
+ * every text run is wrapped in an opaque placeholder token (`[[s{k}]]…[[/s{k}]]`) and
7
+ * every non-text inline atom (image, embed, componentEmbed, hardBreak, mention, any
8
+ * unknown inline node) is a self-closing token (`[[a{k}]]`). The model translates ONLY
9
+ * the human-readable text between tokens; on reconstruction each token is mapped back to
10
+ * the ORIGINAL stored mark stack / atom node — byte-for-byte — so marks, links, internal
11
+ * `attrs.ref` references, and embeds survive by construction. Only `text` leaves change.
12
+ *
13
+ * This ports the Sanity precedent's `blockToMarkdown` / `markdownToChildren` moves to
14
+ * Elytra's ProseMirror storage, but uses opaque placeholder tokens rather than literal
15
+ * markdown: Elytra marks (`strike`/`underline`/`link` + its `attrs.ref` envelope) do not
16
+ * round-trip through markdown losslessly, and reference-index integrity (EC-286) requires
17
+ * the `ref` bytes to be preserved exactly — which token-indexed restoration guarantees.
18
+ *
19
+ * Robust against a misbehaving translator: if a translated block's tokens don't parse
20
+ * back to a coherent structure, the ORIGINAL block is kept unchanged (never a broken
21
+ * tree). Determinism: given a deterministic segment transform the output is identical
22
+ * every run. See `rich-text-translate.test.ts` for the round-trip property tests.
23
+ */
24
+ import { createRichTextValue, parseRichTextValue } from '@elytracms/core/rich-text';
25
+ // --- Token vocabulary -------------------------------------------------------------
26
+ const spanOpen = (k) => `[[s${k}]]`;
27
+ const spanClose = (k) => `[[/s${k}]]`;
28
+ const atomToken = (k) => `[[a${k}]]`;
29
+ /** Matches every placeholder token: open/close spans and atoms. */
30
+ const TOKEN_RE = /\[\[(\/?)([sa])(\d+)\]\]/g;
31
+ /** True when a string contains any placeholder token (non-global; no shared state). */
32
+ function containsToken(value) {
33
+ return /\[\[\/?[sa]\d+\]\]/.test(value);
34
+ }
35
+ /** Key-order-insensitive canonical JSON for de-duplicating identical mark stacks. */
36
+ function canonicalJson(value) {
37
+ if (value === null || typeof value !== 'object')
38
+ return JSON.stringify(value) ?? 'null';
39
+ if (Array.isArray(value))
40
+ return `[${value.map(canonicalJson).join(',')}]`;
41
+ const entries = Object.entries(value)
42
+ .filter(([, v]) => v !== undefined)
43
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
44
+ .map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
45
+ return `{${entries.join(',')}}`;
46
+ }
47
+ /** True when a node is a text leaf (`{ type: 'text', text }`). */
48
+ function isTextNode(node) {
49
+ return node.type === 'text' && typeof node.text === 'string';
50
+ }
51
+ /** True when a node's direct children include a text leaf (⇒ it is an inline/text block). */
52
+ function hasDirectText(node) {
53
+ return Array.isArray(node.content) && node.content.some(isTextNode);
54
+ }
55
+ /** Encode one text-bearing block's inline content into a token string + tables. */
56
+ function encodeBlock(node) {
57
+ const markStacks = [];
58
+ const markIndex = new Map();
59
+ const atoms = [];
60
+ const stackKey = (marks) => {
61
+ const key = canonicalJson(marks ?? []);
62
+ const existing = markIndex.get(key);
63
+ if (existing !== undefined)
64
+ return existing;
65
+ const idx = markStacks.length;
66
+ markStacks.push(marks ? marks.map((m) => ({ ...m })) : []);
67
+ markIndex.set(key, idx);
68
+ return idx;
69
+ };
70
+ const parts = [];
71
+ for (const child of node.content ?? []) {
72
+ if (isTextNode(child)) {
73
+ const k = stackKey(child.marks);
74
+ parts.push(`${spanOpen(k)}${child.text ?? ''}${spanClose(k)}`);
75
+ }
76
+ else {
77
+ const k = atoms.length;
78
+ atoms.push(child);
79
+ parts.push(atomToken(k));
80
+ }
81
+ }
82
+ return { encoded: parts.join(''), markStacks, atoms };
83
+ }
84
+ /** True when an encoded block holds at least one non-whitespace translatable segment. */
85
+ function hasTranslatableText(encoded) {
86
+ return encoded.replace(TOKEN_RE, '').trim().length > 0;
87
+ }
88
+ /**
89
+ * Walk a rich-text value and extract its translatable blocks. Deterministic; never
90
+ * throws. A value that is not parseable rich text yields no units (and a plan whose
91
+ * rebuild is a no-op). Code blocks are skipped (code is not prose).
92
+ */
93
+ export function extractRichTextUnits(raw) {
94
+ const parsed = parseRichTextValue(raw);
95
+ const blocks = new Map();
96
+ const units = [];
97
+ if (!parsed.ok) {
98
+ return { units, plan: { value: { version: 1, doc: { type: 'doc', content: [] } }, blocks } };
99
+ }
100
+ const value = parsed.value;
101
+ const walk = (node, path) => {
102
+ if (node.type === 'codeBlock')
103
+ return;
104
+ if (hasDirectText(node)) {
105
+ const { encoded, markStacks, atoms } = encodeBlock(node);
106
+ if (hasTranslatableText(encoded)) {
107
+ const id = path.join('.');
108
+ blocks.set(id, { path, markStacks, atoms });
109
+ units.push({ id, text: encoded });
110
+ }
111
+ return;
112
+ }
113
+ node.content?.forEach((child, i) => walk(child, [...path, i]));
114
+ };
115
+ value.doc.content.forEach((node, i) => walk(node, [i]));
116
+ return { units, plan: { value, blocks } };
117
+ }
118
+ // --- Reconstruction ---------------------------------------------------------------
119
+ /**
120
+ * Parse an encoded (translated) block string back into inline nodes, or `null` on any
121
+ * structural mismatch. Strict on purpose: the block is only rewritten when the translated
122
+ * string is token-faithful — every atom token present exactly once, every span opened and
123
+ * closed, and no non-whitespace text stranded outside a span (which would mean a dropped
124
+ * formatting/reference token). Anything else keeps the ORIGINAL block, so an internal
125
+ * link, image ref, or embed can never be silently lost to a garbled translation.
126
+ */
127
+ function decodeBlock(translated, tables) {
128
+ const out = [];
129
+ const atomsUsed = new Set();
130
+ let cursor = 0;
131
+ const pushText = (text, marks) => {
132
+ if (text.length === 0)
133
+ return;
134
+ out.push(marks.length > 0 ? { type: 'text', text, marks: marks.map((m) => ({ ...m })) } : { type: 'text', text });
135
+ };
136
+ const re = new RegExp(TOKEN_RE.source, 'g');
137
+ let match;
138
+ while ((match = re.exec(translated)) !== null) {
139
+ const [token, closing, kind, digits] = match;
140
+ const index = Number(digits);
141
+ const before = translated.slice(cursor, match.index);
142
+ // Only whitespace may sit between/around tokens (a translator's added spacing);
143
+ // stranded words mean a formatting/reference token was dropped → reject.
144
+ if (before.trim().length > 0)
145
+ return null;
146
+ if (kind === 'a') {
147
+ const atom = tables.atoms[index];
148
+ if (!atom || atomsUsed.has(index))
149
+ return null;
150
+ atomsUsed.add(index);
151
+ out.push(atom);
152
+ cursor = match.index + token.length;
153
+ continue;
154
+ }
155
+ // kind === 's'
156
+ if (closing === '/')
157
+ return null; // an unmatched close — garbled structure
158
+ const marks = tables.markStacks[index];
159
+ if (!marks)
160
+ return null;
161
+ const close = spanClose(index);
162
+ const closeAt = translated.indexOf(close, re.lastIndex);
163
+ if (closeAt === -1)
164
+ return null;
165
+ const inner = translated.slice(re.lastIndex, closeAt);
166
+ // Inner must not itself contain tokens (spans never nest in our encoding).
167
+ if (containsToken(inner))
168
+ return null;
169
+ pushText(inner, marks);
170
+ cursor = closeAt + close.length;
171
+ re.lastIndex = cursor;
172
+ }
173
+ if (translated.slice(cursor).trim().length > 0)
174
+ return null;
175
+ // Every atom the block carried (image/embed/ref) must survive the translation.
176
+ if (atomsUsed.size !== tables.atoms.length)
177
+ return null;
178
+ return out;
179
+ }
180
+ /** Deep-clone a node array and replace the inline content at `path` with `content`. */
181
+ function replaceAtPath(nodes, path, content) {
182
+ const [head, ...rest] = path;
183
+ return nodes.map((node, i) => {
184
+ if (i !== head)
185
+ return node;
186
+ if (rest.length === 0)
187
+ return { ...node, content };
188
+ return { ...node, content: replaceAtPath(node.content ?? [], rest, content) };
189
+ });
190
+ }
191
+ /**
192
+ * Rebuild a rich-text value from a plan + a per-block translation map. A block absent
193
+ * from `translations` (skipped/aborted) or whose translated tokens don't parse keeps its
194
+ * ORIGINAL content — never a broken structure. Returns a fresh `{ version, doc }` value.
195
+ */
196
+ export function rebuildRichText(plan, translations) {
197
+ let content = plan.value.doc.content.map((n) => n);
198
+ for (const [id, tables] of plan.blocks) {
199
+ const translated = translations.get(id);
200
+ if (translated === undefined)
201
+ continue;
202
+ const decoded = decodeBlock(translated, tables);
203
+ if (!decoded)
204
+ continue;
205
+ content = replaceAtPath(content, tables.path, decoded);
206
+ }
207
+ const doc = { type: 'doc', content };
208
+ return createRichTextValue(doc);
209
+ }
210
+ // --- Deterministic segment transform (FakeProvider + tests) -----------------------
211
+ /**
212
+ * Apply `fn` to only the human-readable text segments of an encoded block (the runs
213
+ * between placeholder tokens), leaving every token exactly in place. This is how the
214
+ * deterministic FakeProvider "translates" a block and how the property tests apply a
215
+ * real text change without ever touching structure. Pure.
216
+ */
217
+ export function mapEncodedSegments(encoded, fn) {
218
+ const re = new RegExp(TOKEN_RE.source, 'g');
219
+ let out = '';
220
+ let cursor = 0;
221
+ let match;
222
+ while ((match = re.exec(encoded)) !== null) {
223
+ out += fn(encoded.slice(cursor, match.index)) + match[0];
224
+ cursor = match.index + match[0].length;
225
+ }
226
+ out += fn(encoded.slice(cursor));
227
+ return out;
228
+ }
229
+ //# sourceMappingURL=rich-text-translate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rich-text-translate.js","sourceRoot":"","sources":["../../../src/lib/ai/rich-text-translate.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Structure-preserving rich-text translation (EC-332).\n *\n * The load-bearing invariant: **the model never emits node structure.** Each\n * translatable block's inline content is serialized to a single plain string in which\n * every text run is wrapped in an opaque placeholder token (`[[s{k}]]…[[/s{k}]]`) and\n * every non-text inline atom (image, embed, componentEmbed, hardBreak, mention, any\n * unknown inline node) is a self-closing token (`[[a{k}]]`). The model translates ONLY\n * the human-readable text between tokens; on reconstruction each token is mapped back to\n * the ORIGINAL stored mark stack / atom node — byte-for-byte — so marks, links, internal\n * `attrs.ref` references, and embeds survive by construction. Only `text` leaves change.\n *\n * This ports the Sanity precedent's `blockToMarkdown` / `markdownToChildren` moves to\n * Elytra's ProseMirror storage, but uses opaque placeholder tokens rather than literal\n * markdown: Elytra marks (`strike`/`underline`/`link` + its `attrs.ref` envelope) do not\n * round-trip through markdown losslessly, and reference-index integrity (EC-286) requires\n * the `ref` bytes to be preserved exactly — which token-indexed restoration guarantees.\n *\n * Robust against a misbehaving translator: if a translated block's tokens don't parse\n * back to a coherent structure, the ORIGINAL block is kept unchanged (never a broken\n * tree). Determinism: given a deterministic segment transform the output is identical\n * every run. See `rich-text-translate.test.ts` for the round-trip property tests.\n */\nimport { createRichTextValue, parseRichTextValue } from '@elytracms/core/rich-text'\nimport type {\n RichTextDoc,\n RichTextMark,\n RichTextNode,\n RichTextValue,\n} from '@elytracms/core/rich-text'\n\n// --- Token vocabulary -------------------------------------------------------------\n\nconst spanOpen = (k: number): string => `[[s${k}]]`\nconst spanClose = (k: number): string => `[[/s${k}]]`\nconst atomToken = (k: number): string => `[[a${k}]]`\n\n/** Matches every placeholder token: open/close spans and atoms. */\nconst TOKEN_RE = /\\[\\[(\\/?)([sa])(\\d+)\\]\\]/g\n\n/** True when a string contains any placeholder token (non-global; no shared state). */\nfunction containsToken(value: string): boolean {\n return /\\[\\[\\/?[sa]\\d+\\]\\]/.test(value)\n}\n\n/** Key-order-insensitive canonical JSON for de-duplicating identical mark stacks. */\nfunction canonicalJson(value: unknown): string {\n if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'\n if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`\n const entries = Object.entries(value as Record<string, unknown>)\n .filter(([, v]) => v !== undefined)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`)\n return `{${entries.join(',')}}`\n}\n\n// --- Block-unit model -------------------------------------------------------------\n\n/** The per-block side tables the rebuild uses to restore marks + atoms verbatim. */\ninterface BlockTables {\n /** Path (content-index chain) to the text-bearing node within the doc. */\n path: number[]\n /** De-duplicated mark stacks; a span token's index selects one. */\n markStacks: RichTextMark[][]\n /** Inline atom nodes (image/embed/componentEmbed/hardBreak/…), preserved verbatim. */\n atoms: RichTextNode[]\n}\n\n/** One translatable rich-text block: an id stable within the field + its encoded text. */\nexport interface RichTextBlockUnit {\n /** Stable within the field (the block's content-index path, dot-joined). */\n id: string\n /** The encoded translatable string (text runs + placeholder tokens). */\n text: string\n}\n\n/** The extraction plan: the original value + every block's restoration tables. */\nexport interface RichTextPlan {\n value: RichTextValue\n blocks: Map<string, BlockTables>\n}\n\n/** True when a node is a text leaf (`{ type: 'text', text }`). */\nfunction isTextNode(node: RichTextNode): boolean {\n return node.type === 'text' && typeof node.text === 'string'\n}\n\n/** True when a node's direct children include a text leaf (⇒ it is an inline/text block). */\nfunction hasDirectText(node: RichTextNode): boolean {\n return Array.isArray(node.content) && node.content.some(isTextNode)\n}\n\n/** Encode one text-bearing block's inline content into a token string + tables. */\nfunction encodeBlock(node: RichTextNode): { encoded: string; markStacks: RichTextMark[][]; atoms: RichTextNode[] } {\n const markStacks: RichTextMark[][] = []\n const markIndex = new Map<string, number>()\n const atoms: RichTextNode[] = []\n const stackKey = (marks: RichTextMark[] | undefined): number => {\n const key = canonicalJson(marks ?? [])\n const existing = markIndex.get(key)\n if (existing !== undefined) return existing\n const idx = markStacks.length\n markStacks.push(marks ? marks.map((m) => ({ ...m })) : [])\n markIndex.set(key, idx)\n return idx\n }\n const parts: string[] = []\n for (const child of node.content ?? []) {\n if (isTextNode(child)) {\n const k = stackKey(child.marks)\n parts.push(`${spanOpen(k)}${child.text ?? ''}${spanClose(k)}`)\n } else {\n const k = atoms.length\n atoms.push(child)\n parts.push(atomToken(k))\n }\n }\n return { encoded: parts.join(''), markStacks, atoms }\n}\n\n/** True when an encoded block holds at least one non-whitespace translatable segment. */\nfunction hasTranslatableText(encoded: string): boolean {\n return encoded.replace(TOKEN_RE, '').trim().length > 0\n}\n\n/**\n * Walk a rich-text value and extract its translatable blocks. Deterministic; never\n * throws. A value that is not parseable rich text yields no units (and a plan whose\n * rebuild is a no-op). Code blocks are skipped (code is not prose).\n */\nexport function extractRichTextUnits(raw: unknown): { units: RichTextBlockUnit[]; plan: RichTextPlan } {\n const parsed = parseRichTextValue(raw)\n const blocks = new Map<string, BlockTables>()\n const units: RichTextBlockUnit[] = []\n if (!parsed.ok) {\n return { units, plan: { value: { version: 1, doc: { type: 'doc', content: [] } }, blocks } }\n }\n const value = parsed.value\n\n const walk = (node: RichTextNode, path: number[]): void => {\n if (node.type === 'codeBlock') return\n if (hasDirectText(node)) {\n const { encoded, markStacks, atoms } = encodeBlock(node)\n if (hasTranslatableText(encoded)) {\n const id = path.join('.')\n blocks.set(id, { path, markStacks, atoms })\n units.push({ id, text: encoded })\n }\n return\n }\n node.content?.forEach((child, i) => walk(child, [...path, i]))\n }\n\n value.doc.content.forEach((node, i) => walk(node, [i]))\n return { units, plan: { value, blocks } }\n}\n\n// --- Reconstruction ---------------------------------------------------------------\n\n/**\n * Parse an encoded (translated) block string back into inline nodes, or `null` on any\n * structural mismatch. Strict on purpose: the block is only rewritten when the translated\n * string is token-faithful — every atom token present exactly once, every span opened and\n * closed, and no non-whitespace text stranded outside a span (which would mean a dropped\n * formatting/reference token). Anything else keeps the ORIGINAL block, so an internal\n * link, image ref, or embed can never be silently lost to a garbled translation.\n */\nfunction decodeBlock(translated: string, tables: BlockTables): RichTextNode[] | null {\n const out: RichTextNode[] = []\n const atomsUsed = new Set<number>()\n let cursor = 0\n const pushText = (text: string, marks: RichTextMark[]): void => {\n if (text.length === 0) return\n out.push(marks.length > 0 ? { type: 'text', text, marks: marks.map((m) => ({ ...m })) } : { type: 'text', text })\n }\n const re = new RegExp(TOKEN_RE.source, 'g')\n let match: RegExpExecArray | null\n while ((match = re.exec(translated)) !== null) {\n const [token, closing, kind, digits] = match\n const index = Number(digits)\n const before = translated.slice(cursor, match.index)\n // Only whitespace may sit between/around tokens (a translator's added spacing);\n // stranded words mean a formatting/reference token was dropped → reject.\n if (before.trim().length > 0) return null\n if (kind === 'a') {\n const atom = tables.atoms[index]\n if (!atom || atomsUsed.has(index)) return null\n atomsUsed.add(index)\n out.push(atom)\n cursor = match.index + token.length\n continue\n }\n // kind === 's'\n if (closing === '/') return null // an unmatched close — garbled structure\n const marks = tables.markStacks[index]\n if (!marks) return null\n const close = spanClose(index)\n const closeAt = translated.indexOf(close, re.lastIndex)\n if (closeAt === -1) return null\n const inner = translated.slice(re.lastIndex, closeAt)\n // Inner must not itself contain tokens (spans never nest in our encoding).\n if (containsToken(inner)) return null\n pushText(inner, marks)\n cursor = closeAt + close.length\n re.lastIndex = cursor\n }\n if (translated.slice(cursor).trim().length > 0) return null\n // Every atom the block carried (image/embed/ref) must survive the translation.\n if (atomsUsed.size !== tables.atoms.length) return null\n return out\n}\n\n/** Deep-clone a node array and replace the inline content at `path` with `content`. */\nfunction replaceAtPath(nodes: RichTextNode[], path: number[], content: RichTextNode[]): RichTextNode[] {\n const [head, ...rest] = path\n return nodes.map((node, i) => {\n if (i !== head) return node\n if (rest.length === 0) return { ...node, content }\n return { ...node, content: replaceAtPath(node.content ?? [], rest, content) }\n })\n}\n\n/**\n * Rebuild a rich-text value from a plan + a per-block translation map. A block absent\n * from `translations` (skipped/aborted) or whose translated tokens don't parse keeps its\n * ORIGINAL content — never a broken structure. Returns a fresh `{ version, doc }` value.\n */\nexport function rebuildRichText(\n plan: RichTextPlan,\n translations: ReadonlyMap<string, string>,\n): RichTextValue {\n let content = plan.value.doc.content.map((n) => n)\n for (const [id, tables] of plan.blocks) {\n const translated = translations.get(id)\n if (translated === undefined) continue\n const decoded = decodeBlock(translated, tables)\n if (!decoded) continue\n content = replaceAtPath(content, tables.path, decoded)\n }\n const doc: RichTextDoc = { type: 'doc', content }\n return createRichTextValue(doc)\n}\n\n// --- Deterministic segment transform (FakeProvider + tests) -----------------------\n\n/**\n * Apply `fn` to only the human-readable text segments of an encoded block (the runs\n * between placeholder tokens), leaving every token exactly in place. This is how the\n * deterministic FakeProvider \"translates\" a block and how the property tests apply a\n * real text change without ever touching structure. Pure.\n */\nexport function mapEncodedSegments(encoded: string, fn: (segment: string) => string): string {\n const re = new RegExp(TOKEN_RE.source, 'g')\n let out = ''\n let cursor = 0\n let match: RegExpExecArray | null\n while ((match = re.exec(encoded)) !== null) {\n out += fn(encoded.slice(cursor, match.index)) + match[0]\n cursor = match.index + match[0].length\n }\n out += fn(encoded.slice(cursor))\n return out\n}\n"]}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `createRouteProvider` — the CLIENT-side {@link AiProvider} the studio runs in the
3
+ * browser (EC-199). It is a thin proxy that forwards `generateStructured` /
4
+ * `streamChat` to the host's mounted {@link createAiRouteHandler} endpoint over
5
+ * `fetch`. The provider KEY never touches the browser: the key-holding
6
+ * {@link createAnthropicProvider} lives only inside the server route; the studio's
7
+ * `defineStudio({ ai: { provider } })` gets this route provider (or the FakeProvider
8
+ * for a keyless playground), so the whole authoring loop —
9
+ * `applyContentWithShape` included — proxies its model calls through the session-gated
10
+ * route while the tools (search / list / apply) execute client-side with the user's
11
+ * own session.
12
+ *
13
+ * Bundle-safe by construction: this module imports nothing from `ai` / `@ai-sdk/*`
14
+ * (it only uses `fetch`), so it stays off the AI-SDK dependency edge the bundle test
15
+ * pins — the route provider can sit on the studio critical path without pulling the
16
+ * SDK. The capability gate passes: the route only ever fronts a gate-checked provider
17
+ * (validated at the server mount), so the proxy declares both required capabilities.
18
+ */
19
+ import type { AiProvider } from './types';
20
+ /** How extra request headers are supplied — a static set or a (possibly async) thunk. */
21
+ export type RouteHeaders = HeadersInit | (() => HeadersInit | undefined | Promise<HeadersInit | undefined>);
22
+ export interface RouteProviderOptions {
23
+ /** The mounted route path/URL (default `/api/ai`). */
24
+ endpoint?: string;
25
+ /** `fetch` implementation (default the global). Injected in tests. */
26
+ fetchImpl?: typeof fetch;
27
+ /**
28
+ * Extra headers per request — e.g. the studio session token the route's `getSession`
29
+ * checks. A thunk is re-evaluated on every call so a refreshed token is picked up.
30
+ */
31
+ headers?: RouteHeaders;
32
+ /** Provider id recorded in provenance (default `route`). */
33
+ id?: string;
34
+ /** Model id recorded in provenance (the downstream model, when the host fixes one). */
35
+ model?: string;
36
+ }
37
+ /** Default mount path a consuming studio app exposes for {@link createAiRouteHandler}. */
38
+ export declare const DEFAULT_AI_ENDPOINT = "/api/ai";
39
+ /** A structured error thrown when the route rejects (surfaced by the chat UI). */
40
+ export declare class AiRouteError extends Error {
41
+ readonly status: number;
42
+ readonly code: string;
43
+ constructor(status: number, code: string);
44
+ }
45
+ /**
46
+ * Build a route-backed {@link AiProvider}. Deterministic given a deterministic route
47
+ * (so the FakeProvider mounted behind the route drives the whole client loop in the
48
+ * playground). Never holds a key; never imports the AI SDK.
49
+ */
50
+ export declare function createRouteProvider(options?: RouteProviderOptions): AiProvider;
@@ -0,0 +1,60 @@
1
+ /** Default mount path a consuming studio app exposes for {@link createAiRouteHandler}. */
2
+ export const DEFAULT_AI_ENDPOINT = '/api/ai';
3
+ async function resolveHeaders(headers) {
4
+ if (typeof headers === 'function')
5
+ return headers();
6
+ return headers;
7
+ }
8
+ /** A structured error thrown when the route rejects (surfaced by the chat UI). */
9
+ export class AiRouteError extends Error {
10
+ status;
11
+ code;
12
+ constructor(status, code) {
13
+ super(`AI route request failed (${status}: ${code})`);
14
+ this.status = status;
15
+ this.code = code;
16
+ this.name = 'AiRouteError';
17
+ }
18
+ }
19
+ async function errorCode(response) {
20
+ try {
21
+ const body = (await response.json());
22
+ return typeof body.error === 'string' ? body.error : 'request-failed';
23
+ }
24
+ catch {
25
+ return 'request-failed';
26
+ }
27
+ }
28
+ /**
29
+ * Build a route-backed {@link AiProvider}. Deterministic given a deterministic route
30
+ * (so the FakeProvider mounted behind the route drives the whole client loop in the
31
+ * playground). Never holds a key; never imports the AI SDK.
32
+ */
33
+ export function createRouteProvider(options = {}) {
34
+ const endpoint = options.endpoint ?? DEFAULT_AI_ENDPOINT;
35
+ const fetchImpl = options.fetchImpl ?? fetch;
36
+ async function post(body) {
37
+ const extra = await resolveHeaders(options.headers);
38
+ const headers = new Headers(extra);
39
+ headers.set('content-type', 'application/json');
40
+ return fetchImpl(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
41
+ }
42
+ return {
43
+ id: options.id ?? 'route',
44
+ ...(options.model !== undefined ? { model: options.model } : {}),
45
+ capabilities: () => ({ toolCalling: true, structuredOutput: true }),
46
+ async generateStructured(request) {
47
+ const response = await post({ kind: 'generate', request });
48
+ if (!response.ok)
49
+ throw new AiRouteError(response.status, await errorCode(response));
50
+ return (await response.json());
51
+ },
52
+ async streamChat(request) {
53
+ const response = await post({ kind: 'chat', ...request });
54
+ if (!response.ok)
55
+ throw new AiRouteError(response.status, await errorCode(response));
56
+ return response;
57
+ },
58
+ };
59
+ }
60
+ //# sourceMappingURL=route-provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"route-provider.js","sourceRoot":"","sources":["../../../src/lib/ai/route-provider.ts"],"names":[],"mappings":"AA+CA,0FAA0F;AAC1F,MAAM,CAAC,MAAM,mBAAmB,GAAG,SAAS,CAAA;AAE5C,KAAK,UAAU,cAAc,CAAC,OAAiC;IAC7D,IAAI,OAAO,OAAO,KAAK,UAAU;QAAE,OAAO,OAAO,EAAE,CAAA;IACnD,OAAO,OAAO,CAAA;AAChB,CAAC;AAED,kFAAkF;AAClF,MAAM,OAAO,YAAa,SAAQ,KAAK;IAE1B;IACA;IAFX,YACW,MAAc,EACd,IAAY;QAErB,KAAK,CAAC,4BAA4B,MAAM,KAAK,IAAI,GAAG,CAAC,CAAA;QAH5C,WAAM,GAAN,MAAM,CAAQ;QACd,SAAI,GAAJ,IAAI,CAAQ;QAGrB,IAAI,CAAC,IAAI,GAAG,cAAc,CAAA;IAC5B,CAAC;CACF;AAED,KAAK,UAAU,SAAS,CAAC,QAAkB;IACzC,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAwB,CAAA;QAC3D,OAAO,OAAO,IAAI,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,gBAAgB,CAAA;IACvE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,gBAAgB,CAAA;IACzB,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,UAAgC,EAAE;IACpE,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,mBAAmB,CAAA;IACxD,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,IAAI,KAAK,CAAA;IAE5C,KAAK,UAAU,IAAI,CAAC,IAAa;QAC/B,MAAM,KAAK,GAAG,MAAM,cAAc,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;QACnD,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,KAAK,CAAC,CAAA;QAClC,OAAO,CAAC,GAAG,CAAC,cAAc,EAAE,kBAAkB,CAAC,CAAA;QAC/C,OAAO,SAAS,CAAC,QAAQ,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IACrF,CAAC;IAED,OAAO;QACL,EAAE,EAAE,OAAO,CAAC,EAAE,IAAI,OAAO;QACzB,GAAG,CAAC,OAAO,CAAC,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAChE,YAAY,EAAE,GAA2B,EAAE,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,gBAAgB,EAAE,IAAI,EAAE,CAAC;QAE3F,KAAK,CAAC,kBAAkB,CACtB,OAAkC;YAElC,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,CAAC,CAAA;YAC1D,IAAI,CAAC,QAAQ,CAAC,EAAE;gBAAE,MAAM,IAAI,YAAY,CAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,SAAS,CAAC,QAAQ,CAAC,CAAC,CAAA;YACpF,OAAO,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAA6B,CAAA;QAC5D,CAAC;QAED,KAAK,CAAC,UAAU,CAAC,OAA0B;YACzC,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,EAAE,CAAC,CAAA;YACzD,IAAI,CAAC,QAAQ,CAAC,EAAE;gBAAE,MAAM,IAAI,YAAY,CAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,SAAS,CAAC,QAAQ,CAAC,CAAC,CAAA;YACpF,OAAO,QAAQ,CAAA;QACjB,CAAC;KACF,CAAA;AACH,CAAC","sourcesContent":["/**\n * `createRouteProvider` — the CLIENT-side {@link AiProvider} the studio runs in the\n * browser (EC-199). It is a thin proxy that forwards `generateStructured` /\n * `streamChat` to the host's mounted {@link createAiRouteHandler} endpoint over\n * `fetch`. The provider KEY never touches the browser: the key-holding\n * {@link createAnthropicProvider} lives only inside the server route; the studio's\n * `defineStudio({ ai: { provider } })` gets this route provider (or the FakeProvider\n * for a keyless playground), so the whole authoring loop —\n * `applyContentWithShape` included — proxies its model calls through the session-gated\n * route while the tools (search / list / apply) execute client-side with the user's\n * own session.\n *\n * Bundle-safe by construction: this module imports nothing from `ai` / `@ai-sdk/*`\n * (it only uses `fetch`), so it stays off the AI-SDK dependency edge the bundle test\n * pins — the route provider can sit on the studio critical path without pulling the\n * SDK. The capability gate passes: the route only ever fronts a gate-checked provider\n * (validated at the server mount), so the proxy declares both required capabilities.\n */\nimport type {\n AiProvider,\n AiProviderCapabilities,\n GenerateStructuredRequest,\n GenerateStructuredResult,\n StreamChatRequest,\n} from './types'\n\n/** How extra request headers are supplied — a static set or a (possibly async) thunk. */\nexport type RouteHeaders =\n | HeadersInit\n | (() => HeadersInit | undefined | Promise<HeadersInit | undefined>)\n\nexport interface RouteProviderOptions {\n /** The mounted route path/URL (default `/api/ai`). */\n endpoint?: string\n /** `fetch` implementation (default the global). Injected in tests. */\n fetchImpl?: typeof fetch\n /**\n * Extra headers per request — e.g. the studio session token the route's `getSession`\n * checks. A thunk is re-evaluated on every call so a refreshed token is picked up.\n */\n headers?: RouteHeaders\n /** Provider id recorded in provenance (default `route`). */\n id?: string\n /** Model id recorded in provenance (the downstream model, when the host fixes one). */\n model?: string\n}\n\n/** Default mount path a consuming studio app exposes for {@link createAiRouteHandler}. */\nexport const DEFAULT_AI_ENDPOINT = '/api/ai'\n\nasync function resolveHeaders(headers: RouteHeaders | undefined): Promise<HeadersInit | undefined> {\n if (typeof headers === 'function') return headers()\n return headers\n}\n\n/** A structured error thrown when the route rejects (surfaced by the chat UI). */\nexport class AiRouteError extends Error {\n constructor(\n readonly status: number,\n readonly code: string,\n ) {\n super(`AI route request failed (${status}: ${code})`)\n this.name = 'AiRouteError'\n }\n}\n\nasync function errorCode(response: Response): Promise<string> {\n try {\n const body = (await response.json()) as { error?: unknown }\n return typeof body.error === 'string' ? body.error : 'request-failed'\n } catch {\n return 'request-failed'\n }\n}\n\n/**\n * Build a route-backed {@link AiProvider}. Deterministic given a deterministic route\n * (so the FakeProvider mounted behind the route drives the whole client loop in the\n * playground). Never holds a key; never imports the AI SDK.\n */\nexport function createRouteProvider(options: RouteProviderOptions = {}): AiProvider {\n const endpoint = options.endpoint ?? DEFAULT_AI_ENDPOINT\n const fetchImpl = options.fetchImpl ?? fetch\n\n async function post(body: unknown): Promise<Response> {\n const extra = await resolveHeaders(options.headers)\n const headers = new Headers(extra)\n headers.set('content-type', 'application/json')\n return fetchImpl(endpoint, { method: 'POST', headers, body: JSON.stringify(body) })\n }\n\n return {\n id: options.id ?? 'route',\n ...(options.model !== undefined ? { model: options.model } : {}),\n capabilities: (): AiProviderCapabilities => ({ toolCalling: true, structuredOutput: true }),\n\n async generateStructured(\n request: GenerateStructuredRequest,\n ): Promise<GenerateStructuredResult> {\n const response = await post({ kind: 'generate', request })\n if (!response.ok) throw new AiRouteError(response.status, await errorCode(response))\n return (await response.json()) as GenerateStructuredResult\n },\n\n async streamChat(request: StreamChatRequest): Promise<Response> {\n const response = await post({ kind: 'chat', ...request })\n if (!response.ok) throw new AiRouteError(response.status, await errorCode(response))\n return response\n },\n }\n}\n"]}
@@ -13,8 +13,20 @@
13
13
  * Session-gated: `getSession` (host-wired, reading its own auth — this module never
14
14
  * touches `lib/auth`) must return a session for authenticated callers; anonymous
15
15
  * callers are rejected. Plus a soft, in-memory per-project rate counter (spend guard).
16
+ *
17
+ * EC-199: the route serves TWO model operations — both thin proxies, neither with any
18
+ * data access — discriminated by the request body's `kind`:
19
+ * - `{ kind: 'chat', messages, system? }` → `provider.streamChat` (text stream);
20
+ * - `{ kind: 'generate', request }` → `provider.generateStructured` (JSON).
21
+ * The structured mode is what lets `applyContentWithShape` run CLIENT-side against a
22
+ * {@link createRouteProvider} whose key never touches the browser — the creation flow's
23
+ * whole authoring loop proxies its model calls here while the tools (search/list/apply)
24
+ * execute in the client with the user's own session. A body with `messages` and no
25
+ * `kind` is still treated as a chat request (back-compat with the phase-1 contract).
16
26
  */
17
27
  import type { AiRouteHandler, AiRouteHandlerOptions } from './types';
28
+ /** The wire discriminator selecting which model operation the route proxies. */
29
+ export type AiRouteKind = 'chat' | 'generate';
18
30
  /**
19
31
  * Build the mounted route handler. The returned function is a plain
20
32
  * `(Request) => Promise<Response>` — session-gated, soft-rate-limited, and a thin
@@ -24,6 +24,29 @@ function parseChatBody(body) {
24
24
  ...(typeof system === 'string' ? { system } : {}),
25
25
  };
26
26
  }
27
+ /** Structurally validate a {@link GenerateStructuredRequest}, or `null` when malformed. */
28
+ function parseGenerateBody(body) {
29
+ if (typeof body !== 'object' || body === null)
30
+ return null;
31
+ const request = body.request;
32
+ if (typeof request !== 'object' || request === null)
33
+ return null;
34
+ const { prompt, schema } = request;
35
+ if (typeof prompt !== 'string' || typeof schema !== 'object' || schema === null)
36
+ return null;
37
+ return request;
38
+ }
39
+ /** The `kind` a body selects: explicit `kind`, else `messages` ⇒ chat (back-compat). */
40
+ function bodyKind(body) {
41
+ if (typeof body !== 'object' || body === null)
42
+ return null;
43
+ const kind = body.kind;
44
+ if (kind === 'chat' || kind === 'generate')
45
+ return kind;
46
+ if (Array.isArray(body.messages))
47
+ return 'chat';
48
+ return null;
49
+ }
27
50
  /**
28
51
  * Build the mounted route handler. The returned function is a plain
29
52
  * `(Request) => Promise<Response>` — session-gated, soft-rate-limited, and a thin
@@ -63,7 +86,7 @@ export function createAiRouteHandler(options) {
63
86
  // 3. Soft per-project rate / spend counter.
64
87
  if (rateLimited(projectKey(request)))
65
88
  return jsonResponse({ error: 'rate-limited' }, 429);
66
- // 4. Body → chat request.
89
+ // 4. Body → the requested model operation.
67
90
  let body;
68
91
  try {
69
92
  body = await request.json();
@@ -71,11 +94,27 @@ export function createAiRouteHandler(options) {
71
94
  catch {
72
95
  return jsonResponse({ error: 'invalid-json' }, 400);
73
96
  }
74
- const chat = parseChatBody(body);
97
+ const kind = bodyKind(body);
98
+ // 5a. Structured generation — the thin proxy that lets `applyContentWithShape`
99
+ // run client-side without the key ever reaching the browser.
100
+ if (kind === 'generate') {
101
+ const generate = parseGenerateBody(body);
102
+ if (!generate) {
103
+ return jsonResponse({ error: 'invalid-request', message: 'expected { kind: "generate", request }' }, 400);
104
+ }
105
+ try {
106
+ const result = await options.provider.generateStructured(generate);
107
+ return jsonResponse(result, 200);
108
+ }
109
+ catch {
110
+ return jsonResponse({ error: 'provider-error' }, 502);
111
+ }
112
+ }
113
+ // 5b. Chat stream (default / back-compat).
114
+ const chat = kind === 'chat' ? parseChatBody(body) : null;
75
115
  if (!chat) {
76
116
  return jsonResponse({ error: 'invalid-request', message: 'expected { messages: AiChatMessage[] }' }, 400);
77
117
  }
78
- // 5. Thin proxy to the provider stream.
79
118
  if (!options.provider.streamChat) {
80
119
  return jsonResponse({ error: 'provider-cannot-stream' }, 501);
81
120
  }
@@ -1 +1 @@
1
- {"version":3,"file":"route.js","sourceRoot":"","sources":["../../../src/lib/ai/route.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Server-route factory (EC-331): a streaming chat/completion endpoint the consuming\n * studio app mounts. Key custody = host route (never the browser, never Convex): the\n * `provider` holds the host-env key and this handler is a THIN model proxy — NO data\n * access, NO service credentials.\n *\n * Framework-agnostic on purpose: it returns a Web `Request → Response` handler the\n * host wires into whatever server entry it has (a TanStack Start server route, a\n * Convex `httpAction`, a plain fetch handler). The current demo studio builds as a\n * pure client SPA with server functions disabled (EC-234), so actually mounting this\n * is an EC-199 integration step; phase 1 ships the handler + its contract.\n *\n * Session-gated: `getSession` (host-wired, reading its own auth — this module never\n * touches `lib/auth`) must return a session for authenticated callers; anonymous\n * callers are rejected. Plus a soft, in-memory per-project rate counter (spend guard).\n */\nimport type {\n AiChatMessage,\n AiRouteHandler,\n AiRouteHandlerOptions,\n AiSession,\n StreamChatRequest,\n} from './types'\n\nfunction jsonResponse(value: unknown, status: number): Response {\n return new Response(JSON.stringify(value), {\n status,\n headers: { 'content-type': 'application/json' },\n })\n}\n\ninterface RateBucket {\n windowStart: number\n count: number\n}\n\nfunction isChatMessage(value: unknown): value is AiChatMessage {\n if (typeof value !== 'object' || value === null) return false\n const m = value as { role?: unknown; content?: unknown }\n return (\n (m.role === 'system' || m.role === 'user' || m.role === 'assistant') &&\n typeof m.content === 'string'\n )\n}\n\n/** Parse a request body into a {@link StreamChatRequest}, or `null` when malformed. */\nfunction parseChatBody(body: unknown): StreamChatRequest | null {\n if (typeof body !== 'object' || body === null) return null\n const { messages, system } = body as { messages?: unknown; system?: unknown }\n if (!Array.isArray(messages) || messages.length === 0 || !messages.every(isChatMessage)) {\n return null\n }\n return {\n messages: messages as AiChatMessage[],\n ...(typeof system === 'string' ? { system } : {}),\n }\n}\n\n/**\n * Build the mounted route handler. The returned function is a plain\n * `(Request) => Promise<Response>` — session-gated, soft-rate-limited, and a thin\n * proxy to `provider.streamChat`. Never throws to the caller: every failure is a\n * structured JSON response with an appropriate status.\n */\nexport function createAiRouteHandler(options: AiRouteHandlerOptions): AiRouteHandler {\n const maxPerMinute = options.limits?.maxRequestsPerMinute\n const buckets = new Map<string, RateBucket>()\n const projectKey = options.projectKey ?? (() => 'global')\n\n function rateLimited(key: string): boolean {\n if (maxPerMinute === undefined || maxPerMinute <= 0) return false\n const now = Date.now()\n const bucket = buckets.get(key)\n if (!bucket || now - bucket.windowStart >= 60_000) {\n buckets.set(key, { windowStart: now, count: 1 })\n return false\n }\n bucket.count += 1\n return bucket.count > maxPerMinute\n }\n\n return async (request: Request): Promise<Response> => {\n // 1. Session gate — reject anonymous callers (the route holds spend, not data).\n let session: AiSession | null\n try {\n session = await options.getSession(request)\n } catch {\n return jsonResponse({ error: 'unauthorized' }, 401)\n }\n if (!session) return jsonResponse({ error: 'unauthorized' }, 401)\n\n // 2. Method.\n if (request.method !== 'POST') return jsonResponse({ error: 'method-not-allowed' }, 405)\n\n // 3. Soft per-project rate / spend counter.\n if (rateLimited(projectKey(request))) return jsonResponse({ error: 'rate-limited' }, 429)\n\n // 4. Body → chat request.\n let body: unknown\n try {\n body = await request.json()\n } catch {\n return jsonResponse({ error: 'invalid-json' }, 400)\n }\n const chat = parseChatBody(body)\n if (!chat) {\n return jsonResponse(\n { error: 'invalid-request', message: 'expected { messages: AiChatMessage[] }' },\n 400,\n )\n }\n\n // 5. Thin proxy to the provider stream.\n if (!options.provider.streamChat) {\n return jsonResponse({ error: 'provider-cannot-stream' }, 501)\n }\n try {\n return await options.provider.streamChat(chat)\n } catch {\n return jsonResponse({ error: 'provider-error' }, 502)\n }\n }\n}\n"]}
1
+ {"version":3,"file":"route.js","sourceRoot":"","sources":["../../../src/lib/ai/route.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Server-route factory (EC-331): a streaming chat/completion endpoint the consuming\n * studio app mounts. Key custody = host route (never the browser, never Convex): the\n * `provider` holds the host-env key and this handler is a THIN model proxy — NO data\n * access, NO service credentials.\n *\n * Framework-agnostic on purpose: it returns a Web `Request → Response` handler the\n * host wires into whatever server entry it has (a TanStack Start server route, a\n * Convex `httpAction`, a plain fetch handler). The current demo studio builds as a\n * pure client SPA with server functions disabled (EC-234), so actually mounting this\n * is an EC-199 integration step; phase 1 ships the handler + its contract.\n *\n * Session-gated: `getSession` (host-wired, reading its own auth — this module never\n * touches `lib/auth`) must return a session for authenticated callers; anonymous\n * callers are rejected. Plus a soft, in-memory per-project rate counter (spend guard).\n *\n * EC-199: the route serves TWO model operations — both thin proxies, neither with any\n * data access — discriminated by the request body's `kind`:\n * - `{ kind: 'chat', messages, system? }` → `provider.streamChat` (text stream);\n * - `{ kind: 'generate', request }` → `provider.generateStructured` (JSON).\n * The structured mode is what lets `applyContentWithShape` run CLIENT-side against a\n * {@link createRouteProvider} whose key never touches the browser — the creation flow's\n * whole authoring loop proxies its model calls here while the tools (search/list/apply)\n * execute in the client with the user's own session. A body with `messages` and no\n * `kind` is still treated as a chat request (back-compat with the phase-1 contract).\n */\nimport type {\n AiChatMessage,\n AiRouteHandler,\n AiRouteHandlerOptions,\n AiSession,\n GenerateStructuredRequest,\n StreamChatRequest,\n} from './types'\n\nfunction jsonResponse(value: unknown, status: number): Response {\n return new Response(JSON.stringify(value), {\n status,\n headers: { 'content-type': 'application/json' },\n })\n}\n\ninterface RateBucket {\n windowStart: number\n count: number\n}\n\nfunction isChatMessage(value: unknown): value is AiChatMessage {\n if (typeof value !== 'object' || value === null) return false\n const m = value as { role?: unknown; content?: unknown }\n return (\n (m.role === 'system' || m.role === 'user' || m.role === 'assistant') &&\n typeof m.content === 'string'\n )\n}\n\n/** Parse a request body into a {@link StreamChatRequest}, or `null` when malformed. */\nfunction parseChatBody(body: unknown): StreamChatRequest | null {\n if (typeof body !== 'object' || body === null) return null\n const { messages, system } = body as { messages?: unknown; system?: unknown }\n if (!Array.isArray(messages) || messages.length === 0 || !messages.every(isChatMessage)) {\n return null\n }\n return {\n messages: messages as AiChatMessage[],\n ...(typeof system === 'string' ? { system } : {}),\n }\n}\n\n/** The wire discriminator selecting which model operation the route proxies. */\nexport type AiRouteKind = 'chat' | 'generate'\n\n/** Structurally validate a {@link GenerateStructuredRequest}, or `null` when malformed. */\nfunction parseGenerateBody(body: unknown): GenerateStructuredRequest | null {\n if (typeof body !== 'object' || body === null) return null\n const request = (body as { request?: unknown }).request\n if (typeof request !== 'object' || request === null) return null\n const { prompt, schema } = request as { prompt?: unknown; schema?: unknown }\n if (typeof prompt !== 'string' || typeof schema !== 'object' || schema === null) return null\n return request as GenerateStructuredRequest\n}\n\n/** The `kind` a body selects: explicit `kind`, else `messages` ⇒ chat (back-compat). */\nfunction bodyKind(body: unknown): AiRouteKind | null {\n if (typeof body !== 'object' || body === null) return null\n const kind = (body as { kind?: unknown }).kind\n if (kind === 'chat' || kind === 'generate') return kind\n if (Array.isArray((body as { messages?: unknown }).messages)) return 'chat'\n return null\n}\n\n/**\n * Build the mounted route handler. The returned function is a plain\n * `(Request) => Promise<Response>` — session-gated, soft-rate-limited, and a thin\n * proxy to `provider.streamChat`. Never throws to the caller: every failure is a\n * structured JSON response with an appropriate status.\n */\nexport function createAiRouteHandler(options: AiRouteHandlerOptions): AiRouteHandler {\n const maxPerMinute = options.limits?.maxRequestsPerMinute\n const buckets = new Map<string, RateBucket>()\n const projectKey = options.projectKey ?? (() => 'global')\n\n function rateLimited(key: string): boolean {\n if (maxPerMinute === undefined || maxPerMinute <= 0) return false\n const now = Date.now()\n const bucket = buckets.get(key)\n if (!bucket || now - bucket.windowStart >= 60_000) {\n buckets.set(key, { windowStart: now, count: 1 })\n return false\n }\n bucket.count += 1\n return bucket.count > maxPerMinute\n }\n\n return async (request: Request): Promise<Response> => {\n // 1. Session gate — reject anonymous callers (the route holds spend, not data).\n let session: AiSession | null\n try {\n session = await options.getSession(request)\n } catch {\n return jsonResponse({ error: 'unauthorized' }, 401)\n }\n if (!session) return jsonResponse({ error: 'unauthorized' }, 401)\n\n // 2. Method.\n if (request.method !== 'POST') return jsonResponse({ error: 'method-not-allowed' }, 405)\n\n // 3. Soft per-project rate / spend counter.\n if (rateLimited(projectKey(request))) return jsonResponse({ error: 'rate-limited' }, 429)\n\n // 4. Body → the requested model operation.\n let body: unknown\n try {\n body = await request.json()\n } catch {\n return jsonResponse({ error: 'invalid-json' }, 400)\n }\n\n const kind = bodyKind(body)\n\n // 5a. Structured generation — the thin proxy that lets `applyContentWithShape`\n // run client-side without the key ever reaching the browser.\n if (kind === 'generate') {\n const generate = parseGenerateBody(body)\n if (!generate) {\n return jsonResponse(\n { error: 'invalid-request', message: 'expected { kind: \"generate\", request }' },\n 400,\n )\n }\n try {\n const result = await options.provider.generateStructured(generate)\n return jsonResponse(result, 200)\n } catch {\n return jsonResponse({ error: 'provider-error' }, 502)\n }\n }\n\n // 5b. Chat stream (default / back-compat).\n const chat = kind === 'chat' ? parseChatBody(body) : null\n if (!chat) {\n return jsonResponse(\n { error: 'invalid-request', message: 'expected { messages: AiChatMessage[] }' },\n 400,\n )\n }\n if (!options.provider.streamChat) {\n return jsonResponse({ error: 'provider-cannot-stream' }, 501)\n }\n try {\n return await options.provider.streamChat(chat)\n } catch {\n return jsonResponse({ error: 'provider-error' }, 502)\n }\n }\n}\n"]}
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The narrow tool set the creation flow's model works against (EC-199).
3
+ *
4
+ * Per the ratified AD, tools execute CLIENT-side against the operations layer with
5
+ * the user's own session — this module only declares their SHAPES (the model-facing
6
+ * planner schema) and the reader interface the studio binds to operations. The engine
7
+ * never gains a write path: `searchDocuments` / `listAssets` are reads that gather the
8
+ * real ids a proposal may reference (a relation id is otherwise structurally
9
+ * impossible — see `buildTargetShape`), and `createDocument` is the terminal action
10
+ * that runs `applyContentWithShape` and yields a reviewable proposal. `updateFields`
11
+ * and `translate` are deliberately NOT here — they are later slices (EC-332 / post-v1).
12
+ */
13
+ import type { CmsDocument, JsonSchema } from '@elytracms/core/cms-core';
14
+ /** A document the model may reference as a relation target (from a `searchDocuments` read). */
15
+ export interface DocumentSearchResult {
16
+ id: string;
17
+ title?: string;
18
+ }
19
+ /** An asset the model may reference (from a `listAssets` read). */
20
+ export interface AssetSearchResult {
21
+ id: string;
22
+ title?: string;
23
+ }
24
+ /**
25
+ * The client-side, operations-backed readers the orchestrator calls. The studio binds
26
+ * these to the user's authenticated operations client; tests bind an in-memory fake.
27
+ * Reads only — there is no write tool (apply is a separate, human-gated draft write).
28
+ */
29
+ export interface CreateFlowTools {
30
+ searchDocuments(input: {
31
+ collection: string;
32
+ query?: string;
33
+ limit?: number;
34
+ }): Promise<DocumentSearchResult[]>;
35
+ listAssets(input: {
36
+ query?: string;
37
+ limit?: number;
38
+ }): Promise<AssetSearchResult[]>;
39
+ /**
40
+ * Fetch a full document (values + localized) for the translate flow (EC-332). Optional:
41
+ * the creation flow never needs it, so a studio that only wires create can omit it.
42
+ */
43
+ getDocument?(input: {
44
+ collection: string;
45
+ id: string;
46
+ }): Promise<CmsDocument | null>;
47
+ }
48
+ /** One planned step the model returns — the discriminated tool/action union. */
49
+ export type PlannerAction = {
50
+ action: 'searchDocuments';
51
+ collection: string;
52
+ query?: string;
53
+ } | {
54
+ action: 'listAssets';
55
+ query?: string;
56
+ } | {
57
+ action: 'createDocument';
58
+ collection: string;
59
+ instruction?: string;
60
+ } | {
61
+ action: 'translateDocument';
62
+ collection: string;
63
+ query?: string;
64
+ } | {
65
+ action: 'respond';
66
+ message: string;
67
+ };
68
+ /** The schema name the planner call carries — how a FakeProvider tells plan vs. author. */
69
+ export declare const PLANNER_SCHEMA_NAME = "create_flow_action";
70
+ /**
71
+ * The structured-output schema for ONE planning step. `action` is a closed enum and
72
+ * `collection` is leashed to the known collection ids, so the model can neither invent
73
+ * an action nor target a collection that does not exist.
74
+ */
75
+ export declare function plannerSchema(collectionIds: readonly string[]): JsonSchema;
76
+ /** Coerce a raw structured-output object into a {@link PlannerAction}; `null` if unusable. */
77
+ export declare function parsePlannerAction(raw: unknown): PlannerAction | null;