more tweaks

This commit is contained in:
mats committed 2026-11-01 12:11:23 +01:00
1 parent 2e0bf1e88e
commit 20528c8aef
9 files changed
+594 -256

No files matched your search

+28 -9
View File
@@ -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<State>({ s: 'idle' })
const [menu, setMenu] = useState(false)
const abort = useRef<AbortController | null>(null)
const corner = useRef<HTMLDivElement>(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 (
<div className="cms-ai">
<select className="cms-ai__pick" aria-label="Rewrite with AI" disabled={!value.trim()} value="" onChange={(e) => e.target.value && void run(e.target.value as RewriteAction)}>
<option value="">✨ Rewrite…</option>
{REWRITE_ACTIONS.map((a) => (
<option key={a.id} value={a.id}>
{a.label}
</option>
))}
</select>
<div className="cms-ai__corner" ref={corner}>
<button type="button" className={`cms-ai__btn${menu ? ' is-on' : ''}`} aria-label="Rewrite with AI" aria-haspopup="menu" aria-expanded={menu} title="Rewrite with AI" disabled={!value.trim()} onClick={() => setMenu((m) => !m)}>
<Icon name="assistant" size={14} />
</button>
{menu && (
<div className="cms-ai__menu" role="menu">
{REWRITE_ACTIONS.map((a) => (
<button key={a.id} type="button" role="menuitem" onClick={() => void run(a.id)}>
{a.label}
</button>
))}
</div>
)}
</div>
)
if (state.s === 'loading' || state.s === 'working')
+75
View File
@@ -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<string, Conversation>()
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<Conversation>) {
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<Controller, Conversations>()
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))
}
+41
View File
@@ -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))],
}
}
+238 -144
View File
@@ -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<string, unknown>; 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<string, unknown> {
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<ReturnType<Controller['node']>>; schema: JSONSchema }>
title(id: string, fallback: string): string
}
export type ToolFactory = (h: Helpers) => Tool
const invalid = (errors: Record<string, string>) => `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<string, unknown>) : {}
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<string, unknown>) : {}
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<boolean> {
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
+10 -8
View File
@@ -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 (
<>
<div className="cms-aiwrap">
<textarea className={`cms-input cms-input--area${mono ? ' cms-mono' : ''}`} rows={x === 'markdown' || x === 'richtext' ? 8 : 5} value={text} onChange={(e) => onChange(e.target.value)} spellCheck={!mono} />
{x !== 'monaco' && (
<Guard name="Rewrite">
<RewriteButton schema={s} value={text} onAccept={onChange} />
</Guard>
)}
</>
</div>
)
}
const type = ({ email: 'email', uri: 'url', url: 'url', date: 'date', time: 'time' } as Record<string, string>)[s.format] ?? 'text'
return (
<>
<input className={`cms-input${x === 'node' ? ' cms-mono' : ''}`} type={type} value={text} placeholder={x === 'node' ? 'node id' : s.default ?? s.examples?.[0] ?? ''} onChange={(e) => onChange(e.target.value)} />
<div className="cms-aiwrap">
<input className={`cms-input${x === 'node' ? ' cms-mono' : ''}`} type={type} value={text} placeholder={x === 'node' ? 'node id' : s.default ?? s.examples?.[0] ?? ''} onChange={(e) => onChange(e.target.value)} />
{!x && !s.format && !s.enum && (
<Guard name="Rewrite">
<RewriteButton schema={s} value={text} onAccept={onChange} />
</Guard>
)}
</div>
{x === 'asset' && looksLikeImage(text) && <img className="cms-thumb" src={text} alt="" />}
{!x && !s.format && !s.enum && (
<Guard name="Rewrite">
<RewriteButton schema={s} value={text} onAccept={onChange} />
</Guard>
)}
</>
)
}
+76 -82
View File
@@ -1,49 +1,51 @@
// Chat panel for the local AI assistant. The model can read the page and propose
// edits through tools (src/ai/tools.ts); proposals render as cards and are saved
// only when the user accepts them. Replies stream in as they are generated.
// Chat panel for the local AI assistant. One conversation per selected block (or
// the whole page when nothing is selected), kept in memory; the model only gets
// tools for that block and its descendants (src/ai/scope.ts, tools.ts). Replies
// stream in; proposals render as cards and are saved only on Accept.
import { useEffect, useRef, useState } from 'react'
import { runAgent, visibleText } from '../ai/agent'
import { conversationsFor, useConversation } from '../ai/conversations'
import type { ProposalStatus } from '../ai/conversations'
import { aiSupported, getEngine } from '../ai/engine'
import type { ChatMessage } from '../ai/engine'
import { createScope } from '../ai/scope'
import { applyProposal, createTools, describeProposal } from '../ai/tools'
import type { NewProposal, Proposal } from '../ai/tools'
import { Icon } from './Icon'
import { useCtrl, useEditorState } from './hooks'
type Status = 'pending' | 'applied' | 'rejected' | 'failed'
type Item =
| { kind: 'user' | 'assistant' | 'note'; text: string }
| { kind: 'tool'; name: string; args: string }
| { kind: 'proposal'; p: Proposal; status: Status }
const SYSTEM =
'You are an editorial assistant inside a website CMS editor. Help the user improve copy: tone, clarity, spelling, length, consistency. ' +
'Be brief. Read the real content with tools before suggesting changes, and use the propose_* tools for every concrete change. Keep the content language.'
'You are an editorial and layout assistant inside a website CMS editor. Help the user improve copy (tone, clarity, spelling, length, consistency) and layout/spacing, including per-breakpoint (mobile/tablet/desktop) styling. ' +
'You can only see and change the block you were scoped to and its descendants. Be brief. Read the real content with tools before suggesting changes, and use the propose_* tools for every concrete change. Keep the content language.'
const STARTERS = ['Check spelling and grammar on this block', 'Make this block more concise', 'Review the tone across the page', 'Suggest a better headline']
const BLOCK_STARTERS = ['Check spelling and grammar', 'Make this more concise', 'Fix the layout on mobile', 'Review the spacing']
const PAGE_STARTERS = ['Review the tone across the page', 'Suggest a better headline', 'Find inconsistent wording']
const HISTORY_LIMIT = 16
const TOOL_LABEL: Record<string, string> = {
get_page_outline: 'Reading page outline',
get_selected_node: 'Reading block',
get_page_text: 'Reading all page text',
get_outline: 'Reading structure',
get_node: 'Reading block',
get_text: 'Reading text',
find_text: 'Searching text',
select_block: 'Selecting block',
select_block: 'Highlighting block',
list_insertable_types: 'Looking up block types',
get_style: 'Reading style',
propose_set: 'Drafting an edit',
propose_style: 'Drafting a style change',
propose_insert: 'Drafting a new block',
propose_remove: 'Drafting a removal',
}
const show = (v: unknown) => (typeof v === 'string' ? v : (JSON.stringify(v) ?? ''))
function ProposalCard({ p, status, onResolve }: { p: Proposal; status: Status; onResolve(accept: boolean): void }) {
function ProposalCard({ p, status, onResolve }: { p: Proposal; status: ProposalStatus; onResolve(accept: boolean): void }) {
return (
<div className="cms-prop" data-status={status}>
<div className="cms-prop__head">{describeProposal(p)}</div>
{p.kind === 'set' && (
{(p.kind === 'set' || p.kind === 'style') && (
<>
<div className="cms-prop__del">{show(p.before) || '(empty)'}</div>
<div className="cms-prop__ins">{show(p.after)}</div>
<div className="cms-prop__ins">{show(p.after) || '(cleared)'}</div>
</>
)}
{p.kind === 'insert' && <div className="cms-prop__ins">{show(p.data)}</div>}
@@ -69,85 +71,73 @@ export function Assistant() {
const open = useEditorState((s) => s.assistant)
const selected = useEditorState((s) => s.selected)
useEditorState((s) => s.labelsVersion)
const [items, setItems] = useState<Item[]>([])
const store = conversationsFor(ctrl)
const scope = createScope(ctrl, selected)
const conv = useConversation(store, scope.key)
const [text, setText] = useState('')
const [busy, setBusy] = useState<string | null>(null)
const [live, setLive] = useState('')
const abort = useRef<AbortController | null>(null)
const nextId = useRef(1)
const end = useRef<HTMLDivElement>(null)
const input = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
end.current?.scrollIntoView?.({ block: 'end' })
}, [items, busy, live])
}, [conv.items, conv.busy, conv.live, scope.key])
useEffect(() => {
if (open) input.current?.focus()
}, [open])
useEffect(
() => () => {
abort.current?.abort()
},
[],
)
}, [open, scope.key])
if (!open || !aiSupported()) return null
const add = (...i: Item[]) => setItems((cur) => [...cur, ...i])
const stop = () => {
abort.current?.abort()
setBusy(null)
setLive('')
}
const { items, busy, live } = conv
const key = scope.key
const send = async (override?: string) => {
const q = (override ?? text).trim()
if (!q || busy) return
setText('')
const history: ChatMessage[] = [...items.flatMap((i): ChatMessage[] => (i.kind === 'user' || i.kind === 'assistant' ? [{ role: i.kind, content: i.text }] : [])), { role: 'user', content: q }]
add({ kind: 'user', text: q })
abort.current?.abort()
const ctl = (abort.current = new AbortController())
const live_ = () => !ctl.signal.aborted
const history: ChatMessage[] = [
...items.flatMap((i): ChatMessage[] => (i.kind === 'user' || i.kind === 'assistant' ? [{ role: i.kind, content: i.text }] : [])),
{ role: 'user' as const, content: q },
].slice(-HISTORY_LIMIT)
store.push(key, { kind: 'user', text: q })
const ctl = new AbortController()
store.update(key, () => ({ abort: ctl, busy: 'Loading model (first use only)…', live: '' }))
const mine = () => store.get(key).abort === ctl && !ctl.signal.aborted
const status = (busy: string | null, live?: string) => mine() && store.update(key, () => ({ busy, ...(live !== undefined ? { live } : {}) }))
try {
setBusy('Loading model (first use only)…')
const engine = await getEngine((p) => live_() && setBusy(`Loading model${p.ratio !== undefined ? ` ${Math.round(p.ratio * 100)}%` : '…'} (first use only)`))
const tools = createTools({ ctrl, propose: (p: NewProposal) => add({ kind: 'proposal', p: { ...p, id: nextId.current++ } as Proposal, status: 'pending' }) })
const sel = ctrl.store.get().selected
const system = `${SYSTEM}\nSelected block: ${sel ? `${sel} (${ctrl.block(sel)?.type ?? 'unknown'})` : 'none'}.`
setBusy('Thinking…')
const engine = await getEngine((p) => status(`Loading model${p.ratio !== undefined ? ` ${Math.round(p.ratio * 100)}%` : '…'} (first use only)`))
const tools = createTools({ ctrl, scope, propose: (p: NewProposal) => store.push(key, { kind: 'proposal', p: { ...p, id: store.nextProposalId() } as Proposal, status: 'pending' }) })
const system = `${SYSTEM}\nScope: ${scope.rootId ? `block ${scope.rootId} "${scope.label}" and its descendants` : 'the whole page'}.`
status('Thinking…')
const reply = await runAgent(engine, system, history, tools, {
signal: ctl.signal,
onText: (partial) => {
if (!live_()) return
const v = visibleText(partial)
setLive(v)
setBusy(v ? 'Writing…' : 'Thinking…')
status(v ? 'Writing…' : 'Thinking…', v)
},
onStep: (c) => {
if (!live_()) return
setLive('')
add({ kind: 'tool', name: c.name, args: Object.values(c.arguments).map(show).join(', ').slice(0, 60) })
setBusy(`${TOOL_LABEL[c.name] ?? c.name}…`)
if (!mine()) return
store.push(key, { kind: 'tool', name: c.name, args: Object.values(c.arguments).map(show).join(', ').slice(0, 60) })
status(`${TOOL_LABEL[c.name] ?? c.name}…`, '')
},
})
if (live_() && reply) add({ kind: 'assistant', text: reply })
if (mine() && reply) store.push(key, { kind: 'assistant', text: reply })
} catch (e) {
if ((e as Error).name !== 'AbortError') add({ kind: 'note', text: `Error: ${(e as Error).message}` })
if ((e as Error).name !== 'AbortError') store.push(key, { kind: 'note', text: `Error: ${(e as Error).message}` })
} finally {
if (abort.current === ctl) {
setBusy(null)
setLive('')
}
if (store.get(key).abort === ctl) store.update(key, () => ({ busy: null, live: '', abort: null }))
}
}
const stop = () => {
conv.abort?.abort()
store.update(key, () => ({ busy: null, live: '', abort: null }))
}
const resolve = async (idx: number, accept: boolean) => {
const it = items[idx]
if (it.kind !== 'proposal' || it.status !== 'pending') return
const status: Status = accept ? ((await applyProposal(ctrl, it.p)) ? 'applied' : 'failed') : 'rejected'
setItems((cur) => cur.map((x, i) => (i === idx && x.kind === 'proposal' ? { ...x, status } : x)))
const it = store.get(key).items[idx]
if (it?.kind !== 'proposal' || it.status !== 'pending') return
store.setStatus(key, idx, accept ? ((await applyProposal(ctrl, it.p)) ? 'applied' : 'failed') : 'rejected')
}
const pending = items.filter((i) => i.kind === 'proposal' && i.status === 'pending').length
const selType = selected ? ctrl.block(selected)?.type : undefined
const selTitle = selected ? (ctrl.labelFor(selected) ?? selType) : undefined
const others = store.working().filter((k) => k !== key).length
const starters = scope.rootId ? BLOCK_STARTERS : PAGE_STARTERS
return (
<aside className="cms-assistant" aria-label="AI assistant">
@@ -157,7 +147,7 @@ export function Assistant() {
</span>
<span>
{items.length > 0 && (
<button className="cms-ico" title="Clear conversation" aria-label="Clear conversation" onClick={() => (stop(), setItems([]))}>
<button className="cms-ico" title="Clear this conversation" aria-label="Clear conversation" onClick={() => store.clear(key)}>
<Icon name="reset" />
</button>
)}
@@ -166,12 +156,25 @@ export function Assistant() {
</button>
</span>
</header>
<div className="cms-scope" title={scope.rootId ?? undefined}>
<span>
Scope: <b>{scope.label}</b>
{scope.rootId && <small> + children</small>}
</span>
{scope.rootId && (
<button className="cms-link" onClick={() => ctrl.select(null)} title="Deselect to chat about the whole page">
Whole page
</button>
)}
</div>
<div className="cms-chat" role="log" aria-live="polite">
{items.length === 0 && (
<div className="cms-chat__empty">
<p className="cms-muted">Ask for editorial help. It reads your page, proposes changes, and saves nothing until you accept. Runs locally on your GPU.</p>
<p className="cms-muted">
{scope.rootId ? 'This chat only sees the selected block and what is inside it.' : 'This chat sees the whole page. Select a block to focus on it.'} Nothing is saved until you accept it. Each selection keeps its own conversation. Runs locally on your GPU.
</p>
<div className="cms-chips">
{STARTERS.map((s) => (
{starters.map((s) => (
<button key={s} className="cms-chip" onClick={() => void send(s)} disabled={!!busy}>
{s}
</button>
@@ -221,23 +224,14 @@ export function Assistant() {
void send()
}}
>
<div className="cms-ctx" title={selected ?? undefined}>
{selected ? (
<>
Context: <b>{selTitle}</b>
{selTitle !== selType && selType ? <small> {selType}</small> : null}
</>
) : (
'Context: whole page (select a block to focus)'
)}
</div>
{others > 0 && <div className="cms-ctx">Another conversation is still working in the background.</div>}
<div className="cms-compose">
<textarea
ref={input}
className="cms-input"
rows={2}
value={text}
placeholder="Ask about the selected block… (Enter to send, Shift+Enter for a new line)"
placeholder={`Ask about ${scope.rootId ? 'this block' : 'the page'}… (Enter to send)`}
aria-label="Message the assistant"
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
+17
View File
@@ -322,3 +322,20 @@ details[open] > summary > .cms-chev { transform: rotate(90deg); }
.cms-compose textarea { flex: 1; resize: none; }
@keyframes cms-blink { 50% { opacity: 0; } }
@keyframes cms-spin { to { transform: rotate(360deg); } }
/* AI rewrite: corner icon + popover menu */
.cms-aiwrap { position: relative; }
.cms-aiwrap:has(.cms-ai__corner) .cms-input { padding-right: 30px; }
.cms-ai__corner { position: absolute; top: 3px; right: 3px; z-index: 2; }
.cms-ai__btn { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; background: none; border: 0; border-radius: 5px; color: #8b8fa0; opacity: .6; }
.cms-aiwrap:hover .cms-ai__btn, .cms-ai__btn:focus-visible, .cms-ai__btn.is-on { opacity: 1; }
.cms-ai__btn:hover:not(:disabled), .cms-ai__btn.is-on { background: #242836; color: var(--cms-accent, #d2a24c); }
.cms-ai__btn:disabled { opacity: .25; cursor: default; }
.cms-ai__menu { position: absolute; right: 0; top: 26px; min-width: 170px; display: flex; flex-direction: column; padding: 4px; background: var(--cms-panel, #14161d); border: 1px solid var(--cms-line, #2a2d3a); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,.5); }
.cms-ai__menu button { background: none; border: 0; border-radius: 5px; padding: 5px 8px; text-align: left; }
.cms-ai__menu button:hover { background: #242836; }
.cms-scope { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 5px 12px; font-size: 12px; color: #8b8fa0; border-bottom: 1px solid var(--cms-line, #2a2d3a); }
.cms-scope b { color: var(--cms-accent, #d2a24c); font-weight: 600; }
.cms-scope > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cms-link { background: none; border: 0; padding: 0; color: #6aa7ff; font-size: 12px; text-decoration: underline; cursor: pointer; }
+6 -3
View File
@@ -34,7 +34,8 @@ describe('RewriteButton', () => {
generate.mockResolvedValue('Better text')
const onAccept = vi.fn()
render(<RewriteButton schema={{}} value="Bad txt" onAccept={onAccept} />)
fireEvent.change(screen.getByLabelText('Rewrite with AI'), { target: { value: 'fix' } })
fireEvent.click(screen.getByLabelText('Rewrite with AI'))
fireEvent.click(screen.getByText('Fix spelling & grammar'))
await screen.findByLabelText('AI suggestion')
expect(onAccept).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Accept'))
@@ -44,7 +45,8 @@ describe('RewriteButton', () => {
generate.mockResolvedValue('Other')
const onAccept = vi.fn()
render(<RewriteButton schema={{}} value="x" onAccept={onAccept} />)
fireEvent.change(screen.getByLabelText('Rewrite with AI'), { target: { value: 'improve' } })
fireEvent.click(screen.getByLabelText('Rewrite with AI'))
fireEvent.click(screen.getByText('Improve'))
fireEvent.click(await screen.findByText('Reject'))
expect(onAccept).not.toHaveBeenCalled()
await waitFor(() => screen.getByLabelText('Rewrite with AI'))
@@ -52,7 +54,8 @@ describe('RewriteButton', () => {
it('surfaces engine errors', async () => {
generate.mockRejectedValue(new Error('boom'))
render(<RewriteButton schema={{}} value="x" onAccept={() => {}} />)
fireEvent.change(screen.getByLabelText('Rewrite with AI'), { target: { value: 'improve' } })
fireEvent.click(screen.getByLabelText('Rewrite with AI'))
fireEvent.click(screen.getByText('Improve'))
expect((await screen.findByRole('alert')).textContent).toBe('boom')
})
})
+103 -10
View File
@@ -1,21 +1,29 @@
import { describe, expect, it, vi } from 'vitest'
import { parseToolCall, runAgent, visibleText } from '../src/ai/agent'
import type { Engine } from '../src/ai/engine'
import { applyProposal, createTools } from '../src/ai/tools'
import { applyProposal, createTools as makeTools } from '../src/ai/tools'
import { createScope } from '../src/ai/scope'
import { Conversations } from '../src/ai/conversations'
const schema = { type: 'object', properties: { title: { type: 'string', maxLength: 20 }, links: { type: 'array', items: { type: 'object', properties: { text: { type: 'string' } } } } } }
const node = { id: 'n1', dataType: 'hero', version: 3, data: { title: 'Hello wrold', links: [{ text: 'a' }] } }
const blocks: any[] = [
{ id: 'n1', type: 'hero', depth: 0 },
{ id: 'n2', type: 'text', depth: 1, parentId: 'n1' },
{ id: 'n3', type: 'text', depth: 0 },
]
function fakeCtrl() {
const setData = vi.fn(async () => ({}))
return {
setData,
ctrl: {
store: { get: () => ({ selected: 'n1', blocks: [{ id: 'n1', type: 'hero', depth: 0 }] }) },
block: (id: string) => (id === 'n1' ? { id } : undefined),
store: { get: () => ({ selected: 'n1', blocks }) },
block: (id: string) => blocks.find((b) => b.id === id),
select: vi.fn(),
schemaOf: () => schema,
node: async () => node,
node: async (id: string) => (id === 'n1' ? node : { id, dataType: 'text', version: 1, data: { title: 'child ' + id } }),
schema: async () => schema,
labelFor: () => 'Hero',
setData,
@@ -24,6 +32,8 @@ function fakeCtrl() {
}
const scripted = (replies: string[]): Engine => ({ generate: async () => replies.shift() ?? 'done' })
const createTools = (h: { ctrl: any; propose: (p: any) => void; root?: string | null }) => makeTools({ ctrl: h.ctrl, propose: h.propose, scope: createScope(h.ctrl, h.root === undefined ? 'n1' : h.root) })
describe('parseToolCall', () => {
it('reads tagged, unclosed and fenced calls; plain text is not a call', () => {
expect(parseToolCall('<tool_call>{"name":"a","arguments":{"x":1}}</tool_call>')).toEqual({ name: 'a', arguments: { x: 1 } })
@@ -41,7 +51,7 @@ describe('runAgent + tools', () => {
const tools = createTools({ ctrl, propose: (p) => proposals.push(p) })
const out = await runAgent(
scripted([
'<tool_call>{"name":"get_selected_node","arguments":{}}</tool_call>',
'<tool_call>{"name":"get_node","arguments":{}}</tool_call>',
'<tool_call>{"name":"propose_set","arguments":{"path":"title","value":"Hello world"}}</tool_call>',
'Fixed the typo.',
]),
@@ -74,7 +84,7 @@ describe('runAgent + tools', () => {
})
it('stops after maxSteps', async () => {
const { ctrl } = fakeCtrl()
const loop: Engine = { generate: async () => '<tool_call>{"name":"get_page_outline","arguments":{}}</tool_call>' }
const loop: Engine = { generate: async () => '<tool_call>{"name":"get_outline","arguments":{}}</tool_call>' }
expect(await runAgent(loop, 's', [], createTools({ ctrl, propose() {} }), { maxSteps: 2 })).toContain('too many steps')
})
})
@@ -99,13 +109,13 @@ describe('more tools', () => {
const t = createTools({ ctrl, propose() {} }).find((x) => x.name === 'select_block')!
await t.run({ id: 'n1' })
expect(ctrl.select).toHaveBeenCalledWith('n1', { scroll: true })
await expect(t.run({ id: 'zzz' })).rejects.toThrow('No block')
await expect(t.run({ id: 'zzz' })).rejects.toThrow('outside the current scope')
})
it('propose_remove queues a removal; applyProposal removes', async () => {
const { ctrl } = fakeCtrl()
ctrl.remove = vi.fn(async () => ({}))
const got: any[] = []
await createTools({ ctrl, propose: (p) => got.push(p) }).find((x) => x.name === 'propose_remove')!.run({ id: 'n1' })
await createTools({ ctrl, propose: (p) => got.push(p), root: null }).find((x) => x.name === 'propose_remove')!.run({ id: 'n1' })
expect(got[0]).toMatchObject({ kind: 'remove', nodeId: 'n1', summary: 'Hello wrold' })
await applyProposal(ctrl, { ...got[0], id: 1 })
expect(ctrl.remove).toHaveBeenCalledWith('n1')
@@ -118,8 +128,8 @@ describe('streaming', () => {
const seen: string[] = []
const engine: Engine = {
generate: async (_m, o) => {
for (const t of ['Let me ', 'look.<tool', '_call>{"name":"get_page_outline","arguments":{}}</tool_call>']) o?.onToken?.(t)
return 'Let me look.<tool_call>{"name":"get_page_outline","arguments":{}}</tool_call>'
for (const t of ['Let me ', 'look.<tool', '_call>{"name":"get_outline","arguments":{}}</tool_call>']) o?.onToken?.(t)
return 'Let me look.<tool_call>{"name":"get_outline","arguments":{}}</tool_call>'
},
}
let n = 0
@@ -130,3 +140,86 @@ describe('streaming', () => {
expect(seen.every((s) => !s.includes('<tool'))).toBe(true)
})
})
describe('scope', () => {
it('contains the root and descendants only; page scope contains every block', () => {
const { ctrl } = fakeCtrl()
const sc = createScope(ctrl, 'n1')
expect(sc.ids()).toEqual(['n1', 'n2'])
expect(sc.contains('n3')).toBe(false)
expect(sc.key).toBe('n1')
const page = createScope(ctrl, null)
expect(page.key).toBe('page')
expect(page.ids()).toEqual(['n1', 'n2', 'n3'])
})
it('tools refuse blocks outside the scope and default to the root', async () => {
const { ctrl } = fakeCtrl()
const tools = createTools({ ctrl, propose() {} })
const get = tools.find((t) => t.name === 'get_node')!
expect((await get.run({}) as any).id).toBe('n1')
expect((await get.run({ id: 'n2' }) as any).id).toBe('n2')
await expect(get.run({ id: 'n3' })).rejects.toThrow('outside the current scope')
const outline = (await tools.find((t) => t.name === 'get_outline')!.run({})) as any[]
expect(outline.map((b) => b.id)).toEqual(['n1', 'n2'])
await expect(createTools({ ctrl, propose() {}, root: null }).find((t) => t.name === 'get_node')!.run({})).rejects.toThrow('whole page')
})
it('propose_remove cannot delete the scope root but can delete children', async () => {
const { ctrl } = fakeCtrl()
const got: any[] = []
const rm = createTools({ ctrl, propose: (p) => got.push(p) }).find((t) => t.name === 'propose_remove')!
await expect(rm.run({ id: 'n1' })).rejects.toThrow('scope root')
await rm.run({ id: 'n2' })
expect(got[0]).toMatchObject({ kind: 'remove', nodeId: 'n2' })
})
})
describe('style tools', () => {
const gap = { name: 'gap', label: 'Gap', kind: 'gap', options: ['none', 'sm', 'md', 'lg'], responsive: ['mobile'] }
const styled = () => {
const { ctrl } = fakeCtrl()
ctrl.styleFields = () => [gap]
ctrl.doc = { defaultView: { innerWidth: 390 } }
ctrl.setStyle = vi.fn(async () => ({}))
node.data = { ...node.data, gap: 'lg', responsive: { mobile: { gap: 'md' } } } as any
return ctrl
}
it('get_style reports value and per-breakpoint overrides', async () => {
const ctrl = styled()
const r = (await createTools({ ctrl, propose() {} }).find((t) => t.name === 'get_style')!.run({})) as any
expect(r.viewportWidth).toBe(390)
expect(r.fields[0]).toMatchObject({ name: 'gap', value: 'lg', breakpoints: { mobile: 'md' } })
})
it('propose_style validates options and breakpoints, then queues and applies with bp', async () => {
const ctrl = styled()
const got: any[] = []
const t = createTools({ ctrl, propose: (p) => got.push(p) }).find((x) => x.name === 'propose_style')!
await expect(t.run({ field: 'gap', value: 'huge', breakpoint: 'mobile' })).rejects.toThrow('must be one of')
await expect(t.run({ field: 'gap', value: 'sm', breakpoint: 'tablet' })).rejects.toThrow('no tablet override')
await expect(t.run({ field: 'nope', value: 'sm' })).rejects.toThrow('Unknown style field')
await t.run({ field: 'gap', value: 'sm', breakpoint: 'mobile' })
expect(got[0]).toMatchObject({ kind: 'style', field: 'gap', bp: 'mobile', before: 'md', after: 'sm' })
await applyProposal(ctrl, { ...got[0], id: 1 })
expect(ctrl.setStyle).toHaveBeenCalledWith('n1', gap, 'sm', { bp: 'mobile' })
})
})
describe('conversations', () => {
it('keeps separate histories per scope key and notifies subscribers', () => {
const c = new Conversations()
const spy = vi.fn()
c.subscribe(spy)
c.push('n1', { kind: 'user', text: 'a' })
c.push('n2', { kind: 'user', text: 'b' })
expect(c.get('n1').items).toHaveLength(1)
expect(c.get('n2').items[0]).toMatchObject({ text: 'b' })
expect(c.get('n1')).toBe(c.get('n1')) // stable snapshot
expect(spy).toHaveBeenCalledTimes(2)
const ctl = new AbortController()
c.update('n1', () => ({ abort: ctl, busy: 'x' }))
expect(c.working()).toEqual(['n1'])
c.clear('n1')
expect(ctl.signal.aborted).toBe(true)
expect(c.get('n1').items).toHaveLength(0)
expect(c.get('n2').items).toHaveLength(1)
})
})