more tweaks
This commit is contained in:
1 parent
2e0bf1e88e
commit
20528c8aef
9 files changed
+594
-256
No files matched your search
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user