diff --git a/mock/server.ts b/mock/server.ts index f1aa589..d4677ce 100644 --- a/mock/server.ts +++ b/mock/server.ts @@ -100,9 +100,9 @@ export function createMockCms(opts: MockOptions = {}) { return json(200, { ...res, patches }) } if (path === '/api/publish-roots' && m === 'POST') { - const body = (await req.json()) as { roots: string[] } - published.push(...body.roots) - return json(200, { published: body.roots }) + const body = (await req.json()) as { ids: string[] } + published.push(...body.ids) + return json(200, { publishedAt: new Date().toISOString(), results: body.ids.map((id) => ({ id })) }) } if (path === '/api/preview' && !previewApi) return json(404, { error: 'no such route' }) if (path === '/api/preview' || (m === 'GET' && req.headers.get('accept')?.includes('text/html'))) { diff --git a/src/ai/RewriteButton.tsx b/src/ai/RewriteButton.tsx new file mode 100644 index 0000000..a048d75 --- /dev/null +++ b/src/ai/RewriteButton.tsx @@ -0,0 +1,107 @@ +// "Rewrite with AI" for a text field. Never writes to the field itself: the model's +// answer is shown as a before/after suggestion and only Accept calls `onAccept`, +// which goes through the normal form onChange (so validation, dirty-tracking and +// Save/undo behave exactly as for typing). +import { useEffect, useRef, useState } from 'react' +import type { JSONSchema } from '../api' +import { aiSupported, getEngine } from './engine' +import { cleanReply, REWRITE_ACTIONS, rewriteMessages } from './rewrite' +import type { RewriteAction } from './rewrite' + +type State = + | { s: 'idle' } + | { s: 'loading'; ratio?: number } + | { s: 'working' } + | { s: 'suggest'; text: string } + | { s: 'error'; message: string } + +export function RewriteButton({ schema, value, label, onAccept }: { schema: JSONSchema; value: string; label?: string; onAccept(v: string): void }) { + const [state, setState] = useState({ s: 'idle' }) + const abort = useRef(null) + useEffect( + () => () => { + abort.current?.abort() + }, + [], + ) + if (!aiSupported()) return null + + const run = async (action: RewriteAction) => { + abort.current?.abort() + const ctl = (abort.current = new AbortController()) + try { + setState({ s: 'loading' }) + const engine = await getEngine((p) => !ctl.signal.aborted && setState({ s: 'loading', ratio: p.ratio })) + if (ctl.signal.aborted) return + setState({ s: 'working' }) + const reply = await engine.generate(rewriteMessages(action, value, schema, label), { signal: ctl.signal }) + if (ctl.signal.aborted) return + const text = cleanReply(reply, schema) + setState(text ? { s: 'suggest', text } : { s: 'error', message: 'The model returned nothing' }) + } catch (e) { + if ((e as Error).name !== 'AbortError') setState({ s: 'error', message: (e as Error).message }) + } + } + const reset = () => { + abort.current?.abort() + setState({ s: 'idle' }) + } + + if (state.s === 'idle') + return ( +
+ +
+ ) + if (state.s === 'loading' || state.s === 'working') + return ( +
+ + {state.s === 'working' ? 'Rewriting…' : `Loading model${state.ratio !== undefined ? ` ${Math.round(state.ratio * 100)}%` : '…'} (first use only)`} + + +
+ ) + if (state.s === 'error') + return ( +
+ + {state.message} + + +
+ ) + return ( +
+
+ {state.text} +
+
+ + +
+
+ ) +} diff --git a/src/ai/agent.ts b/src/ai/agent.ts new file mode 100644 index 0000000..599c811 --- /dev/null +++ b/src/ai/agent.ts @@ -0,0 +1,94 @@ +// Tool-calling loop over any `Engine`. The protocol is plain text so it does not +// depend on the model's native function-calling template: +// {"name": "...", "arguments": {...}} +// Tool output goes back as a `` user message. Tool turns are +// transient: only the user message and the final answer survive into history. +import type { ChatMessage, Engine } from './engine' + +export interface Tool { + name: string + description: string + /** One-line parameter hint shown to the model, e.g. `id (string, optional)`. */ + params: string + run(args: Record): Promise +} + +export interface ToolCall { + name: string + arguments: Record +} + +/** Pull a tool call out of a model reply; tolerates a missing closing tag and a ```json fence. */ +export function parseToolCall(reply: string): ToolCall | null { + const tagged = reply.match(/\s*([\s\S]*?)\s*(?:<\/tool_call>|$)/) + const fenced = reply.match(/```(?:json|tool_call)?\s*(\{[\s\S]*?\})\s*```/) + const raw = tagged?.[1] ?? fenced?.[1] + if (!raw) return null + try { + const o = JSON.parse(raw.replace(/^```(?:json)?|```$/g, '').trim()) + if (o && typeof o.name === 'string') return { name: o.name, arguments: o.arguments && typeof o.arguments === 'object' ? o.arguments : (o.args ?? {}) } + } catch { + /* malformed: treated as plain text */ + } + return null +} + +export function toolPrompt(tools: Tool[]): string { + return [ + 'You can call tools to read the page and to propose edits. To call a tool, reply with ONLY:', + '{"name": "", "arguments": {...}}', + 'You will get the result in a message, then continue. Call one tool at a time.', + 'When you are done, reply in plain text with no tool call. Never claim an edit was made: propose_set only proposes, the user accepts or rejects it.', + 'Tools:', + ...tools.map((t) => `- ${t.name}(${t.params}): ${t.description}`), + ].join('\n') +} + +export interface RunOptions { + maxSteps?: number + maxResultChars?: number + signal?: AbortSignal + /** Called before each tool runs, for progress display. */ + onStep?(call: ToolCall): void + /** Streamed reply text for the current step (accumulated). Use `visibleText` to hide tool-call markup. */ + onText?(partial: string): void +} + +export async function runAgent(engine: Engine, system: string, history: ChatMessage[], tools: Tool[], opts: RunOptions = {}): Promise { + const { maxSteps = 6, maxResultChars = 4000, signal } = opts + const byName = new Map(tools.map((t) => [t.name, t])) + const msgs: ChatMessage[] = [{ role: 'system', content: `${system}\n\n${toolPrompt(tools)}` }, ...history] + for (let step = 0; step < maxSteps; step++) { + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError') + let acc = '' + opts.onText?.('') + const reply = await engine.generate(msgs, { + maxTokens: 700, + signal, + onToken: (t) => opts.onText?.((acc += t)), + }) + const call = parseToolCall(reply) + if (!call) return reply.trim() + opts.onStep?.(call) + const tool = byName.get(call.name) + let result: string + if (!tool) result = JSON.stringify({ error: `Unknown tool "${call.name}". Available: ${[...byName.keys()].join(', ')}` }) + else { + try { + result = JSON.stringify(await tool.run(call.arguments)) + } catch (e) { + result = JSON.stringify({ error: (e as Error).message }) + } + } + if (result.length > maxResultChars) result = `${result.slice(0, maxResultChars)}…[truncated]` + msgs.push({ role: 'assistant', content: reply }, { role: 'user', content: `${result}` }) + } + return 'I stopped after too many steps. Try a more specific request.' +} + +/** The part of a (partial) reply that is prose: everything before a tool call, including a half-typed `= 0 ? partial.slice(0, i) : partial.replace(/<(t(o(o(l(_(c(a(l(l)?)?)?)?)?)?)?)?)?$/, '') + return head.trim() +} diff --git a/src/ai/engine.ts b/src/ai/engine.ts new file mode 100644 index 0000000..964a6a0 --- /dev/null +++ b/src/ai/engine.ts @@ -0,0 +1,121 @@ +// Local text generation on WebGPU (Gemma 4 via Transformers.js). +// +// Optional by construction: Transformers.js is NOT a dependency and is never +// bundled. It is `import()`ed from a URL the first time someone asks for AI, so +// the editor bundle is unchanged for everyone else. Override the URL (e.g. to a +// self-hosted copy under your CSP) with `configureAi({ transformersUrl })`. +// +// Everything above this file talks to the `Engine` interface, so a remote model +// or a local Ollama can be dropped in later without touching the UI. + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant' + content: string +} + +export interface GenerateOptions { + maxTokens?: number + signal?: AbortSignal + /** Called with each decoded text chunk as it is generated. */ + onToken?(delta: string): void +} + +export interface Engine { + generate(messages: ChatMessage[], opts?: GenerateOptions): Promise +} + +export interface LoadProgress { + /** 0..1, or undefined while unknown. */ + ratio?: number + file?: string +} + +export interface AiConfig { + transformersUrl: string + model: string + dtype: string +} + +const config: AiConfig = { + transformersUrl: 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@next', + model: 'onnx-community/gemma-4-E2B-it-ONNX', + dtype: 'q4f16', +} + +export function configureAi(patch: Partial): void { + Object.assign(config, patch) + loading = undefined // next use reloads with the new settings +} + +/** Cheap synchronous check: is there a WebGPU adapter API at all? (shader-f16 is checked on load.) */ +export function aiSupported(): boolean { + return typeof navigator !== 'undefined' && 'gpu' in navigator +} + +async function assertF16(): Promise { + const gpu = (navigator as any).gpu + const adapter = await gpu?.requestAdapter() + if (!adapter) throw new Error('No WebGPU adapter available') + if (!adapter.features?.has('shader-f16')) throw new Error('This GPU/browser lacks the shader-f16 feature the q4f16 model needs') +} + +let loading: Promise | undefined +let progressSink: ((p: LoadProgress) => void) | undefined + +/** Load (once) and return the engine. Concurrent callers share one download; the latest `onProgress` wins. */ +export function getEngine(onProgress?: (p: LoadProgress) => void): Promise { + progressSink = onProgress + loading ??= load().catch((e) => { + loading = undefined // allow retry after a failure + throw e + }) + return loading +} + +async function load(): Promise { + await assertF16() + const tf: any = await import(/* @vite-ignore */ config.transformersUrl) + const files = new Map() + const generator = await tf.pipeline('text-generation', config.model, { + device: 'webgpu', + dtype: config.dtype, + progress_callback: (p: any) => { + if (p?.status !== 'progress' || !p.file) return + files.set(p.file, { loaded: p.loaded ?? 0, total: p.total ?? 0 }) + let loaded = 0 + let total = 0 + for (const f of files.values()) (loaded += f.loaded), (total += f.total) + progressSink?.({ ratio: total ? loaded / total : undefined, file: p.file }) + }, + }) + return { + async generate(messages, opts = {}) { + if (opts.signal?.aborted) throw new DOMException('Aborted', 'AbortError') + // Greedy decoding: edits should be repeatable, not creative. + const stop = new tf.InterruptableStoppingCriteria() + const onAbort = () => stop.interrupt() + opts.signal?.addEventListener('abort', onAbort) + const streamer = opts.onToken + ? new tf.TextStreamer(generator.tokenizer, { skip_prompt: true, skip_special_tokens: true, callback_function: (t: string) => opts.onToken!(t) }) + : undefined + let out: any + try { + out = await generator(messages, { max_new_tokens: opts.maxTokens ?? 512, do_sample: false, streamer, stopping_criteria: stop }) + } finally { + opts.signal?.removeEventListener('abort', onAbort) + } + if (opts.signal?.aborted) throw new DOMException('Aborted', 'AbortError') + const text = out?.[0]?.generated_text + // Chat input returns the whole conversation; the reply is the last message. + return toText(Array.isArray(text) ? text.at(-1)?.content : text) + }, + } +} + +/** Message content may be a string or a list of parts ({type:'text', text}); always return a string. */ +export function toText(c: unknown): string { + if (typeof c === 'string') return c + if (Array.isArray(c)) return c.map(toText).join('') + if (c && typeof c === 'object' && typeof (c as any).text === 'string') return (c as any).text + return '' +} diff --git a/src/ai/rewrite.ts b/src/ai/rewrite.ts new file mode 100644 index 0000000..a0fb021 --- /dev/null +++ b/src/ai/rewrite.ts @@ -0,0 +1,42 @@ +// Prompting for per-field rewrites. Pure, so it is testable without a model. +import type { JSONSchema } from '../api' +import type { ChatMessage } from './engine' + +export type RewriteAction = 'improve' | 'shorten' | 'expand' | 'fix' | 'friendly' | 'formal' + +export const REWRITE_ACTIONS: Array<{ id: RewriteAction; label: string; instruction: string }> = [ + { id: 'improve', label: 'Improve', instruction: 'Improve clarity and flow while keeping the meaning and length similar.' }, + { id: 'fix', label: 'Fix spelling & grammar', instruction: 'Fix spelling, grammar and punctuation only. Do not change wording otherwise.' }, + { id: 'shorten', label: 'Shorten', instruction: 'Make it noticeably shorter without losing the key message.' }, + { id: 'expand', label: 'Expand', instruction: 'Expand it slightly with relevant detail, without inventing facts.' }, + { id: 'friendly', label: 'Friendlier', instruction: 'Make the tone warmer and more conversational.' }, + { id: 'formal', label: 'More formal', instruction: 'Make the tone more formal and professional.' }, +] + +export function rewriteMessages(action: RewriteAction, text: string, schema: JSONSchema, label?: string): ChatMessage[] { + const a = REWRITE_ACTIONS.find((x) => x.id === action)! + const x = schema['x-editor'] as string | undefined + const format = x === 'markdown' || x === 'richtext' ? 'Preserve the Markdown formatting.' : x === 'html' ? 'Preserve the HTML tags and structure.' : 'Output plain text.' + const limit = schema.maxLength ? ` The result must be at most ${schema.maxLength} characters.` : '' + return [ + { + role: 'system', + content: + 'You are an editorial assistant for website copy. Reply with ONLY the rewritten text: no quotes, no preamble, no explanation. ' + + 'Keep the original language. Do not add facts that are not in the text. ' + + `${format}${limit}`, + }, + { role: 'user', content: `${label ? `Field: ${label}\n` : ''}Task: ${a.instruction}\n\nText:\n${text}` }, + ] +} + +/** Models like to wrap answers; undo the common wrappers. */ +export function cleanReply(raw: string, schema: JSONSchema): string { + let t = raw.trim() + const fence = t.match(/^```[a-z]*\n([\s\S]*?)\n```$/i) + if (fence) t = fence[1].trim() + const quoted = t.match(/^"([^"]*)"$/s) ?? t.match(/^“([^”]*)”$/s) + if (quoted) t = quoted[1].trim() + if (schema.maxLength && t.length > schema.maxLength) t = t.slice(0, schema.maxLength).trimEnd() + return t +} diff --git a/src/ai/tools.ts b/src/ai/tools.ts new file mode 100644 index 0000000..4c463d7 --- /dev/null +++ b/src/ai/tools.ts @@ -0,0 +1,205 @@ +// Editor tools for the assistant. Read tools return compact JSON; the only write +// tool, `propose_set`, validates against the node's schema and QUEUES a proposal. +// Nothing is saved until the user accepts it (see `applyProposal`). +import type { JSONSchema } from '../api' +import type { Controller } from '../controller' +import { validate } from '../form/validate' +import { resolveSchema } from '../form/resolve' +import { collectStrings, getIn, parsePath, patchForPath } from '../page/infer' +import { allowedChildren, typeName } from '../schema' +import type { Tool } from './agent' + +interface ProposalBase { + id: number + /** The node it concerns (parent for inserts). */ + nodeId: string + /** Node title/type for display. */ + label: string +} +export type Proposal = + | (ProposalBase & { kind: 'set'; /** Dotted path, e.g. `title` or `links.0.text`. */ path: string; before: unknown; after: unknown }) + | (ProposalBase & { kind: 'insert'; type: string; data: Record; index?: number }) + | (ProposalBase & { kind: 'remove'; summary: string }) +/** What `propose` receives: a proposal without its id. */ +export type NewProposal = Proposal extends infer P ? (P extends Proposal ? Omit : never) : never + +/** Property summary of a schema: enough for the model to know what exists, small enough for a 2B model. */ +export function summarizeSchema(schema: JSONSchema): Record { + const out: Record = {} + for (const [k, raw] of Object.entries(schema.properties ?? {})) { + const s = resolveSchema(schema, raw).schema + if (s['x-editor'] === 'hidden' || s.readOnly) continue + const t = Array.isArray(s.type) ? s.type.find((x: string) => x !== 'null') : s.type + out[k] = { + type: t ?? 'object', + ...(s.title ? { title: s.title } : {}), + ...(s.maxLength ? { maxLength: s.maxLength } : {}), + ...(s.enum ? { enum: s.enum } : {}), + ...(s['x-editor'] ? { editor: s['x-editor'] } : {}), + } + } + return out +} + +export interface ToolHost { + ctrl: Controller + /** Receives validated proposals. */ + propose(p: NewProposal): void +} + +export function createTools({ ctrl, propose }: ToolHost): Tool[] { + const target = (id?: string) => { + const nodeId = id || ctrl.store.get().selected + if (!nodeId) throw new Error('No block is selected. Pass an "id" or ask the user to select a block.') + return nodeId + } + const describe = async (id: string) => { + const node = await ctrl.node(id, true) + return { node, schema: await ctrl.schema(node.dataType) } + } + return [ + { + name: 'get_page_outline', + description: 'List the blocks on the page in order with their ids, types, depth and title.', + params: 'none', + async run() { + return ctrl.store.get().blocks.slice(0, 120).map((b) => ({ id: b.id, type: b.type, depth: b.depth, ...(ctrl.labelFor(b.id) ? { title: ctrl.labelFor(b.id) } : {}) })) + }, + }, + { + name: 'get_selected_node', + description: 'Get the block the user has selected (or the given id): type, data, editable text fields (with paths) and the field schema.', + params: 'id (string, optional)', + async run(args) { + const { node, schema } = await describe(target(args.id)) + return { + id: node.id, + type: node.dataType, + data: node.data, + textFields: collectStrings(node.data ?? {}, schema).map((l) => ({ path: l.path.join('.'), value: l.value })), + fields: summarizeSchema(schema), + } + }, + }, + { + name: 'get_page_text', + description: 'Get all editable text on the page, block by block (id, type, path, value). Use it for tone/consistency checks.', + params: 'none', + async run() { + const out: Array<{ id: string; type: string; text: Array<{ path: string; value: string }> }> = [] + for (const b of ctrl.store.get().blocks.slice(0, 60)) { + const n = await ctrl.node(b.id).catch(() => null) + if (!n) continue + const text = collectStrings(n.data ?? {}, ctrl.schemaOf(n.dataType)).map((l) => ({ path: l.path.join('.'), value: l.value.slice(0, 240) })) + if (text.length) out.push({ id: b.id, type: n.dataType, text }) + } + return out + }, + }, + { + name: 'find_text', + description: 'Find blocks whose text contains a string (case-insensitive). Returns matching block ids, paths and the text.', + params: 'query (string)', + async run(args) { + const q = String(args.query ?? '').toLowerCase() + if (!q) throw new Error('"query" is required') + const hits: Array<{ id: string; type: string; path: string; value: string }> = [] + for (const b of ctrl.store.get().blocks.slice(0, 80)) { + const n = await ctrl.node(b.id).catch(() => null) + if (!n) continue + for (const l of collectStrings(n.data ?? {}, ctrl.schemaOf(n.dataType))) if (l.value.toLowerCase().includes(q)) hits.push({ id: n.id, type: n.dataType, path: l.path.join('.'), value: l.value.slice(0, 240) }) + if (hits.length >= 20) break + } + return hits + }, + }, + { + name: 'select_block', + description: 'Select a block and scroll it into view so the user sees what you are talking about.', + params: 'id (string)', + async run(args) { + if (!ctrl.block(String(args.id))) throw new Error(`No block "${args.id}" on this page`) + ctrl.select(String(args.id), { scroll: true }) + return { ok: true } + }, + }, + { + name: 'list_insertable_types', + description: 'List the block types that may be inserted inside a block (default: the page root), and the fields of each.', + params: 'parent_id (string, optional)', + async run(args) { + const parent = args.parent_id ? String(args.parent_id) : await ctrl.pageRoot() + const pn = await ctrl.node(parent) + const types = allowedChildren(await ctrl.schema(pn.dataType), (await ctrl.api.types()).map(typeName)).filter((t) => !ctrl.isStaticRoot(t)) + return Promise.all(types.slice(0, 25).map(async (t) => ({ type: t, fields: summarizeSchema(await ctrl.schema(t)) }))) + }, + }, + { + name: 'propose_insert', + description: 'Propose adding a new block inside a parent. Data is validated against the type schema; the user must accept it.', + params: 'parent_id (string, optional: page root), type (string), data (object), index (number, optional)', + async run(args) { + const parent = args.parent_id ? String(args.parent_id) : await ctrl.pageRoot() + const type = String(args.type ?? '') + const pn = await ctrl.node(parent) + const allowed = allowedChildren(await ctrl.schema(pn.dataType), (await ctrl.api.types()).map(typeName)) + if (!allowed.includes(type)) throw new Error(`"${type}" cannot be inserted there. Allowed: ${allowed.join(', ')}`) + const data = args.data && typeof args.data === 'object' ? (args.data as Record) : {} + const schema = await ctrl.schema(type) + const errors = validate(schema, schema, data) + if (Object.keys(errors).length) throw new Error(`Invalid: ${Object.entries(errors).map(([p, m]) => `${p || 'value'}: ${m}`).join('; ')}`) + propose({ kind: 'insert', nodeId: parent, label: ctrl.labelFor(parent) ?? pn.dataType, type, data, ...(typeof args.index === 'number' ? { index: args.index } : {}) }) + return { ok: true, note: 'Proposal queued for the user to accept or reject.' } + }, + }, + { + name: 'propose_remove', + description: 'Propose deleting a block. The user must accept it (and it can be undone).', + params: 'id (string)', + async run(args) { + const id = String(args.id ?? '') + if (!ctrl.block(id)) throw new Error(`No block "${id}" on this page`) + const n = await ctrl.node(id) + const text = collectStrings(n.data ?? {}, ctrl.schemaOf(n.dataType))[0]?.value + propose({ kind: 'remove', nodeId: id, label: ctrl.labelFor(id) ?? n.dataType, summary: text ? text.slice(0, 120) : n.dataType }) + return { ok: true, note: 'Proposal queued for the user to accept or reject.' } + }, + }, + { + name: 'propose_set', + description: 'Propose a new value for ONE field of a block. Validated against the schema; the user must accept it before it is saved.', + params: 'id (string, optional: defaults to selected block), path (string, e.g. "title" or "links.0.text"), value (any)', + async run(args) { + const nodeId = target(args.id) + if (typeof args.path !== 'string' || !args.path) throw new Error('"path" is required') + if (!('value' in args)) throw new Error('"value" is required') + const { node, schema } = await describe(nodeId) + const path = parsePath(args.path) + const top = schema.properties?.[path[0] as string] + if (!top) throw new Error(`Unknown field "${path[0]}". Fields: ${Object.keys(schema.properties ?? {}).join(', ')}`) + const data = node.data ?? {} + const merged = { ...data, ...patchForPath(data, path, args.value) } + const errors = validate(schema, schema, merged) + if (Object.keys(errors).length) throw new Error(`Invalid: ${Object.entries(errors).map(([p, m]) => `${p || 'value'}: ${m}`).join('; ')}`) + const before = getIn(data, path) + if (JSON.stringify(before) === JSON.stringify(args.value)) return { ok: true, note: 'Value is already that; nothing to propose.' } + propose({ kind: 'set', nodeId, label: ctrl.labelFor(nodeId) ?? node.dataType, path: args.path, before, after: args.value }) + return { ok: true, note: 'Proposal queued for the user to accept or reject.' } + }, + }, + ] +} + +/** One-line description of a proposal for the UI. */ +export function describeProposal(p: Proposal): string { + return p.kind === 'set' ? `${p.label} · ${p.path}` : p.kind === 'insert' ? `Add ${p.type} inside ${p.label}` : `Delete ${p.label}` +} + +/** Apply an accepted proposal. Re-reads the node first so earlier accepted proposals are not overwritten. */ +export async function applyProposal(ctrl: Controller, p: Proposal): Promise { + if (p.kind === 'insert') return (await ctrl.insert(p.nodeId, p.type, { data: p.data, ...(p.index !== undefined ? { index: p.index } : {}) })) !== null + if (p.kind === 'remove') return (await ctrl.remove(p.nodeId)) !== null + const node = await ctrl.node(p.nodeId, true) + const patch = patchForPath(node.data ?? {}, parsePath(p.path), p.after) + return (await ctrl.setData(node.id, patch, { version: node.version })) !== null +} diff --git a/src/api.ts b/src/api.ts index 7fec699..82cff81 100644 --- a/src/api.ts +++ b/src/api.ts @@ -139,7 +139,7 @@ export class Api { node = (id: string) => this.get(`/api/nodes/${encodeURIComponent(id)}`) edges = (id: string) => this.get(`/api/nodes/${encodeURIComponent(id)}/edges`) edit = (ops: Op[]) => this.post('/api/edit', { ops }) - publish = (roots: string[]) => this.post('/api/publish-roots', { roots }) + publish = (ids: string[]) => this.post<{ publishedAt?: string; results?: { id: string; error?: string }[] }>('/api/publish-roots', { ids }) /** Schemas are code: cache for the session. */ schema(type: string): Promise { diff --git a/src/controller.ts b/src/controller.ts index 9265bd9..2614222 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -33,7 +33,7 @@ export interface DialogInstance { size: 'sm' | 'md' | 'lg' content: ReactNode /** Page-space rect of the block it belongs to, for initial placement. */ - anchor?: { x: number; y: number } + anchor?: { x: number; y: number; right?: number; bottom?: number } /** Identity for de-duplication: opening a dialog whose id is already open focuses the existing one. */ id?: string /** Stacking order (higher = on top); raised when the dialog is opened again or clicked. */ @@ -66,6 +66,8 @@ export interface EditorState { /** Left rail: page outline or the block palette. */ rail: 'outline' | 'blocks' | null dev: boolean + /** AI assistant panel open. */ + assistant: boolean inline: { id: string; field: string } | null log: RequestLog[] /** Page root node id, learned from the first top-level block's parent edge. */ @@ -118,6 +120,7 @@ export class Controller { toast: null, rail: null, dev: false, + assistant: false, inline: null, log: [], rootId: null, @@ -700,7 +703,9 @@ export class Controller { async publish() { try { const rootId = await this.pageRoot() - await this.api.publish([rootId]) + const res = await this.api.publish([rootId]) + const failed = res?.results?.find((r) => r.error) + if (failed) throw new Error(failed.error) this.store.set({ dirty: false }) this.toast('info', 'Published') } catch (e) { @@ -1047,7 +1052,7 @@ export class Controller { return existing.key } const key = ++this.dialogKey - this.store.set((s) => ({ dialogs: [...s.dialogs, { size: 'md', ...d, key, z: ++this.zCounter, pulse: 0 }] })) + this.store.set((s) => ({ dialogs: [...s.dialogs, { ...d, size: d.size ?? 'md', key, z: ++this.zCounter, pulse: 0 }] })) return key } /** Bring a dialog to the front; `pulse` also flashes it and moves focus into it. */ @@ -1086,7 +1091,7 @@ export class Controller { id: dialogId, title: `${d.label} · ${this.labelFor(id) ?? node.dataType}`, size: d.size, - anchor: { x: r.right, y: r.top }, + anchor: { x: r.left, y: r.top, right: r.right, bottom: r.bottom }, content: createElement(d.component, { info, node, schema, ctrl: this, close: () => this.closeDialog(key) }), }) } catch (e) { diff --git a/src/dev.ts b/src/dev.ts index f691452..0804619 100644 --- a/src/dev.ts +++ b/src/dev.ts @@ -25,5 +25,5 @@ const auth = new AuthManager({ void fetch('/__dev/token', t ? { method: 'POST', body: JSON.stringify({ accessToken: t.accessToken }) } : { method: 'DELETE' }).catch(() => {}), }) auth.announce() -const handle = boot({ dev: true, api: new Api({ auth }) }) +const handle = boot({ api: new Api({ auth }) }) if (import.meta.hot) import.meta.hot.dispose(() => handle.destroy()) diff --git a/src/editors/AttrEditor.tsx b/src/editors/AttrEditor.tsx index ebe969e..ac9344a 100644 --- a/src/editors/AttrEditor.tsx +++ b/src/editors/AttrEditor.tsx @@ -2,7 +2,15 @@ import { useEffect, useRef, useState } from 'react' import type { Controller } from '../controller' import { Icon } from '../ui/Icon' -const LABEL: Record = { href: 'Link', src: 'Source', alt: 'Alt text', title: 'Title', placeholder: 'Placeholder', 'aria-label': 'Accessible label', value: 'Value' } +const LABEL: Record = { + href: 'Link', + src: 'Source', + alt: 'Alt text', + title: 'Title', + placeholder: 'Placeholder', + 'aria-label': 'Accessible label', + value: 'Value', +} /** Tiny editor for a property that is rendered as an attribute (href, src, alt…), opened from its chip. */ export function AttrEditor({ id, path, attr, initial, ctrl, close }: { id: string; path: string; attr: string; initial: string; ctrl: Controller; close(): void }) { @@ -24,14 +32,16 @@ export function AttrEditor({ id, path, attr, initial, ctrl, close }: { id: strin setV(e.target.value)} /> -
- - -
+ {v !== initial && ( +
+ + +
+ )} ) } diff --git a/src/editors/builtin.tsx b/src/editors/builtin.tsx index c459091..cdb6c6b 100644 --- a/src/editors/builtin.tsx +++ b/src/editors/builtin.tsx @@ -41,19 +41,21 @@ function Properties({ node, schema, ctrl, close }: EditorProps) { }} > -
- - {problems.length > 0 && ( - `${p || 'form'}: ${m}`).join('\n')}> - {problems.length} problem{problems.length === 1 ? '' : 's'} to fix - - )} - -
+ {changed && ( +
+ + {problems.length > 0 && ( + `${p || 'form'}: ${m}`).join('\n')}> + {problems.length} problem{problems.length === 1 ? '' : 's'} to fix + + )} + +
+ )} ) } @@ -66,15 +68,13 @@ function RawJson({ node, ctrl, close }: EditorProps) { return (
-
- -
+ {Object.keys(patch).length > 0 && ( +
+ +
+ )}
) } @@ -155,7 +155,10 @@ function AddChild({ info, ctrl, close }: EditorProps) { } function AddAfter({ info, ctrl, close }: EditorProps) { - const [target, setTarget] = useState<{ parent: string; index: number } | null>(null) + const [target, setTarget] = useState<{ + parent: string + index: number + } | null>(null) useEffect(() => { void (async () => { const parent = await ctrl.parentIdOf(info.id) @@ -168,12 +171,53 @@ function AddAfter({ info, ctrl, close }: EditorProps) { export function registerBuiltins(): () => void { const off = [ - registerEditor({ id: 'properties', label: 'Properties', icon: 'edit', match: '*', priority: 100, onDoubleClick: true, component: Properties }), + registerEditor({ + id: 'properties', + label: 'Properties', + icon: 'edit', + match: '*', + priority: 100, + onDoubleClick: true, + component: Properties, + }), // Matches on the schema, not the node type: any type with x-style fields gets spacing/layout controls. - registerEditor({ id: 'style', label: 'Style', icon: 'style', match: (i) => describeStyle(i.schema).length > 0, priority: 90, size: 'md', component: StylePanel }), - registerEditor({ id: 'add-child', label: 'Add inside', icon: 'add-inside', match: (i) => i.container, priority: 50, size: 'sm', component: AddChild }), - registerEditor({ id: 'add-after', label: 'Add after', icon: 'add', match: '*', priority: 40, size: 'sm', component: AddAfter }), - registerEditor({ id: 'raw-json', label: 'JSON', icon: 'json', placement: 'menu', match: '*', priority: -100, size: 'lg', component: RawJson }), + registerEditor({ + id: 'style', + label: 'Style', + icon: 'style', + match: (i) => describeStyle(i.schema).length > 0, + priority: 90, + size: 'md', + component: StylePanel, + }), + registerEditor({ + id: 'add-child', + label: 'Add inside', + icon: 'add-inside', + match: (i) => i.container, + priority: 50, + size: 'sm', + component: AddChild, + }), + registerEditor({ + id: 'add-after', + label: 'Add after', + icon: 'add', + match: '*', + priority: 40, + size: 'sm', + component: AddAfter, + }), + registerEditor({ + id: 'raw-json', + label: 'JSON', + icon: 'json', + placement: 'menu', + match: '*', + priority: -100, + size: 'lg', + component: RawJson, + }), registerInlineEditor({ id: 'text', start: startPlainText }), registerInlineEditor({ id: 'markdown', start: startMarkdown }), ] diff --git a/src/form/ArrayField.tsx b/src/form/ArrayField.tsx index 7c47406..c28b366 100644 --- a/src/form/ArrayField.tsx +++ b/src/form/ArrayField.tsx @@ -135,7 +135,8 @@ function ArrayList({ schema: s, itemSchema, items, onChange, path, compound }: { {compound ? ( ) : (
diff --git a/src/form/Form.tsx b/src/form/Form.tsx index 32431bd..54fa9c4 100644 --- a/src/form/Form.tsx +++ b/src/form/Form.tsx @@ -5,6 +5,8 @@ import type { Controller } from '../controller' import { fieldEditorFor } from '../registry' import { labelOf, READONLY_KEYS } from '../schema' import { Summary } from '../ui/Summary' +import { RewriteButton } from '../ai/RewriteButton' +import { Guard } from '../ui/Guard' import { ArrayField } from './ArrayField' import { FormContext, useForm } from './context' import { MapField } from './MapField' @@ -205,13 +207,27 @@ export function StringInput({ schema: s, value, onChange }: { schema: JSONSchema const text = typeof value === 'string' ? value : value == null ? '' : String(value) if (x && ['textarea', 'markdown', 'monaco', 'html', 'richtext'].includes(x) || (s.maxLength ?? 0) > 200) { const mono = x === 'monaco' || x === 'html' - return