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). // Save/undo behave exactly as for typing).
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { JSONSchema } from '../api' import type { JSONSchema } from '../api'
import { Icon } from '../ui/Icon'
import { aiSupported, getEngine } from './engine' import { aiSupported, getEngine } from './engine'
import { cleanReply, REWRITE_ACTIONS, rewriteMessages } from './rewrite' import { cleanReply, REWRITE_ACTIONS, rewriteMessages } from './rewrite'
import type { RewriteAction } 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 }) { 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 [state, setState] = useState<State>({ s: 'idle' })
const [menu, setMenu] = useState(false)
const abort = useRef<AbortController | null>(null) 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( useEffect(
() => () => { () => () => {
abort.current?.abort() abort.current?.abort()
@@ -28,6 +42,7 @@ export function RewriteButton({ schema, value, label, onAccept }: { schema: JSON
const run = async (action: RewriteAction) => { const run = async (action: RewriteAction) => {
abort.current?.abort() abort.current?.abort()
setMenu(false)
const ctl = (abort.current = new AbortController()) const ctl = (abort.current = new AbortController())
try { try {
setState({ s: 'loading' }) setState({ s: 'loading' })
@@ -49,15 +64,19 @@ export function RewriteButton({ schema, value, label, onAccept }: { schema: JSON
if (state.s === 'idle') if (state.s === 'idle')
return ( return (
<div className="cms-ai"> <div className="cms-ai__corner" ref={corner}>
<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)}> <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)}>
<option value="">✨ Rewrite…</option> <Icon name="assistant" size={14} />
{REWRITE_ACTIONS.map((a) => ( </button>
<option key={a.id} value={a.id}> {menu && (
{a.label} <div className="cms-ai__menu" role="menu">
</option> {REWRITE_ACTIONS.map((a) => (
))} <button key={a.id} type="button" role="menuitem" onClick={() => void run(a.id)}>
</select> {a.label}
</button>
))}
</div>
)}
</div> </div>
) )
if (state.s === 'loading' || state.s === 'working') 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 // Editor tools for the assistant, generated per Scope (scope.ts): every tool can
// tool, `propose_set`, validates against the node's schema and QUEUES a proposal. // only see and touch the scope's root block and its descendants (or the whole page
// Nothing is saved until the user accepts it (see `applyProposal`). // 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 { JSONSchema } from '../api'
import type { Controller } from '../controller' import type { Controller } from '../controller'
import { validate } from '../form/validate' import { validate } from '../form/validate'
import { resolveSchema } from '../form/resolve' import { resolveSchema } from '../form/resolve'
import { collectStrings, getIn, parsePath, patchForPath } from '../page/infer' import { collectStrings, getIn, parsePath, patchForPath } from '../page/infer'
import { allowedChildren, typeName } from '../schema' import { allowedChildren, typeName } from '../schema'
import { BREAKPOINTS, readField } from '../style/model'
import type { Breakpoint } from '../style/model'
import type { Tool } from './agent' import type { Tool } from './agent'
import type { Scope } from './scope'
interface ProposalBase { interface ProposalBase {
id: number id: number
@@ -18,6 +24,7 @@ interface ProposalBase {
} }
export type Proposal = export type Proposal =
| (ProposalBase & { kind: 'set'; /** Dotted path, e.g. `title` or `links.0.text`. */ path: string; before: unknown; after: unknown }) | (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: 'insert'; type: string; data: Record<string, unknown>; index?: number })
| (ProposalBase & { kind: 'remove'; summary: string }) | (ProposalBase & { kind: 'remove'; summary: string })
/** What `propose` receives: a proposal without its id. */ /** What `propose` receives: a proposal without its id. */
@@ -43,162 +50,249 @@ export function summarizeSchema(schema: JSONSchema): Record<string, unknown> {
export interface ToolHost { export interface ToolHost {
ctrl: Controller ctrl: Controller
scope: Scope
/** Receives validated proposals. */ /** Receives validated proposals. */
propose(p: NewProposal): void propose(p: NewProposal): void
} }
export function createTools({ ctrl, propose }: ToolHost): Tool[] { interface Helpers extends ToolHost {
const target = (id?: string) => { /** The block a tool acts on: the given id, else the scope root. Throws when out of scope. */
const nodeId = id || ctrl.store.get().selected target(id?: unknown): string
if (!nodeId) throw new Error('No block is selected. Pass an "id" or ask the user to select a block.') describe(id: string): Promise<{ node: Awaited<ReturnType<Controller['node']>>; schema: JSONSchema }>
return nodeId 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) => { return toolFactories.map((f) => f(h))
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.' }
},
},
]
} }
/** One-line description of a proposal for the UI. */ /** One-line description of a proposal for the UI. */
export function describeProposal(p: Proposal): string { 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. */ /** 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> { 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 === '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 === '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 node = await ctrl.node(p.nodeId, true)
const patch = patchForPath(node.data ?? {}, parsePath(p.path), p.after) const patch = patchForPath(node.data ?? {}, parsePath(p.path), p.after)
return (await ctrl.setData(node.id, patch, { version: node.version })) !== null 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) { if (x && ['textarea', 'markdown', 'monaco', 'html', 'richtext'].includes(x) || (s.maxLength ?? 0) > 200) {
const mono = x === 'monaco' || x === 'html' const mono = x === 'monaco' || x === 'html'
return ( 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} /> <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' && ( {x !== 'monaco' && (
<Guard name="Rewrite"> <Guard name="Rewrite">
<RewriteButton schema={s} value={text} onAccept={onChange} /> <RewriteButton schema={s} value={text} onAccept={onChange} />
</Guard> </Guard>
)} )}
</> </div>
) )
} }
const type = ({ email: 'email', uri: 'url', url: 'url', date: 'date', time: 'time' } as Record<string, string>)[s.format] ?? 'text' const type = ({ email: 'email', uri: 'url', url: 'url', date: 'date', time: 'time' } as Record<string, string>)[s.format] ?? 'text'
return ( 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 === '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 // Chat panel for the local AI assistant. One conversation per selected block (or
// edits through tools (src/ai/tools.ts); proposals render as cards and are saved // the whole page when nothing is selected), kept in memory; the model only gets
// only when the user accepts them. Replies stream in as they are generated. // 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 { useEffect, useRef, useState } from 'react'
import { runAgent, visibleText } from '../ai/agent' 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 { aiSupported, getEngine } from '../ai/engine'
import type { ChatMessage } from '../ai/engine' import type { ChatMessage } from '../ai/engine'
import { createScope } from '../ai/scope'
import { applyProposal, createTools, describeProposal } from '../ai/tools' import { applyProposal, createTools, describeProposal } from '../ai/tools'
import type { NewProposal, Proposal } from '../ai/tools' import type { NewProposal, Proposal } from '../ai/tools'
import { Icon } from './Icon' import { Icon } from './Icon'
import { useCtrl, useEditorState } from './hooks' 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 = const SYSTEM =
'You are an editorial assistant inside a website CMS editor. Help the user improve copy: tone, clarity, spelling, length, consistency. ' + '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. ' +
'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 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> = { const TOOL_LABEL: Record<string, string> = {
get_page_outline: 'Reading page outline', get_outline: 'Reading structure',
get_selected_node: 'Reading block', get_node: 'Reading block',
get_page_text: 'Reading all page text', get_text: 'Reading text',
find_text: 'Searching text', find_text: 'Searching text',
select_block: 'Selecting block', select_block: 'Highlighting block',
list_insertable_types: 'Looking up block types', list_insertable_types: 'Looking up block types',
get_style: 'Reading style',
propose_set: 'Drafting an edit', propose_set: 'Drafting an edit',
propose_style: 'Drafting a style change',
propose_insert: 'Drafting a new block', propose_insert: 'Drafting a new block',
propose_remove: 'Drafting a removal', propose_remove: 'Drafting a removal',
} }
const show = (v: unknown) => (typeof v === 'string' ? v : (JSON.stringify(v) ?? '')) 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 ( return (
<div className="cms-prop" data-status={status}> <div className="cms-prop" data-status={status}>
<div className="cms-prop__head">{describeProposal(p)}</div> <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__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>} {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 open = useEditorState((s) => s.assistant)
const selected = useEditorState((s) => s.selected) const selected = useEditorState((s) => s.selected)
useEditorState((s) => s.labelsVersion) 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 [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 end = useRef<HTMLDivElement>(null)
const input = useRef<HTMLTextAreaElement>(null) const input = useRef<HTMLTextAreaElement>(null)
useEffect(() => { useEffect(() => {
end.current?.scrollIntoView?.({ block: 'end' }) end.current?.scrollIntoView?.({ block: 'end' })
}, [items, busy, live]) }, [conv.items, conv.busy, conv.live, scope.key])
useEffect(() => { useEffect(() => {
if (open) input.current?.focus() if (open) input.current?.focus()
}, [open]) }, [open, scope.key])
useEffect(
() => () => {
abort.current?.abort()
},
[],
)
if (!open || !aiSupported()) return null if (!open || !aiSupported()) return null
const add = (...i: Item[]) => setItems((cur) => [...cur, ...i]) const { items, busy, live } = conv
const stop = () => { const key = scope.key
abort.current?.abort()
setBusy(null)
setLive('')
}
const send = async (override?: string) => { const send = async (override?: string) => {
const q = (override ?? text).trim() const q = (override ?? text).trim()
if (!q || busy) return if (!q || busy) return
setText('') setText('')
const history: ChatMessage[] = [...items.flatMap((i): ChatMessage[] => (i.kind === 'user' || i.kind === 'assistant' ? [{ role: i.kind, content: i.text }] : [])), { role: 'user', content: q }] const history: ChatMessage[] = [
add({ kind: 'user', text: q }) ...items.flatMap((i): ChatMessage[] => (i.kind === 'user' || i.kind === 'assistant' ? [{ role: i.kind, content: i.text }] : [])),
abort.current?.abort() { role: 'user' as const, content: q },
const ctl = (abort.current = new AbortController()) ].slice(-HISTORY_LIMIT)
const live_ = () => !ctl.signal.aborted 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 { try {
setBusy('Loading model (first use only)…') const engine = await getEngine((p) => status(`Loading model${p.ratio !== undefined ? ` ${Math.round(p.ratio * 100)}%` : '…'} (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, scope, propose: (p: NewProposal) => store.push(key, { kind: 'proposal', p: { ...p, id: store.nextProposalId() } as Proposal, status: 'pending' }) })
const tools = createTools({ ctrl, propose: (p: NewProposal) => add({ kind: 'proposal', p: { ...p, id: nextId.current++ } as Proposal, status: 'pending' }) }) const system = `${SYSTEM}\nScope: ${scope.rootId ? `block ${scope.rootId} "${scope.label}" and its descendants` : 'the whole page'}.`
const sel = ctrl.store.get().selected status('Thinking…')
const system = `${SYSTEM}\nSelected block: ${sel ? `${sel} (${ctrl.block(sel)?.type ?? 'unknown'})` : 'none'}.`
setBusy('Thinking…')
const reply = await runAgent(engine, system, history, tools, { const reply = await runAgent(engine, system, history, tools, {
signal: ctl.signal, signal: ctl.signal,
onText: (partial) => { onText: (partial) => {
if (!live_()) return
const v = visibleText(partial) const v = visibleText(partial)
setLive(v) status(v ? 'Writing…' : 'Thinking…', v)
setBusy(v ? 'Writing…' : 'Thinking…')
}, },
onStep: (c) => { onStep: (c) => {
if (!live_()) return if (!mine()) return
setLive('') store.push(key, { kind: 'tool', name: c.name, args: Object.values(c.arguments).map(show).join(', ').slice(0, 60) })
add({ kind: 'tool', name: c.name, args: Object.values(c.arguments).map(show).join(', ').slice(0, 60) }) status(`${TOOL_LABEL[c.name] ?? c.name}…`, '')
setBusy(`${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) { } 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 { } finally {
if (abort.current === ctl) { if (store.get(key).abort === ctl) store.update(key, () => ({ busy: null, live: '', abort: null }))
setBusy(null)
setLive('')
}
} }
} }
const stop = () => {
conv.abort?.abort()
store.update(key, () => ({ busy: null, live: '', abort: null }))
}
const resolve = async (idx: number, accept: boolean) => { const resolve = async (idx: number, accept: boolean) => {
const it = items[idx] const it = store.get(key).items[idx]
if (it.kind !== 'proposal' || it.status !== 'pending') return if (it?.kind !== 'proposal' || it.status !== 'pending') return
const status: Status = accept ? ((await applyProposal(ctrl, it.p)) ? 'applied' : 'failed') : 'rejected' store.setStatus(key, idx, accept ? ((await applyProposal(ctrl, it.p)) ? 'applied' : 'failed') : 'rejected')
setItems((cur) => cur.map((x, i) => (i === idx && x.kind === 'proposal' ? { ...x, status } : x)))
} }
const pending = items.filter((i) => i.kind === 'proposal' && i.status === 'pending').length const pending = items.filter((i) => i.kind === 'proposal' && i.status === 'pending').length
const selType = selected ? ctrl.block(selected)?.type : undefined const others = store.working().filter((k) => k !== key).length
const selTitle = selected ? (ctrl.labelFor(selected) ?? selType) : undefined const starters = scope.rootId ? BLOCK_STARTERS : PAGE_STARTERS
return ( return (
<aside className="cms-assistant" aria-label="AI assistant"> <aside className="cms-assistant" aria-label="AI assistant">
@@ -157,7 +147,7 @@ export function Assistant() {
</span> </span>
<span> <span>
{items.length > 0 && ( {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" /> <Icon name="reset" />
</button> </button>
)} )}
@@ -166,12 +156,25 @@ export function Assistant() {
</button> </button>
</span> </span>
</header> </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"> <div className="cms-chat" role="log" aria-live="polite">
{items.length === 0 && ( {items.length === 0 && (
<div className="cms-chat__empty"> <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"> <div className="cms-chips">
{STARTERS.map((s) => ( {starters.map((s) => (
<button key={s} className="cms-chip" onClick={() => void send(s)} disabled={!!busy}> <button key={s} className="cms-chip" onClick={() => void send(s)} disabled={!!busy}>
{s} {s}
</button> </button>
@@ -221,23 +224,14 @@ export function Assistant() {
void send() void send()
}} }}
> >
<div className="cms-ctx" title={selected ?? undefined}> {others > 0 && <div className="cms-ctx">Another conversation is still working in the background.</div>}
{selected ? (
<>
Context: <b>{selTitle}</b>
{selTitle !== selType && selType ? <small> {selType}</small> : null}
</>
) : (
'Context: whole page (select a block to focus)'
)}
</div>
<div className="cms-compose"> <div className="cms-compose">
<textarea <textarea
ref={input} ref={input}
className="cms-input" className="cms-input"
rows={2} rows={2}
value={text} 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" aria-label="Message the assistant"
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
+17
View File
@@ -322,3 +322,20 @@ details[open] > summary > .cms-chev { transform: rotate(90deg); }
.cms-compose textarea { flex: 1; resize: none; } .cms-compose textarea { flex: 1; resize: none; }
@keyframes cms-blink { 50% { opacity: 0; } } @keyframes cms-blink { 50% { opacity: 0; } }
@keyframes cms-spin { to { transform: rotate(360deg); } } @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') generate.mockResolvedValue('Better text')
const onAccept = vi.fn() const onAccept = vi.fn()
render(<RewriteButton schema={{}} value="Bad txt" onAccept={onAccept} />) 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') await screen.findByLabelText('AI suggestion')
expect(onAccept).not.toHaveBeenCalled() expect(onAccept).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Accept')) fireEvent.click(screen.getByText('Accept'))
@@ -44,7 +45,8 @@ describe('RewriteButton', () => {
generate.mockResolvedValue('Other') generate.mockResolvedValue('Other')
const onAccept = vi.fn() const onAccept = vi.fn()
render(<RewriteButton schema={{}} value="x" onAccept={onAccept} />) 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')) fireEvent.click(await screen.findByText('Reject'))
expect(onAccept).not.toHaveBeenCalled() expect(onAccept).not.toHaveBeenCalled()
await waitFor(() => screen.getByLabelText('Rewrite with AI')) await waitFor(() => screen.getByLabelText('Rewrite with AI'))
@@ -52,7 +54,8 @@ describe('RewriteButton', () => {
it('surfaces engine errors', async () => { it('surfaces engine errors', async () => {
generate.mockRejectedValue(new Error('boom')) generate.mockRejectedValue(new Error('boom'))
render(<RewriteButton schema={{}} value="x" onAccept={() => {}} />) 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') expect((await screen.findByRole('alert')).textContent).toBe('boom')
}) })
}) })
+103 -10
View File
@@ -1,21 +1,29 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { parseToolCall, runAgent, visibleText } from '../src/ai/agent' import { parseToolCall, runAgent, visibleText } from '../src/ai/agent'
import type { Engine } from '../src/ai/engine' 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 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 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() { function fakeCtrl() {
const setData = vi.fn(async () => ({})) const setData = vi.fn(async () => ({}))
return { return {
setData, setData,
ctrl: { ctrl: {
store: { get: () => ({ selected: 'n1', blocks: [{ id: 'n1', type: 'hero', depth: 0 }] }) }, store: { get: () => ({ selected: 'n1', blocks }) },
block: (id: string) => (id === 'n1' ? { id } : undefined), block: (id: string) => blocks.find((b) => b.id === id),
select: vi.fn(), select: vi.fn(),
schemaOf: () => schema, schemaOf: () => schema,
node: async () => node, node: async (id: string) => (id === 'n1' ? node : { id, dataType: 'text', version: 1, data: { title: 'child ' + id } }),
schema: async () => schema, schema: async () => schema,
labelFor: () => 'Hero', labelFor: () => 'Hero',
setData, setData,
@@ -24,6 +32,8 @@ function fakeCtrl() {
} }
const scripted = (replies: string[]): Engine => ({ generate: async () => replies.shift() ?? 'done' }) 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', () => { describe('parseToolCall', () => {
it('reads tagged, unclosed and fenced calls; plain text is not a call', () => { 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 } }) 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 tools = createTools({ ctrl, propose: (p) => proposals.push(p) })
const out = await runAgent( const out = await runAgent(
scripted([ 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>', '<tool_call>{"name":"propose_set","arguments":{"path":"title","value":"Hello world"}}</tool_call>',
'Fixed the typo.', 'Fixed the typo.',
]), ]),
@@ -74,7 +84,7 @@ describe('runAgent + tools', () => {
}) })
it('stops after maxSteps', async () => { it('stops after maxSteps', async () => {
const { ctrl } = fakeCtrl() 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') 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')! const t = createTools({ ctrl, propose() {} }).find((x) => x.name === 'select_block')!
await t.run({ id: 'n1' }) await t.run({ id: 'n1' })
expect(ctrl.select).toHaveBeenCalledWith('n1', { scroll: true }) 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 () => { it('propose_remove queues a removal; applyProposal removes', async () => {
const { ctrl } = fakeCtrl() const { ctrl } = fakeCtrl()
ctrl.remove = vi.fn(async () => ({})) ctrl.remove = vi.fn(async () => ({}))
const got: any[] = [] 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' }) expect(got[0]).toMatchObject({ kind: 'remove', nodeId: 'n1', summary: 'Hello wrold' })
await applyProposal(ctrl, { ...got[0], id: 1 }) await applyProposal(ctrl, { ...got[0], id: 1 })
expect(ctrl.remove).toHaveBeenCalledWith('n1') expect(ctrl.remove).toHaveBeenCalledWith('n1')
@@ -118,8 +128,8 @@ describe('streaming', () => {
const seen: string[] = [] const seen: string[] = []
const engine: Engine = { const engine: Engine = {
generate: async (_m, o) => { generate: async (_m, o) => {
for (const t of ['Let me ', 'look.<tool', '_call>{"name":"get_page_outline","arguments":{}}</tool_call>']) o?.onToken?.(t) 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_page_outline","arguments":{}}</tool_call>' return 'Let me look.<tool_call>{"name":"get_outline","arguments":{}}</tool_call>'
}, },
} }
let n = 0 let n = 0
@@ -130,3 +140,86 @@ describe('streaming', () => {
expect(seen.every((s) => !s.includes('<tool'))).toBe(true) 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)
})
})