diff --git a/src/ai/RewriteButton.tsx b/src/ai/RewriteButton.tsx index a048d75..5f1e6e5 100644 --- a/src/ai/RewriteButton.tsx +++ b/src/ai/RewriteButton.tsx @@ -4,6 +4,7 @@ // Save/undo behave exactly as for typing). import { useEffect, useRef, useState } from 'react' import type { JSONSchema } from '../api' +import { Icon } from '../ui/Icon' import { aiSupported, getEngine } from './engine' import { cleanReply, REWRITE_ACTIONS, rewriteMessages } from './rewrite' import type { RewriteAction } from './rewrite' @@ -17,7 +18,20 @@ type State = export function RewriteButton({ schema, value, label, onAccept }: { schema: JSONSchema; value: string; label?: string; onAccept(v: string): void }) { const [state, setState] = useState({ s: 'idle' }) + const [menu, setMenu] = useState(false) const abort = useRef(null) + const corner = useRef(null) + useEffect(() => { + if (!menu) return + const away = (e: Event) => !e.composedPath().includes(corner.current!) && setMenu(false) + const esc = (e: KeyboardEvent) => e.key === 'Escape' && setMenu(false) + document.addEventListener('pointerdown', away) + document.addEventListener('keydown', esc) + return () => { + document.removeEventListener('pointerdown', away) + document.removeEventListener('keydown', esc) + } + }, [menu]) useEffect( () => () => { abort.current?.abort() @@ -28,6 +42,7 @@ export function RewriteButton({ schema, value, label, onAccept }: { schema: JSON const run = async (action: RewriteAction) => { abort.current?.abort() + setMenu(false) const ctl = (abort.current = new AbortController()) try { setState({ s: 'loading' }) @@ -49,15 +64,19 @@ export function RewriteButton({ schema, value, label, onAccept }: { schema: JSON if (state.s === 'idle') return ( -
- +
+ + {menu && ( +
+ {REWRITE_ACTIONS.map((a) => ( + + ))} +
+ )}
) if (state.s === 'loading' || state.s === 'working') diff --git a/src/ai/conversations.ts b/src/ai/conversations.ts new file mode 100644 index 0000000..a57412b --- /dev/null +++ b/src/ai/conversations.ts @@ -0,0 +1,75 @@ +// In-memory chat per scope (selected block or whole page). Lives as long as the +// editor does; nothing is persisted. A run keeps writing into its own conversation +// even if the user selects something else meanwhile. +import { useSyncExternalStore } from 'react' +import type { Controller } from '../controller' +import type { Proposal } from './tools' + +export type ProposalStatus = 'pending' | 'applied' | 'rejected' | 'failed' +export type Item = + | { kind: 'user' | 'assistant' | 'note'; text: string } + | { kind: 'tool'; name: string; args: string } + | { kind: 'proposal'; p: Proposal; status: ProposalStatus } + +export interface Conversation { + key: string + items: Item[] + /** Status line while a run is active, else null. */ + busy: string | null + /** Streamed text of the reply in progress. */ + live: string + abort: AbortController | null +} + +const MAX_ITEMS = 200 +const empty = (key: string): Conversation => ({ key, items: [], busy: null, live: '', abort: null }) + +export class Conversations { + private map = new Map() + private subs = new Set<() => void>() + private ids = 0 + + /** Current snapshot (stable until the conversation changes). */ + get = (key: string): Conversation => { + let c = this.map.get(key) + if (!c) this.map.set(key, (c = empty(key))) + return c + } + subscribe = (fn: () => void) => { + this.subs.add(fn) + return () => void this.subs.delete(fn) + } + update(key: string, fn: (c: Conversation) => Partial) { + const c = this.get(key) + this.map.set(key, { ...c, ...fn(c) }) + this.subs.forEach((f) => f()) + } + push(key: string, ...items: Item[]) { + this.update(key, (c) => ({ items: [...c.items, ...items].slice(-MAX_ITEMS) })) + } + setStatus(key: string, index: number, status: ProposalStatus) { + this.update(key, (c) => ({ items: c.items.map((x, i) => (i === index && x.kind === 'proposal' ? { ...x, status } : x)) })) + } + /** Stop any run and forget the conversation. */ + clear(key: string) { + this.get(key).abort?.abort() + this.map.delete(key) + this.subs.forEach((f) => f()) + } + nextProposalId = () => ++this.ids + /** Keys of conversations that currently have a run in progress (for a "working" hint). */ + working(): string[] { + return [...this.map.values()].filter((c) => c.busy).map((c) => c.key) + } +} + +const stores = new WeakMap() +export function conversationsFor(ctrl: Controller): Conversations { + let s = stores.get(ctrl) + if (!s) stores.set(ctrl, (s = new Conversations())) + return s +} + +export function useConversation(store: Conversations, key: string): Conversation { + return useSyncExternalStore(store.subscribe, () => store.get(key)) +} diff --git a/src/ai/scope.ts b/src/ai/scope.ts new file mode 100644 index 0000000..23d4c7d --- /dev/null +++ b/src/ai/scope.ts @@ -0,0 +1,41 @@ +// A Scope is "what the assistant may see and touch": one block and its descendants, +// or the whole page. Tools are generated per scope (tools.ts) and chats are kept +// per scope key (conversations.ts), so context stays small and edits stay inside +// what the user pointed at. +import type { Controller } from '../controller' + +export interface Scope { + /** Conversation key: the block id, or `page`. */ + key: string + /** The block the scope is rooted at; null = whole page. */ + rootId: string | null + /** Human label for the UI and prompt. */ + label: string + /** Is this block the root or one of its descendants? (Page scope: any block on the page.) */ + contains(id: string): boolean + /** Block ids in scope, in page order, root first. */ + ids(): string[] +} + +export const PAGE_KEY = 'page' + +export function createScope(ctrl: Controller, rootId: string | null): Scope { + const contains = (id: string): boolean => { + if (rootId === null) return !!ctrl.block(id) + let cur = ctrl.block(id) + for (let hops = 0; cur && hops < 64; hops++) { + if (cur.id === rootId) return true + cur = cur.parentId ? ctrl.block(cur.parentId) : undefined + } + return false + } + const type = rootId ? ctrl.block(rootId)?.type : undefined + const title = rootId ? ctrl.labelFor(rootId) : undefined + return { + key: rootId ?? PAGE_KEY, + rootId, + label: rootId ? `${title ?? type ?? rootId}${title && type && title !== type ? ` (${type})` : ''}` : 'Whole page', + contains, + ids: () => [...new Set(ctrl.store.get().blocks.filter((b) => contains(b.id)).map((b) => b.id))], + } +} diff --git a/src/ai/tools.ts b/src/ai/tools.ts index 4c463d7..0c7f430 100644 --- a/src/ai/tools.ts +++ b/src/ai/tools.ts @@ -1,13 +1,19 @@ -// 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`). +// Editor tools for the assistant, generated per Scope (scope.ts): every tool can +// only see and touch the scope's root block and its descendants (or the whole page +// for page scope). Read tools return compact JSON; `propose_*` tools validate and +// QUEUE a proposal. Nothing is saved until the user accepts it (`applyProposal`). +// +// Add a tool by appending a factory to `toolFactories`. 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 { BREAKPOINTS, readField } from '../style/model' +import type { Breakpoint } from '../style/model' import type { Tool } from './agent' +import type { Scope } from './scope' interface ProposalBase { id: number @@ -18,6 +24,7 @@ interface ProposalBase { } export type Proposal = | (ProposalBase & { kind: 'set'; /** Dotted path, e.g. `title` or `links.0.text`. */ path: string; before: unknown; after: unknown }) + | (ProposalBase & { kind: 'style'; field: string; fieldLabel: string; bp?: Breakpoint; 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. */ @@ -43,162 +50,249 @@ export function summarizeSchema(schema: JSONSchema): Record { export interface ToolHost { ctrl: Controller + scope: Scope /** 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 +interface Helpers extends ToolHost { + /** The block a tool acts on: the given id, else the scope root. Throws when out of scope. */ + target(id?: unknown): string + describe(id: string): Promise<{ node: Awaited>; schema: JSONSchema }> + title(id: string, fallback: string): string +} +export type ToolFactory = (h: Helpers) => Tool + +const invalid = (errors: Record) => `Invalid: ${Object.entries(errors).map(([p, m]) => `${p || 'value'}: ${m}`).join('; ')}` +const queued = { ok: true, note: 'Proposal queued for the user to accept or reject.' } + +export const toolFactories: ToolFactory[] = [ + ({ ctrl, scope }) => ({ + name: 'get_outline', + description: 'List the blocks in scope in page order with id, type, depth and title.', + params: 'none', + async run() { + const ids = new Set(scope.ids()) + return ctrl.store.get().blocks.filter((b) => ids.has(b.id)).slice(0, 120).map((b) => ({ id: b.id, type: b.type, depth: b.depth, ...(ctrl.labelFor(b.id) ? { title: ctrl.labelFor(b.id) } : {}) })) + }, + }), + ({ describe, target }) => ({ + name: 'get_node', + description: 'Get a block (default: the scope root): 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), + } + }, + }), + ({ ctrl, scope }) => ({ + name: 'get_text', + description: 'Get all editable text in scope, 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 id of scope.ids().slice(0, 60)) { + const n = await ctrl.node(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, type: n.dataType, text }) + } + return out + }, + }), + ({ ctrl, scope }) => ({ + name: 'find_text', + description: 'Find blocks in scope whose text contains a string (case-insensitive). Returns 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 id of scope.ids().slice(0, 80)) { + const n = await ctrl.node(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, type: n.dataType, path: l.path.join('.'), value: l.value.slice(0, 240) }) + if (hits.length >= 20) break + } + return hits + }, + }), + ({ ctrl, target }) => ({ + name: 'select_block', + description: 'Highlight a block in scope and scroll it into view so the user sees what you mean. (Does not change the conversation scope.)', + params: 'id (string)', + async run(args) { + const id = target(args.id) + ctrl.select(id, { scroll: true }) + return { ok: true } + }, + }), + ({ ctrl, scope }) => ({ + name: 'list_insertable_types', + description: 'List the block types that may be inserted inside a block (default: the scope root, or the page root), with their fields.', + params: 'parent_id (string, optional)', + async run(args) { + const parent = args.parent_id ? String(args.parent_id) : (scope.rootId ?? (await ctrl.pageRoot())) + if (scope.rootId && !scope.contains(parent)) throw new Error(`Block "${parent}" is outside the current scope`) + 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)) }))) + }, + }), + ({ ctrl, target }) => ({ + name: 'get_style', + description: + 'Get the visual style controls of a block (default: scope root): each field with its allowed options, current value and per-breakpoint (mobile/tablet/desktop) overrides. Use before changing layout or spacing.', + params: 'id (string, optional)', + async run(args) { + const id = target(args.id) + const node = await ctrl.node(id, true) + const fields = ctrl.styleFields(id) + if (!fields.length) return { id, type: node.dataType, fields: [], note: 'This block has no style controls.' } + const data = node.data ?? {} + return { + id, + type: node.dataType, + viewportWidth: ctrl.doc.defaultView?.innerWidth, + fields: fields.map((f) => ({ + name: f.name, + label: f.label, + kind: f.kind, + ...(f.options.length ? { options: f.options } : {}), + ...(f.min !== undefined ? { min: f.min } : {}), + ...(f.max !== undefined ? { max: f.max } : {}), + value: data[f.name] ?? null, + breakpoints: Object.fromEntries(f.responsive.map((bp) => [bp, readField(data, f, bp) ?? null])), + })), + } + }, + }), + ({ ctrl, target, title, describe, propose }) => ({ + name: 'propose_style', + description: + 'Propose a style change for ONE field of a block (default: scope root). Give "breakpoint" (mobile|tablet|desktop) to change only that screen size; omit it for the base value. value null clears it. The user must accept it.', + params: 'id (string, optional), field (string, a name from get_style), value (option, number or null), breakpoint (string, optional)', + async run(args) { + const id = target(args.id) + const field = ctrl.styleFields(id).find((f) => f.name === args.field) + if (!field) throw new Error(`Unknown style field "${args.field}". Fields: ${ctrl.styleFields(id).map((f) => f.name).join(', ') || '(none)'}`) + const bp = args.breakpoint ? (String(args.breakpoint) as Breakpoint) : undefined + if (bp && !BREAKPOINTS.includes(bp)) throw new Error(`"breakpoint" must be one of ${BREAKPOINTS.join(', ')}`) + if (bp && !field.responsive.includes(bp)) throw new Error(`"${field.name}" has no ${bp} override. Breakpoints available: ${field.responsive.join(', ') || 'none'}`) + const value = args.value === null || args.value === '' ? undefined : args.value + if (value !== undefined) { + if (field.options.length && !field.options.includes(value)) throw new Error(`"${field.name}" must be one of: ${field.options.join(', ')}`) + if (!field.options.length && typeof value === 'number' && ((field.min !== undefined && value < field.min) || (field.max !== undefined && value > field.max))) throw new Error(`"${field.name}" must be between ${field.min ?? '-∞'} and ${field.max ?? '∞'}`) + } + const { node } = await describe(id) + const before = readField(node.data ?? {}, field, bp) + if (JSON.stringify(before ?? null) === JSON.stringify(value ?? null)) return { ok: true, note: 'Value is already that; nothing to propose.' } + propose({ kind: 'style', nodeId: id, label: title(id, node.dataType), field: field.name, fieldLabel: field.label, ...(bp ? { bp } : {}), before, after: value ?? null }) + return queued + }, + }), + ({ target, title, describe, propose }) => ({ + name: 'propose_set', + description: 'Propose a new value for ONE data field of a block (default: scope root). Validated against the schema; the user must accept it.', + params: 'id (string, optional), 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) + if (!schema.properties?.[path[0] as string]) throw new Error(`Unknown field "${path[0]}". Fields: ${Object.keys(schema.properties ?? {}).join(', ')}`) + const data = node.data ?? {} + const errors = validate(schema, schema, { ...data, ...patchForPath(data, path, args.value) }) + if (Object.keys(errors).length) throw new Error(invalid(errors)) + 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: title(nodeId, node.dataType), path: args.path, before, after: args.value }) + return queued + }, + }), + ({ ctrl, scope, title, propose }) => ({ + name: 'propose_insert', + description: 'Propose adding a new block inside a parent (default: scope root, or page root). Data is validated against the type schema; the user must accept it.', + params: 'parent_id (string, optional), type (string), data (object), index (number, optional)', + async run(args) { + const parent = args.parent_id ? String(args.parent_id) : (scope.rootId ?? (await ctrl.pageRoot())) + if (scope.rootId && !scope.contains(parent)) throw new Error(`Block "${parent}" is outside the current scope`) + 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(errors)) + propose({ kind: 'insert', nodeId: parent, label: title(parent, pn.dataType), type, data, ...(typeof args.index === 'number' ? { index: args.index } : {}) }) + return queued + }, + }), + ({ ctrl, scope, target, title, propose }) => ({ + name: 'propose_remove', + description: 'Propose deleting a block inside the scope (not the scope root itself). The user must accept it; it can be undone.', + params: 'id (string)', + async run(args) { + const id = target(args.id) + if (id === scope.rootId) throw new Error('The scope root itself cannot be removed here; propose removing one of its children.') + const n = await ctrl.node(id) + const text = collectStrings(n.data ?? {}, ctrl.schemaOf(n.dataType))[0]?.value + propose({ kind: 'remove', nodeId: id, label: title(id, n.dataType), summary: text ? text.slice(0, 120) : n.dataType }) + return queued + }, + }), +] + +/** Generate the tool set for one scope. */ +export function createTools(host: ToolHost): Tool[] { + const { ctrl, scope } = host + const h: Helpers = { + ...host, + target(id) { + const v = id === undefined || id === null || id === '' ? scope.rootId : String(id) + if (!v) throw new Error('Pass an "id": this conversation covers the whole page.') + if (!scope.contains(v)) throw new Error(`Block "${v}" is outside the current scope (${scope.label}). Use get_outline to see the blocks in scope.`) + return v + }, + async describe(id) { + const node = await ctrl.node(id, true) + return { node, schema: await ctrl.schema(node.dataType) } + }, + title: (id, fallback) => ctrl.labelFor(id) ?? fallback, } - 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.' } - }, - }, - ] + return toolFactories.map((f) => f(h)) } /** 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}` + switch (p.kind) { + case 'set': + return `${p.label} · ${p.path}` + case 'style': + return `${p.label} · ${p.fieldLabel}${p.bp ? ` (${p.bp})` : ''}` + case 'insert': + return `Add ${p.type} inside ${p.label}` + case 'remove': + return `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 + if (p.kind === 'style') { + const field = ctrl.styleFields(p.nodeId).find((f) => f.name === p.field) + return !!field && (await ctrl.setStyle(p.nodeId, field, p.after ?? undefined, { bp: p.bp })) !== 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/form/Form.tsx b/src/form/Form.tsx index 54fa9c4..feef392 100644 --- a/src/form/Form.tsx +++ b/src/form/Form.tsx @@ -208,26 +208,28 @@ export function StringInput({ schema: s, value, onChange }: { schema: JSONSchema if (x && ['textarea', 'markdown', 'monaco', 'html', 'richtext'].includes(x) || (s.maxLength ?? 0) > 200) { const mono = x === 'monaco' || x === 'html' return ( - <> +