initial ai assistance

This commit is contained in:
mats committed 2026-11-01 10:27:56 +01:00
1 parent 1af9ec3172
commit 2e0bf1e88e
24 files changed
+1293 -60

No files matched your search

+3 -3
View File
@@ -100,9 +100,9 @@ export function createMockCms(opts: MockOptions = {}) {
return json(200, { ...res, patches }) return json(200, { ...res, patches })
} }
if (path === '/api/publish-roots' && m === 'POST') { if (path === '/api/publish-roots' && m === 'POST') {
const body = (await req.json()) as { roots: string[] } const body = (await req.json()) as { ids: string[] }
published.push(...body.roots) published.push(...body.ids)
return json(200, { published: body.roots }) return json(200, { publishedAt: new Date().toISOString(), results: body.ids.map((id) => ({ id })) })
} }
if (path === '/api/preview' && !previewApi) return json(404, { error: 'no such route' }) if (path === '/api/preview' && !previewApi) return json(404, { error: 'no such route' })
if (path === '/api/preview' || (m === 'GET' && req.headers.get('accept')?.includes('text/html'))) { if (path === '/api/preview' || (m === 'GET' && req.headers.get('accept')?.includes('text/html'))) {
+107
View File
@@ -0,0 +1,107 @@
// "Rewrite with AI" for a text field. Never writes to the field itself: the model's
// answer is shown as a before/after suggestion and only Accept calls `onAccept`,
// which goes through the normal form onChange (so validation, dirty-tracking and
// Save/undo behave exactly as for typing).
import { useEffect, useRef, useState } from 'react'
import type { JSONSchema } from '../api'
import { aiSupported, getEngine } from './engine'
import { cleanReply, REWRITE_ACTIONS, rewriteMessages } from './rewrite'
import type { RewriteAction } from './rewrite'
type State =
| { s: 'idle' }
| { s: 'loading'; ratio?: number }
| { s: 'working' }
| { s: 'suggest'; text: string }
| { s: 'error'; message: string }
export function RewriteButton({ schema, value, label, onAccept }: { schema: JSONSchema; value: string; label?: string; onAccept(v: string): void }) {
const [state, setState] = useState<State>({ s: 'idle' })
const abort = useRef<AbortController | null>(null)
useEffect(
() => () => {
abort.current?.abort()
},
[],
)
if (!aiSupported()) return null
const run = async (action: RewriteAction) => {
abort.current?.abort()
const ctl = (abort.current = new AbortController())
try {
setState({ s: 'loading' })
const engine = await getEngine((p) => !ctl.signal.aborted && setState({ s: 'loading', ratio: p.ratio }))
if (ctl.signal.aborted) return
setState({ s: 'working' })
const reply = await engine.generate(rewriteMessages(action, value, schema, label), { signal: ctl.signal })
if (ctl.signal.aborted) return
const text = cleanReply(reply, schema)
setState(text ? { s: 'suggest', text } : { s: 'error', message: 'The model returned nothing' })
} catch (e) {
if ((e as Error).name !== 'AbortError') setState({ s: 'error', message: (e as Error).message })
}
}
const reset = () => {
abort.current?.abort()
setState({ s: 'idle' })
}
if (state.s === 'idle')
return (
<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>
)
if (state.s === 'loading' || state.s === 'working')
return (
<div className="cms-ai" role="status">
<span className="cms-muted">
{state.s === 'working' ? 'Rewriting…' : `Loading model${state.ratio !== undefined ? ` ${Math.round(state.ratio * 100)}%` : '…'} (first use only)`}
</span>
<button type="button" className="cms-btn" onClick={reset}>
Cancel
</button>
</div>
)
if (state.s === 'error')
return (
<div className="cms-ai">
<span className="cms-error" role="alert">
{state.message}
</span>
<button type="button" className="cms-btn" onClick={reset}>
Dismiss
</button>
</div>
)
return (
<div className="cms-ai cms-ai--suggest">
<div className="cms-ai__text" aria-label="AI suggestion">
{state.text}
</div>
<div className="cms-ai__actions">
<button type="button" className="cms-btn" onClick={reset}>
Reject
</button>
<button
type="button"
className="cms-btn cms-btn--primary"
onClick={() => {
onAccept(state.text)
reset()
}}
>
Accept
</button>
</div>
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
// Tool-calling loop over any `Engine`. The protocol is plain text so it does not
// depend on the model's native function-calling template:
// <tool_call>{"name": "...", "arguments": {...}}</tool_call>
// Tool output goes back as a `<tool_result>` user message. Tool turns are
// transient: only the user message and the final answer survive into history.
import type { ChatMessage, Engine } from './engine'
export interface Tool {
name: string
description: string
/** One-line parameter hint shown to the model, e.g. `id (string, optional)`. */
params: string
run(args: Record<string, any>): Promise<unknown>
}
export interface ToolCall {
name: string
arguments: Record<string, any>
}
/** Pull a tool call out of a model reply; tolerates a missing closing tag and a ```json fence. */
export function parseToolCall(reply: string): ToolCall | null {
const tagged = reply.match(/<tool_call>\s*([\s\S]*?)\s*(?:<\/tool_call>|$)/)
const fenced = reply.match(/```(?:json|tool_call)?\s*(\{[\s\S]*?\})\s*```/)
const raw = tagged?.[1] ?? fenced?.[1]
if (!raw) return null
try {
const o = JSON.parse(raw.replace(/^```(?:json)?|```$/g, '').trim())
if (o && typeof o.name === 'string') return { name: o.name, arguments: o.arguments && typeof o.arguments === 'object' ? o.arguments : (o.args ?? {}) }
} catch {
/* malformed: treated as plain text */
}
return null
}
export function toolPrompt(tools: Tool[]): string {
return [
'You can call tools to read the page and to propose edits. To call a tool, reply with ONLY:',
'<tool_call>{"name": "<tool>", "arguments": {...}}</tool_call>',
'You will get the result in a <tool_result> message, then continue. Call one tool at a time.',
'When you are done, reply in plain text with no tool call. Never claim an edit was made: propose_set only proposes, the user accepts or rejects it.',
'Tools:',
...tools.map((t) => `- ${t.name}(${t.params}): ${t.description}`),
].join('\n')
}
export interface RunOptions {
maxSteps?: number
maxResultChars?: number
signal?: AbortSignal
/** Called before each tool runs, for progress display. */
onStep?(call: ToolCall): void
/** Streamed reply text for the current step (accumulated). Use `visibleText` to hide tool-call markup. */
onText?(partial: string): void
}
export async function runAgent(engine: Engine, system: string, history: ChatMessage[], tools: Tool[], opts: RunOptions = {}): Promise<string> {
const { maxSteps = 6, maxResultChars = 4000, signal } = opts
const byName = new Map(tools.map((t) => [t.name, t]))
const msgs: ChatMessage[] = [{ role: 'system', content: `${system}\n\n${toolPrompt(tools)}` }, ...history]
for (let step = 0; step < maxSteps; step++) {
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
let acc = ''
opts.onText?.('')
const reply = await engine.generate(msgs, {
maxTokens: 700,
signal,
onToken: (t) => opts.onText?.((acc += t)),
})
const call = parseToolCall(reply)
if (!call) return reply.trim()
opts.onStep?.(call)
const tool = byName.get(call.name)
let result: string
if (!tool) result = JSON.stringify({ error: `Unknown tool "${call.name}". Available: ${[...byName.keys()].join(', ')}` })
else {
try {
result = JSON.stringify(await tool.run(call.arguments))
} catch (e) {
result = JSON.stringify({ error: (e as Error).message })
}
}
if (result.length > maxResultChars) result = `${result.slice(0, maxResultChars)}…[truncated]`
msgs.push({ role: 'assistant', content: reply }, { role: 'user', content: `<tool_result name="${call.name}">${result}</tool_result>` })
}
return 'I stopped after too many steps. Try a more specific request.'
}
/** The part of a (partial) reply that is prose: everything before a tool call, including a half-typed `<tool_call`. */
export function visibleText(partial: string): string {
const i = partial.indexOf('<tool_call')
const head = i >= 0 ? partial.slice(0, i) : partial.replace(/<(t(o(o(l(_(c(a(l(l)?)?)?)?)?)?)?)?)?$/, '')
return head.trim()
}
+121
View File
@@ -0,0 +1,121 @@
// Local text generation on WebGPU (Gemma 4 via Transformers.js).
//
// Optional by construction: Transformers.js is NOT a dependency and is never
// bundled. It is `import()`ed from a URL the first time someone asks for AI, so
// the editor bundle is unchanged for everyone else. Override the URL (e.g. to a
// self-hosted copy under your CSP) with `configureAi({ transformersUrl })`.
//
// Everything above this file talks to the `Engine` interface, so a remote model
// or a local Ollama can be dropped in later without touching the UI.
export interface ChatMessage {
role: 'system' | 'user' | 'assistant'
content: string
}
export interface GenerateOptions {
maxTokens?: number
signal?: AbortSignal
/** Called with each decoded text chunk as it is generated. */
onToken?(delta: string): void
}
export interface Engine {
generate(messages: ChatMessage[], opts?: GenerateOptions): Promise<string>
}
export interface LoadProgress {
/** 0..1, or undefined while unknown. */
ratio?: number
file?: string
}
export interface AiConfig {
transformersUrl: string
model: string
dtype: string
}
const config: AiConfig = {
transformersUrl: 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@next',
model: 'onnx-community/gemma-4-E2B-it-ONNX',
dtype: 'q4f16',
}
export function configureAi(patch: Partial<AiConfig>): void {
Object.assign(config, patch)
loading = undefined // next use reloads with the new settings
}
/** Cheap synchronous check: is there a WebGPU adapter API at all? (shader-f16 is checked on load.) */
export function aiSupported(): boolean {
return typeof navigator !== 'undefined' && 'gpu' in navigator
}
async function assertF16(): Promise<void> {
const gpu = (navigator as any).gpu
const adapter = await gpu?.requestAdapter()
if (!adapter) throw new Error('No WebGPU adapter available')
if (!adapter.features?.has('shader-f16')) throw new Error('This GPU/browser lacks the shader-f16 feature the q4f16 model needs')
}
let loading: Promise<Engine> | undefined
let progressSink: ((p: LoadProgress) => void) | undefined
/** Load (once) and return the engine. Concurrent callers share one download; the latest `onProgress` wins. */
export function getEngine(onProgress?: (p: LoadProgress) => void): Promise<Engine> {
progressSink = onProgress
loading ??= load().catch((e) => {
loading = undefined // allow retry after a failure
throw e
})
return loading
}
async function load(): Promise<Engine> {
await assertF16()
const tf: any = await import(/* @vite-ignore */ config.transformersUrl)
const files = new Map<string, { loaded: number; total: number }>()
const generator = await tf.pipeline('text-generation', config.model, {
device: 'webgpu',
dtype: config.dtype,
progress_callback: (p: any) => {
if (p?.status !== 'progress' || !p.file) return
files.set(p.file, { loaded: p.loaded ?? 0, total: p.total ?? 0 })
let loaded = 0
let total = 0
for (const f of files.values()) (loaded += f.loaded), (total += f.total)
progressSink?.({ ratio: total ? loaded / total : undefined, file: p.file })
},
})
return {
async generate(messages, opts = {}) {
if (opts.signal?.aborted) throw new DOMException('Aborted', 'AbortError')
// Greedy decoding: edits should be repeatable, not creative.
const stop = new tf.InterruptableStoppingCriteria()
const onAbort = () => stop.interrupt()
opts.signal?.addEventListener('abort', onAbort)
const streamer = opts.onToken
? new tf.TextStreamer(generator.tokenizer, { skip_prompt: true, skip_special_tokens: true, callback_function: (t: string) => opts.onToken!(t) })
: undefined
let out: any
try {
out = await generator(messages, { max_new_tokens: opts.maxTokens ?? 512, do_sample: false, streamer, stopping_criteria: stop })
} finally {
opts.signal?.removeEventListener('abort', onAbort)
}
if (opts.signal?.aborted) throw new DOMException('Aborted', 'AbortError')
const text = out?.[0]?.generated_text
// Chat input returns the whole conversation; the reply is the last message.
return toText(Array.isArray(text) ? text.at(-1)?.content : text)
},
}
}
/** Message content may be a string or a list of parts ({type:'text', text}); always return a string. */
export function toText(c: unknown): string {
if (typeof c === 'string') return c
if (Array.isArray(c)) return c.map(toText).join('')
if (c && typeof c === 'object' && typeof (c as any).text === 'string') return (c as any).text
return ''
}
+42
View File
@@ -0,0 +1,42 @@
// Prompting for per-field rewrites. Pure, so it is testable without a model.
import type { JSONSchema } from '../api'
import type { ChatMessage } from './engine'
export type RewriteAction = 'improve' | 'shorten' | 'expand' | 'fix' | 'friendly' | 'formal'
export const REWRITE_ACTIONS: Array<{ id: RewriteAction; label: string; instruction: string }> = [
{ id: 'improve', label: 'Improve', instruction: 'Improve clarity and flow while keeping the meaning and length similar.' },
{ id: 'fix', label: 'Fix spelling & grammar', instruction: 'Fix spelling, grammar and punctuation only. Do not change wording otherwise.' },
{ id: 'shorten', label: 'Shorten', instruction: 'Make it noticeably shorter without losing the key message.' },
{ id: 'expand', label: 'Expand', instruction: 'Expand it slightly with relevant detail, without inventing facts.' },
{ id: 'friendly', label: 'Friendlier', instruction: 'Make the tone warmer and more conversational.' },
{ id: 'formal', label: 'More formal', instruction: 'Make the tone more formal and professional.' },
]
export function rewriteMessages(action: RewriteAction, text: string, schema: JSONSchema, label?: string): ChatMessage[] {
const a = REWRITE_ACTIONS.find((x) => x.id === action)!
const x = schema['x-editor'] as string | undefined
const format = x === 'markdown' || x === 'richtext' ? 'Preserve the Markdown formatting.' : x === 'html' ? 'Preserve the HTML tags and structure.' : 'Output plain text.'
const limit = schema.maxLength ? ` The result must be at most ${schema.maxLength} characters.` : ''
return [
{
role: 'system',
content:
'You are an editorial assistant for website copy. Reply with ONLY the rewritten text: no quotes, no preamble, no explanation. ' +
'Keep the original language. Do not add facts that are not in the text. ' +
`${format}${limit}`,
},
{ role: 'user', content: `${label ? `Field: ${label}\n` : ''}Task: ${a.instruction}\n\nText:\n${text}` },
]
}
/** Models like to wrap answers; undo the common wrappers. */
export function cleanReply(raw: string, schema: JSONSchema): string {
let t = raw.trim()
const fence = t.match(/^```[a-z]*\n([\s\S]*?)\n```$/i)
if (fence) t = fence[1].trim()
const quoted = t.match(/^"([^"]*)"$/s) ?? t.match(/^“([^”]*)”$/s)
if (quoted) t = quoted[1].trim()
if (schema.maxLength && t.length > schema.maxLength) t = t.slice(0, schema.maxLength).trimEnd()
return t
}
+205
View File
@@ -0,0 +1,205 @@
// Editor tools for the assistant. Read tools return compact JSON; the only write
// tool, `propose_set`, validates against the node's schema and QUEUES a proposal.
// Nothing is saved until the user accepts it (see `applyProposal`).
import type { JSONSchema } from '../api'
import type { Controller } from '../controller'
import { validate } from '../form/validate'
import { resolveSchema } from '../form/resolve'
import { collectStrings, getIn, parsePath, patchForPath } from '../page/infer'
import { allowedChildren, typeName } from '../schema'
import type { Tool } from './agent'
interface ProposalBase {
id: number
/** The node it concerns (parent for inserts). */
nodeId: string
/** Node title/type for display. */
label: string
}
export type Proposal =
| (ProposalBase & { kind: 'set'; /** Dotted path, e.g. `title` or `links.0.text`. */ path: string; before: unknown; after: unknown })
| (ProposalBase & { kind: 'insert'; type: string; data: Record<string, unknown>; index?: number })
| (ProposalBase & { kind: 'remove'; summary: string })
/** What `propose` receives: a proposal without its id. */
export type NewProposal = Proposal extends infer P ? (P extends Proposal ? Omit<P, 'id'> : never) : never
/** Property summary of a schema: enough for the model to know what exists, small enough for a 2B model. */
export function summarizeSchema(schema: JSONSchema): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [k, raw] of Object.entries<JSONSchema>(schema.properties ?? {})) {
const s = resolveSchema(schema, raw).schema
if (s['x-editor'] === 'hidden' || s.readOnly) continue
const t = Array.isArray(s.type) ? s.type.find((x: string) => x !== 'null') : s.type
out[k] = {
type: t ?? 'object',
...(s.title ? { title: s.title } : {}),
...(s.maxLength ? { maxLength: s.maxLength } : {}),
...(s.enum ? { enum: s.enum } : {}),
...(s['x-editor'] ? { editor: s['x-editor'] } : {}),
}
}
return out
}
export interface ToolHost {
ctrl: Controller
/** Receives validated proposals. */
propose(p: NewProposal): void
}
export function createTools({ ctrl, propose }: ToolHost): Tool[] {
const target = (id?: string) => {
const nodeId = id || ctrl.store.get().selected
if (!nodeId) throw new Error('No block is selected. Pass an "id" or ask the user to select a block.')
return nodeId
}
const describe = async (id: string) => {
const node = await ctrl.node(id, true)
return { node, schema: await ctrl.schema(node.dataType) }
}
return [
{
name: 'get_page_outline',
description: 'List the blocks on the page in order with their ids, types, depth and title.',
params: 'none',
async run() {
return ctrl.store.get().blocks.slice(0, 120).map((b) => ({ id: b.id, type: b.type, depth: b.depth, ...(ctrl.labelFor(b.id) ? { title: ctrl.labelFor(b.id) } : {}) }))
},
},
{
name: 'get_selected_node',
description: 'Get the block the user has selected (or the given id): type, data, editable text fields (with paths) and the field schema.',
params: 'id (string, optional)',
async run(args) {
const { node, schema } = await describe(target(args.id))
return {
id: node.id,
type: node.dataType,
data: node.data,
textFields: collectStrings(node.data ?? {}, schema).map((l) => ({ path: l.path.join('.'), value: l.value })),
fields: summarizeSchema(schema),
}
},
},
{
name: 'get_page_text',
description: 'Get all editable text on the page, block by block (id, type, path, value). Use it for tone/consistency checks.',
params: 'none',
async run() {
const out: Array<{ id: string; type: string; text: Array<{ path: string; value: string }> }> = []
for (const b of ctrl.store.get().blocks.slice(0, 60)) {
const n = await ctrl.node(b.id).catch(() => null)
if (!n) continue
const text = collectStrings(n.data ?? {}, ctrl.schemaOf(n.dataType)).map((l) => ({ path: l.path.join('.'), value: l.value.slice(0, 240) }))
if (text.length) out.push({ id: b.id, type: n.dataType, text })
}
return out
},
},
{
name: 'find_text',
description: 'Find blocks whose text contains a string (case-insensitive). Returns matching block ids, paths and the text.',
params: 'query (string)',
async run(args) {
const q = String(args.query ?? '').toLowerCase()
if (!q) throw new Error('"query" is required')
const hits: Array<{ id: string; type: string; path: string; value: string }> = []
for (const b of ctrl.store.get().blocks.slice(0, 80)) {
const n = await ctrl.node(b.id).catch(() => null)
if (!n) continue
for (const l of collectStrings(n.data ?? {}, ctrl.schemaOf(n.dataType))) if (l.value.toLowerCase().includes(q)) hits.push({ id: n.id, type: n.dataType, path: l.path.join('.'), value: l.value.slice(0, 240) })
if (hits.length >= 20) break
}
return hits
},
},
{
name: 'select_block',
description: 'Select a block and scroll it into view so the user sees what you are talking about.',
params: 'id (string)',
async run(args) {
if (!ctrl.block(String(args.id))) throw new Error(`No block "${args.id}" on this page`)
ctrl.select(String(args.id), { scroll: true })
return { ok: true }
},
},
{
name: 'list_insertable_types',
description: 'List the block types that may be inserted inside a block (default: the page root), and the fields of each.',
params: 'parent_id (string, optional)',
async run(args) {
const parent = args.parent_id ? String(args.parent_id) : await ctrl.pageRoot()
const pn = await ctrl.node(parent)
const types = allowedChildren(await ctrl.schema(pn.dataType), (await ctrl.api.types()).map(typeName)).filter((t) => !ctrl.isStaticRoot(t))
return Promise.all(types.slice(0, 25).map(async (t) => ({ type: t, fields: summarizeSchema(await ctrl.schema(t)) })))
},
},
{
name: 'propose_insert',
description: 'Propose adding a new block inside a parent. Data is validated against the type schema; the user must accept it.',
params: 'parent_id (string, optional: page root), type (string), data (object), index (number, optional)',
async run(args) {
const parent = args.parent_id ? String(args.parent_id) : await ctrl.pageRoot()
const type = String(args.type ?? '')
const pn = await ctrl.node(parent)
const allowed = allowedChildren(await ctrl.schema(pn.dataType), (await ctrl.api.types()).map(typeName))
if (!allowed.includes(type)) throw new Error(`"${type}" cannot be inserted there. Allowed: ${allowed.join(', ')}`)
const data = args.data && typeof args.data === 'object' ? (args.data as Record<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. */
export function describeProposal(p: Proposal): string {
return p.kind === 'set' ? `${p.label} · ${p.path}` : p.kind === 'insert' ? `Add ${p.type} inside ${p.label}` : `Delete ${p.label}`
}
/** Apply an accepted proposal. Re-reads the node first so earlier accepted proposals are not overwritten. */
export async function applyProposal(ctrl: Controller, p: Proposal): Promise<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
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
}
+1 -1
View File
@@ -139,7 +139,7 @@ export class Api {
node = (id: string) => this.get<CmsNode>(`/api/nodes/${encodeURIComponent(id)}`) node = (id: string) => this.get<CmsNode>(`/api/nodes/${encodeURIComponent(id)}`)
edges = (id: string) => this.get<NodeEdges>(`/api/nodes/${encodeURIComponent(id)}/edges`) edges = (id: string) => this.get<NodeEdges>(`/api/nodes/${encodeURIComponent(id)}/edges`)
edit = (ops: Op[]) => this.post<EditResult>('/api/edit', { ops }) edit = (ops: Op[]) => this.post<EditResult>('/api/edit', { ops })
publish = (roots: string[]) => this.post('/api/publish-roots', { roots }) publish = (ids: string[]) => this.post<{ publishedAt?: string; results?: { id: string; error?: string }[] }>('/api/publish-roots', { ids })
/** Schemas are code: cache for the session. */ /** Schemas are code: cache for the session. */
schema(type: string): Promise<JSONSchema> { schema(type: string): Promise<JSONSchema> {
+9 -4
View File
@@ -33,7 +33,7 @@ export interface DialogInstance {
size: 'sm' | 'md' | 'lg' size: 'sm' | 'md' | 'lg'
content: ReactNode content: ReactNode
/** Page-space rect of the block it belongs to, for initial placement. */ /** Page-space rect of the block it belongs to, for initial placement. */
anchor?: { x: number; y: number } anchor?: { x: number; y: number; right?: number; bottom?: number }
/** Identity for de-duplication: opening a dialog whose id is already open focuses the existing one. */ /** Identity for de-duplication: opening a dialog whose id is already open focuses the existing one. */
id?: string id?: string
/** Stacking order (higher = on top); raised when the dialog is opened again or clicked. */ /** Stacking order (higher = on top); raised when the dialog is opened again or clicked. */
@@ -66,6 +66,8 @@ export interface EditorState {
/** Left rail: page outline or the block palette. */ /** Left rail: page outline or the block palette. */
rail: 'outline' | 'blocks' | null rail: 'outline' | 'blocks' | null
dev: boolean dev: boolean
/** AI assistant panel open. */
assistant: boolean
inline: { id: string; field: string } | null inline: { id: string; field: string } | null
log: RequestLog[] log: RequestLog[]
/** Page root node id, learned from the first top-level block's parent edge. */ /** Page root node id, learned from the first top-level block's parent edge. */
@@ -118,6 +120,7 @@ export class Controller {
toast: null, toast: null,
rail: null, rail: null,
dev: false, dev: false,
assistant: false,
inline: null, inline: null,
log: [], log: [],
rootId: null, rootId: null,
@@ -700,7 +703,9 @@ export class Controller {
async publish() { async publish() {
try { try {
const rootId = await this.pageRoot() const rootId = await this.pageRoot()
await this.api.publish([rootId]) const res = await this.api.publish([rootId])
const failed = res?.results?.find((r) => r.error)
if (failed) throw new Error(failed.error)
this.store.set({ dirty: false }) this.store.set({ dirty: false })
this.toast('info', 'Published') this.toast('info', 'Published')
} catch (e) { } catch (e) {
@@ -1047,7 +1052,7 @@ export class Controller {
return existing.key return existing.key
} }
const key = ++this.dialogKey const key = ++this.dialogKey
this.store.set((s) => ({ dialogs: [...s.dialogs, { size: 'md', ...d, key, z: ++this.zCounter, pulse: 0 }] })) this.store.set((s) => ({ dialogs: [...s.dialogs, { ...d, size: d.size ?? 'md', key, z: ++this.zCounter, pulse: 0 }] }))
return key return key
} }
/** Bring a dialog to the front; `pulse` also flashes it and moves focus into it. */ /** Bring a dialog to the front; `pulse` also flashes it and moves focus into it. */
@@ -1086,7 +1091,7 @@ export class Controller {
id: dialogId, id: dialogId,
title: `${d.label} · ${this.labelFor(id) ?? node.dataType}`, title: `${d.label} · ${this.labelFor(id) ?? node.dataType}`,
size: d.size, size: d.size,
anchor: { x: r.right, y: r.top }, anchor: { x: r.left, y: r.top, right: r.right, bottom: r.bottom },
content: createElement(d.component, { info, node, schema, ctrl: this, close: () => this.closeDialog(key) }), content: createElement(d.component, { info, node, schema, ctrl: this, close: () => this.closeDialog(key) }),
}) })
} catch (e) { } catch (e) {
+1 -1
View File
@@ -25,5 +25,5 @@ const auth = new AuthManager({
void fetch('/__dev/token', t ? { method: 'POST', body: JSON.stringify({ accessToken: t.accessToken }) } : { method: 'DELETE' }).catch(() => {}), void fetch('/__dev/token', t ? { method: 'POST', body: JSON.stringify({ accessToken: t.accessToken }) } : { method: 'DELETE' }).catch(() => {}),
}) })
auth.announce() auth.announce()
const handle = boot({ dev: true, api: new Api({ auth }) }) const handle = boot({ api: new Api({ auth }) })
if (import.meta.hot) import.meta.hot.dispose(() => handle.destroy()) if (import.meta.hot) import.meta.hot.dispose(() => handle.destroy())
+19 -9
View File
@@ -2,7 +2,15 @@ import { useEffect, useRef, useState } from 'react'
import type { Controller } from '../controller' import type { Controller } from '../controller'
import { Icon } from '../ui/Icon' import { Icon } from '../ui/Icon'
const LABEL: Record<string, string> = { href: 'Link', src: 'Source', alt: 'Alt text', title: 'Title', placeholder: 'Placeholder', 'aria-label': 'Accessible label', value: 'Value' } const LABEL: Record<string, string> = {
href: 'Link',
src: 'Source',
alt: 'Alt text',
title: 'Title',
placeholder: 'Placeholder',
'aria-label': 'Accessible label',
value: 'Value',
}
/** Tiny editor for a property that is rendered as an attribute (href, src, alt…), opened from its chip. */ /** Tiny editor for a property that is rendered as an attribute (href, src, alt…), opened from its chip. */
export function AttrEditor({ id, path, attr, initial, ctrl, close }: { id: string; path: string; attr: string; initial: string; ctrl: Controller; close(): void }) { export function AttrEditor({ id, path, attr, initial, ctrl, close }: { id: string; path: string; attr: string; initial: string; ctrl: Controller; close(): void }) {
@@ -24,14 +32,16 @@ export function AttrEditor({ id, path, attr, initial, ctrl, close }: { id: strin
</span> </span>
<input ref={input} className="cms-input" value={v} onChange={(e) => setV(e.target.value)} /> <input ref={input} className="cms-input" value={v} onChange={(e) => setV(e.target.value)} />
</label> </label>
<footer className="cms-actions"> {v !== initial && (
<button type="button" className="cms-btn" onClick={close}> <footer className="cms-actions">
Cancel <button type="button" className="cms-btn" onClick={close}>
</button> Cancel
<button type="submit" className="cms-btn cms-btn--primary" disabled={v === initial}> </button>
<Icon name="save" size={16} /> Save <button type="submit" className="cms-btn cms-btn--primary">
</button> <Icon name="save" size={16} /> Save
</footer> </button>
</footer>
)}
</form> </form>
) )
} }
+72 -28
View File
@@ -41,19 +41,21 @@ function Properties({ node, schema, ctrl, close }: EditorProps) {
}} }}
> >
<SchemaForm schema={schema} value={draft} onChange={setDraft} node={node} ctrl={ctrl} /> <SchemaForm schema={schema} value={draft} onChange={setDraft} node={node} ctrl={ctrl} />
<footer className="cms-actions"> {changed && (
<button type="button" className="cms-btn" onClick={() => setDraft(node.data ?? {})} disabled={!changed}> <footer className="cms-actions">
<Icon name="reset" size={16} /> Reset <button type="button" className="cms-btn" onClick={() => setDraft(node.data ?? {})}>
</button> <Icon name="reset" size={16} /> Reset
{problems.length > 0 && ( </button>
<span className="cms-error cms-actions__note" role="alert" title={problems.map(([p, m]) => `${p || 'form'}: ${m}`).join('\n')}> {problems.length > 0 && (
{problems.length} problem{problems.length === 1 ? '' : 's'} to fix <span className="cms-error cms-actions__note" role="alert" title={problems.map(([p, m]) => `${p || 'form'}: ${m}`).join('\n')}>
</span> {problems.length} problem{problems.length === 1 ? '' : 's'} to fix
)} </span>
<button type="submit" className="cms-btn cms-btn--primary" disabled={!changed || saving || problems.length > 0}> )}
<Icon name="save" size={16} /> {saving ? 'Saving…' : 'Save'} <button type="submit" className="cms-btn cms-btn--primary" disabled={saving || problems.length > 0}>
</button> <Icon name="save" size={16} /> {saving ? 'Saving…' : 'Save'}
</footer> </button>
</footer>
)}
</form> </form>
) )
} }
@@ -66,15 +68,13 @@ function RawJson({ node, ctrl, close }: EditorProps) {
return ( return (
<div className="cms-form"> <div className="cms-form">
<JsonArea value={node.data ?? {}} onChange={setDraft} /> <JsonArea value={node.data ?? {}} onChange={setDraft} />
<footer className="cms-actions"> {Object.keys(patch).length > 0 && (
<button <footer className="cms-actions">
className="cms-btn cms-btn--primary" <button className="cms-btn cms-btn--primary" onClick={async () => (await ctrl.setData(node.id, patch, { version: node.version })) && close()}>
disabled={!Object.keys(patch).length} <Icon name="save" size={16} /> Save
onClick={async () => (await ctrl.setData(node.id, patch, { version: node.version })) && close()} </button>
> </footer>
<Icon name="save" size={16} /> Save )}
</button>
</footer>
</div> </div>
) )
} }
@@ -155,7 +155,10 @@ function AddChild({ info, ctrl, close }: EditorProps) {
} }
function AddAfter({ info, ctrl, close }: EditorProps) { function AddAfter({ info, ctrl, close }: EditorProps) {
const [target, setTarget] = useState<{ parent: string; index: number } | null>(null) const [target, setTarget] = useState<{
parent: string
index: number
} | null>(null)
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
const parent = await ctrl.parentIdOf(info.id) const parent = await ctrl.parentIdOf(info.id)
@@ -168,12 +171,53 @@ function AddAfter({ info, ctrl, close }: EditorProps) {
export function registerBuiltins(): () => void { export function registerBuiltins(): () => void {
const off = [ const off = [
registerEditor({ id: 'properties', label: 'Properties', icon: 'edit', match: '*', priority: 100, onDoubleClick: true, component: Properties }), registerEditor({
id: 'properties',
label: 'Properties',
icon: 'edit',
match: '*',
priority: 100,
onDoubleClick: true,
component: Properties,
}),
// Matches on the schema, not the node type: any type with x-style fields gets spacing/layout controls. // Matches on the schema, not the node type: any type with x-style fields gets spacing/layout controls.
registerEditor({ id: 'style', label: 'Style', icon: 'style', match: (i) => describeStyle(i.schema).length > 0, priority: 90, size: 'md', component: StylePanel }), registerEditor({
registerEditor({ id: 'add-child', label: 'Add inside', icon: 'add-inside', match: (i) => i.container, priority: 50, size: 'sm', component: AddChild }), id: 'style',
registerEditor({ id: 'add-after', label: 'Add after', icon: 'add', match: '*', priority: 40, size: 'sm', component: AddAfter }), label: 'Style',
registerEditor({ id: 'raw-json', label: 'JSON', icon: 'json', placement: 'menu', match: '*', priority: -100, size: 'lg', component: RawJson }), icon: 'style',
match: (i) => describeStyle(i.schema).length > 0,
priority: 90,
size: 'md',
component: StylePanel,
}),
registerEditor({
id: 'add-child',
label: 'Add inside',
icon: 'add-inside',
match: (i) => i.container,
priority: 50,
size: 'sm',
component: AddChild,
}),
registerEditor({
id: 'add-after',
label: 'Add after',
icon: 'add',
match: '*',
priority: 40,
size: 'sm',
component: AddAfter,
}),
registerEditor({
id: 'raw-json',
label: 'JSON',
icon: 'json',
placement: 'menu',
match: '*',
priority: -100,
size: 'lg',
component: RawJson,
}),
registerInlineEditor({ id: 'text', start: startPlainText }), registerInlineEditor({ id: 'text', start: startPlainText }),
registerInlineEditor({ id: 'markdown', start: startMarkdown }), registerInlineEditor({ id: 'markdown', start: startMarkdown }),
] ]
+2 -1
View File
@@ -135,7 +135,8 @@ function ArrayList({ schema: s, itemSchema, items, onChange, path, compound }: {
</span> </span>
{compound ? ( {compound ? (
<button type="button" className="cms-item__title" aria-expanded={expanded} onClick={() => setOpen((o) => ({ ...o, [k]: !expanded }))}> <button type="button" className="cms-item__title" aria-expanded={expanded} onClick={() => setOpen((o) => ({ ...o, [k]: !expanded }))}>
<Icon name="chevron" size={16} className={`cms-chev${expanded ? ' is-open' : ''}`} /> {summarise(root, itemSchema, item, i)} <Icon name="chevron" size={16} className={`cms-chev${expanded ? ' is-open' : ''}`} />
<span className="cms-item__text">{summarise(root, itemSchema, item, i)}</span>
</button> </button>
) : ( ) : (
<div className="cms-item__value"> <div className="cms-item__value">
+17 -1
View File
@@ -5,6 +5,8 @@ import type { Controller } from '../controller'
import { fieldEditorFor } from '../registry' import { fieldEditorFor } from '../registry'
import { labelOf, READONLY_KEYS } from '../schema' import { labelOf, READONLY_KEYS } from '../schema'
import { Summary } from '../ui/Summary' import { Summary } from '../ui/Summary'
import { RewriteButton } from '../ai/RewriteButton'
import { Guard } from '../ui/Guard'
import { ArrayField } from './ArrayField' import { ArrayField } from './ArrayField'
import { FormContext, useForm } from './context' import { FormContext, useForm } from './context'
import { MapField } from './MapField' import { MapField } from './MapField'
@@ -205,13 +207,27 @@ export function StringInput({ schema: s, value, onChange }: { schema: JSONSchema
const text = typeof value === 'string' ? value : value == null ? '' : String(value) const text = typeof value === 'string' ? value : value == null ? '' : String(value)
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 <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} /> return (
<>
<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>
)}
</>
)
} }
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)} /> <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 === '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>
)}
</> </>
) )
} }
+5
View File
@@ -7,6 +7,8 @@ import { Dialogs } from './Dialogs'
import { Rail } from './Rail' import { Rail } from './Rail'
import { LoginPanel } from './LoginPanel' import { LoginPanel } from './LoginPanel'
import { DevPanel } from './DevPanel' import { DevPanel } from './DevPanel'
import { Assistant } from './Assistant'
import { Guard } from './Guard'
export function App({ ctrl }: { ctrl: Controller }) { export function App({ ctrl }: { ctrl: Controller }) {
useEffect(() => { useEffect(() => {
@@ -23,6 +25,9 @@ export function App({ ctrl }: { ctrl: Controller }) {
<Rail /> <Rail />
<Dialogs /> <Dialogs />
<DevPanel /> <DevPanel />
<Guard name="Assistant">
<Assistant />
</Guard>
<LoginPanel /> <LoginPanel />
<Dock /> <Dock />
<Toast /> <Toast />
+257
View File
@@ -0,0 +1,257 @@
// 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.
import { useEffect, useRef, useState } from 'react'
import { runAgent, visibleText } from '../ai/agent'
import { aiSupported, getEngine } from '../ai/engine'
import type { ChatMessage } from '../ai/engine'
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.'
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 TOOL_LABEL: Record<string, string> = {
get_page_outline: 'Reading page outline',
get_selected_node: 'Reading block',
get_page_text: 'Reading all page text',
find_text: 'Searching text',
select_block: 'Selecting block',
list_insertable_types: 'Looking up block types',
propose_set: 'Drafting an edit',
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 }) {
return (
<div className="cms-prop" data-status={status}>
<div className="cms-prop__head">{describeProposal(p)}</div>
{p.kind === 'set' && (
<>
<div className="cms-prop__del">{show(p.before) || '(empty)'}</div>
<div className="cms-prop__ins">{show(p.after)}</div>
</>
)}
{p.kind === 'insert' && <div className="cms-prop__ins">{show(p.data)}</div>}
{p.kind === 'remove' && <div className="cms-prop__del">{p.summary}</div>}
{status === 'pending' ? (
<div className="cms-prop__actions">
<button className="cms-btn" onClick={() => onResolve(false)}>
Reject
</button>
<button className="cms-btn cms-btn--primary" onClick={() => onResolve(true)}>
Accept
</button>
</div>
) : (
<div className={`cms-prop__state cms-prop__state--${status}`}>{status === 'applied' ? '✓ Applied' : status === 'rejected' ? 'Rejected' : '⚠ Failed to apply'}</div>
)}
</div>
)
}
export function Assistant() {
const ctrl = useCtrl()
const open = useEditorState((s) => s.assistant)
const selected = useEditorState((s) => s.selected)
useEditorState((s) => s.labelsVersion)
const [items, setItems] = useState<Item[]>([])
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])
useEffect(() => {
if (open) input.current?.focus()
}, [open])
useEffect(
() => () => {
abort.current?.abort()
},
[],
)
if (!open || !aiSupported()) return null
const add = (...i: Item[]) => setItems((cur) => [...cur, ...i])
const stop = () => {
abort.current?.abort()
setBusy(null)
setLive('')
}
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
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 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…')
},
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 (live_() && reply) add({ kind: 'assistant', text: reply })
} catch (e) {
if ((e as Error).name !== 'AbortError') add({ kind: 'note', text: `Error: ${(e as Error).message}` })
} finally {
if (abort.current === ctl) {
setBusy(null)
setLive('')
}
}
}
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 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
return (
<aside className="cms-assistant" aria-label="AI assistant">
<header>
<span className="cms-assistant__title">
<Icon name="assistant" size={16} /> Assistant
</span>
<span>
{items.length > 0 && (
<button className="cms-ico" title="Clear conversation" aria-label="Clear conversation" onClick={() => (stop(), setItems([]))}>
<Icon name="reset" />
</button>
)}
<button className="cms-ico" aria-label="Close assistant" onClick={() => ctrl.store.set({ assistant: false })}>
<Icon name="close" />
</button>
</span>
</header>
<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>
<div className="cms-chips">
{STARTERS.map((s) => (
<button key={s} className="cms-chip" onClick={() => void send(s)} disabled={!!busy}>
{s}
</button>
))}
</div>
</div>
)}
{items.map((it, i) =>
it.kind === 'proposal' ? (
<ProposalCard key={i} p={it.p} status={it.status} onResolve={(a) => void resolve(i, a)} />
) : it.kind === 'tool' ? (
<div key={i} className="cms-tool">
<Icon name="flash" size={12} /> {TOOL_LABEL[it.name] ?? it.name}
{it.args && <small> {it.args}</small>}
</div>
) : (
<div key={i} className={`cms-msg cms-msg--${it.kind}`}>
{it.text}
</div>
),
)}
{live && <div className="cms-msg cms-msg--streaming">{live}</div>}
{pending > 1 && (
<button
className="cms-btn cms-btn--primary cms-chat__all"
onClick={async () => {
for (let i = 0; i < items.length; i++) await resolve(i, true)
}}
>
Accept all {pending} changes
</button>
)}
{busy && (
<div className="cms-status-line" role="status">
<span className="cms-dots" aria-hidden="true" />
<span>{busy}</span>
<button className="cms-btn" onClick={stop}>
Stop
</button>
</div>
)}
<div ref={end} />
</div>
<form
onSubmit={(e) => {
e.preventDefault()
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>
<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)"
aria-label="Message the assistant"
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
void send()
}
}}
/>
<button type="submit" className="cms-btn cms-btn--primary" disabled={!text.trim() || !!busy}>
Send
</button>
</div>
</form>
</aside>
)
}
+41 -6
View File
@@ -17,13 +17,33 @@ export function Dialogs() {
) )
} }
const GAP = 12
/** Next to the anchored block when there is room (right, then left, then below, then above); else pinned to the right edge. */
function place(a: DialogInstance['anchor'], w: number, h: number) {
const vw = window.innerWidth
const vh = window.innerHeight
const clampX = (x: number) => Math.max(GAP, Math.min(x, vw - w - GAP))
const clampY = (y: number) => Math.max(GAP, Math.min(y, vh - h - GAP))
if (!a) return { x: clampX(vw - w - 24), y: clampY((vh - h) / 2) }
const left = a.x
const right = a.right ?? a.x
const top = a.y
const bottom = a.bottom ?? a.y
if (right + GAP + w + GAP <= vw) return { x: right + GAP, y: clampY(top) }
if (left - GAP - w >= GAP) return { x: left - GAP - w, y: clampY(top) }
if (bottom + GAP + h + GAP <= vh) return { x: clampX(left), y: bottom + GAP }
if (top - GAP - h >= GAP) return { x: clampX(left), y: top - GAP - h }
return { x: clampX(vw - w - 24), y: clampY(top) }
}
/** Non-modal, draggable window: the page stays visible and clickable behind it. */ /** Non-modal, draggable window: the page stays visible and clickable behind it. */
function Dialog({ d, index }: { d: DialogInstance; index: number }) { function Dialog({ d, index }: { d: DialogInstance; index: number }) {
const ctrl = useCtrl() const ctrl = useCtrl()
const w = WIDTH[d.size] const w = WIDTH[d.size] ?? WIDTH.md
const [pos, setPos] = useState(() => { const [pos, setPos] = useState(() => {
const x = Math.min((d.anchor?.x ?? 80) + 16, window.innerWidth - w - 12) const p = place(d.anchor, w, window.innerHeight * 0.5)
return { x: Math.max(12, x) + index * 18, y: Math.max(12, Math.min(d.anchor?.y ?? 80, window.innerHeight - 320)) + index * 18 } return { x: p.x + index * 18, y: p.y + index * 18 }
}) })
const drag = useRef<{ dx: number; dy: number } | null>(null) const drag = useRef<{ dx: number; dy: number } | null>(null)
const el = useRef<HTMLElement>(null) const el = useRef<HTMLElement>(null)
@@ -32,7 +52,10 @@ function Dialog({ d, index }: { d: DialogInstance; index: number }) {
// Asked to open again: bring it back into view, flash it, and put the cursor in its first field. // Asked to open again: bring it back into view, flash it, and put the cursor in its first field.
useEffect(() => { useEffect(() => {
if (!d.pulse) return if (!d.pulse) return
setPos((p) => ({ x: Math.min(Math.max(8, p.x), Math.max(8, window.innerWidth - w - 8)), y: Math.min(Math.max(8, p.y), Math.max(8, window.innerHeight - 120)) })) setPos((p) => ({
x: Math.min(Math.max(8, p.x), Math.max(8, window.innerWidth - w - 8)),
y: Math.min(Math.max(8, p.y), Math.max(8, window.innerHeight - 120)),
}))
el.current?.querySelector<HTMLElement>('input:not([type=checkbox]), textarea, select, button:not([aria-label="Close"])')?.focus() el.current?.querySelector<HTMLElement>('input:not([type=checkbox]), textarea, select, button:not([aria-label="Close"])')?.focus()
setPulsing(true) setPulsing(true)
const t = setTimeout(() => setPulsing(false), 700) const t = setTimeout(() => setPulsing(false), 700)
@@ -43,7 +66,13 @@ function Dialog({ d, index }: { d: DialogInstance; index: number }) {
<section <section
ref={el} ref={el}
className={`cms-dialog${pulsing ? ' is-pulsing' : ''}`} className={`cms-dialog${pulsing ? ' is-pulsing' : ''}`}
style={{ left: pos.x, top: pos.y, width: w, zIndex: BASE_Z + d.z }} style={{
left: pos.x,
top: pos.y,
width: w,
height: '50vh',
zIndex: BASE_Z + d.z,
}}
role="dialog" role="dialog"
aria-label={d.title} aria-label={d.title}
onPointerDownCapture={() => ctrl.raiseDialog(d.key)} onPointerDownCapture={() => ctrl.raiseDialog(d.key)}
@@ -55,7 +84,13 @@ function Dialog({ d, index }: { d: DialogInstance; index: number }) {
drag.current = { dx: e.clientX - pos.x, dy: e.clientY - pos.y } drag.current = { dx: e.clientX - pos.x, dy: e.clientY - pos.y }
e.currentTarget.setPointerCapture?.(e.pointerId) e.currentTarget.setPointerCapture?.(e.pointerId)
}} }}
onPointerMove={(e) => drag.current && setPos({ x: e.clientX - drag.current.dx, y: Math.max(0, e.clientY - drag.current.dy) })} onPointerMove={(e) =>
drag.current &&
setPos({
x: e.clientX - drag.current.dx,
y: Math.max(0, e.clientY - drag.current.dy),
})
}
onPointerUp={() => (drag.current = null)} onPointerUp={() => (drag.current = null)}
> >
<strong>{d.title}</strong> <strong>{d.title}</strong>
+7 -1
View File
@@ -1,11 +1,12 @@
import { createElement, useEffect } from 'react' import { createElement, useEffect } from 'react'
import { InsertPicker } from '../editors/builtin' import { InsertPicker } from '../editors/builtin'
import { aiSupported } from '../ai/engine'
import { Icon } from './Icon' import { Icon } from './Icon'
import { useCtrl, useEditorState } from './hooks' import { useCtrl, useEditorState } from './hooks'
export function Dock() { export function Dock() {
const ctrl = useCtrl() const ctrl = useCtrl()
const s = useEditorState((s) => ({ mode: s.mode, canUndo: s.canUndo, canRedo: s.canRedo, busy: s.busy, dirty: s.dirty, rail: s.rail, dev: s.dev, auth: s.auth })) const s = useEditorState((s) => ({ mode: s.mode, canUndo: s.canUndo, canRedo: s.canRedo, busy: s.busy, dirty: s.dirty, rail: s.rail, dev: s.dev, assistant: s.assistant, auth: s.auth }))
const out = ( const out = (
<button <button
className="cms-ico" className="cms-ico"
@@ -56,6 +57,11 @@ export function Dock() {
<button className={`cms-ico${s.rail === 'outline' ? ' is-on' : ''}`} onClick={() => rail('outline')} title="Outline" aria-label="Outline"> <button className={`cms-ico${s.rail === 'outline' ? ' is-on' : ''}`} onClick={() => rail('outline')} title="Outline" aria-label="Outline">
<Icon name="outline" /> <Icon name="outline" />
</button> </button>
{aiSupported() && (
<button className={`cms-ico${s.assistant ? ' is-on' : ''}`} onClick={() => ctrl.store.set({ assistant: !s.assistant })} title="AI assistant (runs locally on your GPU)" aria-label="AI assistant">
<Icon name="assistant" />
</button>
)}
<button className="cms-ico" onClick={() => void addToPage()} title="Add a block to the page" aria-label="Add a block to the page"> <button className="cms-ico" onClick={() => void addToPage()} title="Add a block to the page" aria-label="Add a block to the page">
<Icon name="add" /> <Icon name="add" />
</button> </button>
+28
View File
@@ -0,0 +1,28 @@
// Error boundary: a render error in one panel must not unmount the whole editor
// (React drops the entire tree on an uncaught render error).
import { Component } from 'react'
import type { ReactNode } from 'react'
export class Guard extends Component<{ name: string; children: ReactNode }, { error: Error | null }> {
state = { error: null as Error | null }
static getDerivedStateFromError(error: Error) {
return { error }
}
componentDidCatch(error: Error) {
console.error(`[cms-onpage-editor] ${this.props.name} crashed:`, error)
}
render() {
const { error } = this.state
if (!error) return this.props.children
return (
<div className="cms-guard" role="alert">
<span className="cms-error">
{this.props.name} failed: {error.message}
</span>{' '}
<button type="button" className="cms-btn" onClick={() => this.setState({ error: null })}>
Retry
</button>
</div>
)
}
}
+2
View File
@@ -14,6 +14,7 @@ import {
mdiCloudUploadOutline, mdiCloudUploadOutline,
mdiCodeJson, mdiCodeJson,
mdiCogOutline, mdiCogOutline,
mdiCreation,
mdiContentCopy, mdiContentCopy,
mdiDotsHorizontal, mdiDotsHorizontal,
mdiDragVertical, mdiDragVertical,
@@ -72,6 +73,7 @@ export const ICONS = {
outline: mdiFileTreeOutline, outline: mdiFileTreeOutline,
blocks: mdiShapePlusOutline, blocks: mdiShapePlusOutline,
settings: mdiCogOutline, settings: mdiCogOutline,
assistant: mdiCreation,
exit: mdiPower, exit: mdiPower,
'sign-out': mdiLogout, 'sign-out': mdiLogout,
style: mdiPaletteOutline, style: mdiPaletteOutline,
+14
View File
@@ -81,6 +81,20 @@ function ActionBar({ block }: { block: Block }) {
live = false live = false
} }
}, [block.id, ctrl, block.el]) }, [block.id, ctrl, block.el])
// Refresh the position after any edit settles (move, undo/redo, drag) so the arrows' disabled state tracks it.
const busy = useEditorState((s) => s.busy)
const rev = useEditorState((s) => `${s.canUndo}${s.canRedo}`)
useEffect(() => {
if (busy) return
let live = true
ctrl
.position(block.id)
.then((p) => live && setPos((o) => (o && o.index === p.index && o.count === p.count ? o : p)))
.catch(() => {})
return () => {
live = false
}
}, [busy, rev, block.id, ctrl])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const away = (e: Event) => !e.composedPath().includes(menuRef.current!) && setOpen(false) const away = (e: Event) => !e.composedPath().includes(menuRef.current!) && setOpen(false)
+54 -5
View File
@@ -14,7 +14,7 @@ button:disabled { opacity: .35; cursor: default; }
.cms-frame--editing { border: 2px dashed #5ee0a0; background: none; } .cms-frame--editing { border: 2px dashed #5ee0a0; background: none; }
.cms-frame__tag { position: absolute; top: -18px; left: -1px; padding: 1px 6px; font-size: 11px; background: #6aa7ff; color: #07080b; border-radius: 3px 3px 0 0; } .cms-frame__tag { position: absolute; top: -18px; left: -1px; padding: 1px 6px; font-size: 11px; background: #6aa7ff; color: #07080b; border-radius: 3px 3px 0 0; }
.cms-bar, .cms-dock, .cms-dialog, .cms-outline, .cms-dev, .cms-toast { .cms-bar, .cms-dock, .cms-dialog, .cms-outline, .cms-dev, .cms-assistant, .cms-toast {
pointer-events: auto; position: fixed; z-index: 2147483001; pointer-events: auto; position: fixed; z-index: 2147483001;
background: #14161d; border: 1px solid #2a2d3a; border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,.45); background: #14161d; border: 1px solid #2a2d3a; border-radius: 10px; box-shadow: 0 8px 30px rgba(0,0,0,.45);
} }
@@ -35,9 +35,9 @@ button:disabled { opacity: .35; cursor: default; }
.cms-busy.is-busy { background: #5ee0a0; animation: cms-pulse .8s infinite alternate; } .cms-busy.is-busy { background: #5ee0a0; animation: cms-pulse .8s infinite alternate; }
@keyframes cms-pulse { to { opacity: .2; } } @keyframes cms-pulse { to { opacity: .2; } }
.cms-dialog { max-height: calc(100vh - 24px); display: flex; flex-direction: column; } .cms-dialog { max-height: calc(100vh - 24px); min-width: 280px; min-height: 140px; max-width: calc(100vw - 24px); display: flex; flex-direction: column; overflow: hidden; resize: both; }
.cms-dialog__head { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 6px 14px; border-bottom: 1px solid #2a2d3a; cursor: grab; user-select: none; } .cms-dialog__head { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 6px 14px; border-bottom: 1px solid #2a2d3a; cursor: grab; user-select: none; }
.cms-dialog__body { padding: 12px 14px; overflow: auto; } .cms-dialog__body { padding: 12px 14px; overflow: auto; flex: 1; min-height: 0; }
.cms-form { display: flex; flex-direction: column; gap: 10px; } .cms-form { display: flex; flex-direction: column; gap: 10px; }
.cms-field { display: flex; flex-direction: column; gap: 3px; } .cms-field { display: flex; flex-direction: column; gap: 3px; }
@@ -51,7 +51,7 @@ button:disabled { opacity: .35; cursor: default; }
.cms-check { width: 16px; height: 16px; } .cms-check { width: 16px; height: 16px; }
.cms-nested { border: 1px solid #2a2d3a; border-radius: 8px; padding: 8px; display: flex; flex-direction: column; gap: 10px; margin: 0; } .cms-nested { border: 1px solid #2a2d3a; border-radius: 8px; padding: 8px; display: flex; flex-direction: column; gap: 10px; margin: 0; }
.cms-extra summary { cursor: pointer; color: #8b8fa0; margin-bottom: 6px; } .cms-extra summary { cursor: pointer; color: #8b8fa0; margin-bottom: 6px; }
.cms-actions { display: flex; justify-content: flex-end; gap: 8px; position: sticky; bottom: 0; } .cms-actions { display: flex; justify-content: flex-end; gap: 8px; position: sticky; bottom: -12px; z-index: 20; margin: 4px -14px -12px; padding: 10px 14px; background: #14161d; border-top: 1px solid #2a2d3a; box-shadow: 0 -8px 12px -8px rgba(0,0,0,.5); }
.cms-btn { background: #242836; border: 1px solid #343850; border-radius: 6px; padding: 5px 14px; } .cms-btn { background: #242836; border: 1px solid #343850; border-radius: 6px; padding: 5px 14px; }
.cms-btn--primary { background: #d2a24c; border-color: #d2a24c; color: #07080b; font-weight: 600; } .cms-btn--primary { background: #d2a24c; border-color: #d2a24c; color: #07080b; font-weight: 600; }
@@ -112,7 +112,9 @@ button:disabled { opacity: .35; cursor: default; }
.cms-item { border: 1px solid #2a2d3a; border-radius: 8px; background: #101219; } .cms-item { border: 1px solid #2a2d3a; border-radius: 8px; background: #101219; }
.cms-item.is-over { border-color: #5ee0a0; box-shadow: 0 0 0 2px rgba(94,224,160,.2); } .cms-item.is-over { border-color: #5ee0a0; box-shadow: 0 0 0 2px rgba(94,224,160,.2); }
.cms-item__head { display: flex; align-items: center; gap: 4px; padding: 3px 4px 3px 2px; } .cms-item__head { display: flex; align-items: center; gap: 4px; padding: 3px 4px 3px 2px; }
.cms-item__title { flex: 1; min-width: 0; text-align: left; background: none; border: 0; padding: 4px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-radius: 6px; } .cms-item__title { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; text-align: left; background: none; border: 0; padding: 4px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-radius: 6px; }
.cms-item__text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cms-item__title .cms-chev { flex-shrink: 0; }
.cms-item__title:hover { background: #1d2029; } .cms-item__title:hover { background: #1d2029; }
.cms-item__value { flex: 1; min-width: 0; } .cms-item__value { flex: 1; min-width: 0; }
.cms-item__actions { display: flex; flex-shrink: 0; } .cms-item__actions { display: flex; flex-shrink: 0; }
@@ -273,3 +275,50 @@ details[open] > summary > .cms-chev { transform: rotate(90deg); }
.cms-dialog.is-pulsing { animation: cms-pulse-ring .7s ease-out; } .cms-dialog.is-pulsing { animation: cms-pulse-ring .7s ease-out; }
@keyframes cms-pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(210,162,76,.9), 0 8px 30px rgba(0,0,0,.45); } 100% { box-shadow: 0 0 0 14px rgba(210,162,76,0), 0 8px 30px rgba(0,0,0,.45); } } @keyframes cms-pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(210,162,76,.9), 0 8px 30px rgba(0,0,0,.45); } 100% { box-shadow: 0 0 0 14px rgba(210,162,76,0), 0 8px 30px rgba(0,0,0,.45); } }
@media (prefers-reduced-motion: reduce) { .cms-dialog.is-pulsing { animation: none; outline: 2px solid #d2a24c; } } @media (prefers-reduced-motion: reduce) { .cms-dialog.is-pulsing { animation: none; outline: 2px solid #d2a24c; } }
/* AI rewrite (src/ai) */
.cms-ai { display: flex; align-items: center; gap: 8px; margin-top: 4px; font-size: 12px; }
.cms-ai__pick { background: #1a1d26; color: inherit; border: 1px solid var(--cms-line, #2a2d3a); border-radius: 6px; padding: 2px 6px; font: inherit; }
.cms-ai--suggest { flex-direction: column; align-items: stretch; background: #1a1d26; border: 1px dashed var(--cms-accent, #d2a24c); border-radius: 8px; padding: 8px; }
.cms-ai__text { white-space: pre-wrap; }
.cms-ai__actions { display: flex; justify-content: flex-end; gap: 8px; }
/* AI assistant panel */
.cms-assistant { right: 12px; top: 12px; bottom: 70px; width: 380px; display: flex; flex-direction: column; overflow: hidden; }
.cms-assistant > header { display: flex; align-items: center; justify-content: space-between; padding: 6px 8px 6px 12px; border-bottom: 1px solid var(--cms-line, #2a2d3a); font-weight: 600; }
.cms-chat { flex: 1; overflow: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
.cms-msg { max-width: 92%; padding: 6px 10px; border-radius: 10px; white-space: pre-wrap; word-break: break-word; background: #1d2029; }
.cms-msg--user { align-self: flex-end; background: #2b3350; }
.cms-msg--note { align-self: stretch; max-width: none; background: none; color: #8b8fa0; font-size: 12px; padding: 0 4px; }
.cms-prop { border: 1px dashed var(--cms-accent, #d2a24c); border-radius: 8px; padding: 8px; display: flex; flex-direction: column; gap: 4px; background: #1a1d26; }
.cms-prop__head { font-size: 12px; color: #8b8fa0; }
.cms-prop__del { background: #3a2226; text-decoration: line-through; border-radius: 4px; padding: 2px 4px; white-space: pre-wrap; }
.cms-prop__ins { background: #1f3a2c; border-radius: 4px; padding: 2px 4px; white-space: pre-wrap; }
.cms-prop__actions { display: flex; justify-content: flex-end; gap: 8px; }
.cms-guard { margin-top: 4px; font-size: 12px; }
.cms-assistant__title { display: inline-flex; align-items: center; gap: 6px; }
.cms-chat__empty { display: flex; flex-direction: column; gap: 10px; }
.cms-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.cms-chip { background: #1d2029; border: 1px solid var(--cms-line, #2a2d3a); border-radius: 999px; padding: 4px 10px; font-size: 12px; }
.cms-chip:hover:not(:disabled) { border-color: var(--cms-accent, #d2a24c); }
.cms-msg--streaming { align-self: flex-start; border-left: 2px solid var(--cms-accent, #d2a24c); border-radius: 4px 10px 10px 4px; }
.cms-msg--streaming::after { content: '▍'; margin-left: 1px; animation: cms-blink 1s steps(2) infinite; color: var(--cms-accent, #d2a24c); }
.cms-tool { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: #8b8fa0; padding: 0 4px; }
.cms-tool small { opacity: .7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.cms-status-line { display: flex; align-items: center; gap: 8px; color: #8b8fa0; font-size: 12px; }
.cms-status-line .cms-btn { margin-left: auto; padding: 2px 10px; }
.cms-dots { width: 14px; height: 14px; border-radius: 50%; border: 2px solid #343850; border-top-color: var(--cms-accent, #d2a24c); animation: cms-spin .8s linear infinite; }
.cms-chat__all { align-self: stretch; }
.cms-prop__state { font-size: 12px; text-align: right; color: #8b8fa0; }
.cms-prop__state--applied { color: #5ee0a0; }
.cms-prop__state--failed { color: #ff6b6b; }
.cms-prop[data-status="rejected"] { opacity: .55; border-style: solid; border-color: var(--cms-line, #2a2d3a); }
.cms-prop[data-status="applied"] { border-style: solid; border-color: #2f6b4c; }
.cms-assistant form { display: flex; flex-direction: column; gap: 4px; padding: 8px; border-top: 1px solid var(--cms-line, #2a2d3a); }
.cms-ctx { font-size: 11.5px; color: #8b8fa0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cms-ctx b { color: var(--cms-accent, #d2a24c); font-weight: 600; }
.cms-compose { display: flex; gap: 6px; align-items: flex-end; }
.cms-compose textarea { flex: 1; resize: none; }
@keyframes cms-blink { 50% { opacity: 0; } }
@keyframes cms-spin { to { transform: rotate(360deg); } }
+58
View File
@@ -0,0 +1,58 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanReply, rewriteMessages } from '../src/ai/rewrite'
const generate = vi.fn()
vi.mock('../src/ai/engine', () => ({
aiSupported: () => true,
getEngine: async () => ({ generate }),
}))
const { RewriteButton } = await import('../src/ai/RewriteButton')
afterEach(() => {
cleanup()
generate.mockReset()
})
describe('rewrite prompts', () => {
it('carries the task, text, format and length limit', () => {
const m = rewriteMessages('shorten', 'Hello world', { 'x-editor': 'markdown', maxLength: 50 }, 'Intro')
expect(m[0].content).toContain('Markdown')
expect(m[0].content).toContain('50 characters')
expect(m[1].content).toContain('Field: Intro')
expect(m[1].content).toContain('Hello world')
})
it('strips fences/quotes and enforces maxLength', () => {
expect(cleanReply('```\nHi there\n```', {})).toBe('Hi there')
expect(cleanReply('"Hi there"', {})).toBe('Hi there')
expect(cleanReply('abcdefghij', { maxLength: 4 })).toBe('abcd')
})
})
describe('RewriteButton', () => {
it('only writes after Accept', async () => {
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' } })
await screen.findByLabelText('AI suggestion')
expect(onAccept).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Accept'))
expect(onAccept).toHaveBeenCalledWith('Better text')
})
it('Reject leaves the value alone', async () => {
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(await screen.findByText('Reject'))
expect(onAccept).not.toHaveBeenCalled()
await waitFor(() => screen.getByLabelText('Rewrite with AI'))
})
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' } })
expect((await screen.findByRole('alert')).textContent).toBe('boom')
})
})
+132
View File
@@ -0,0 +1,132 @@
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'
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' }] } }
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),
select: vi.fn(),
schemaOf: () => schema,
node: async () => node,
schema: async () => schema,
labelFor: () => 'Hero',
setData,
} as any,
}
}
const scripted = (replies: string[]): Engine => ({ generate: async () => replies.shift() ?? 'done' })
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 } })
expect(parseToolCall('<tool_call>{"name":"a"}')).toEqual({ name: 'a', arguments: {} })
expect(parseToolCall('```json\n{"name":"b","arguments":{}}\n```')?.name).toBe('b')
expect(parseToolCall('Just a sentence.')).toBeNull()
expect(parseToolCall('<tool_call>{oops</tool_call>')).toBeNull()
})
})
describe('runAgent + tools', () => {
it('reads the node, queues a validated proposal, and finishes with text', async () => {
const { ctrl } = fakeCtrl()
const proposals: any[] = []
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":"propose_set","arguments":{"path":"title","value":"Hello world"}}</tool_call>',
'Fixed the typo.',
]),
'sys',
[{ role: 'user', content: 'fix spelling' }],
tools,
)
expect(out).toBe('Fixed the typo.')
expect(proposals).toEqual([{ kind: 'set', nodeId: 'n1', label: 'Hero', path: 'title', before: 'Hello wrold', after: 'Hello world' }])
})
it('rejects schema-invalid proposals and unknown fields, feeding the error back', async () => {
const { ctrl } = fakeCtrl()
const proposals: any[] = []
const tools = createTools({ ctrl, propose: (p) => proposals.push(p) })
const seen: string[] = []
const engine: Engine = {
generate: async (m) => {
seen.push(m.at(-1)!.content)
return seen.length === 1
? '<tool_call>{"name":"propose_set","arguments":{"path":"title","value":"this title is far too long for the limit"}}</tool_call>'
: seen.length === 2
? '<tool_call>{"name":"propose_set","arguments":{"path":"nope","value":"x"}}</tool_call>'
: 'ok'
},
}
await runAgent(engine, 's', [{ role: 'user', content: 'q' }], tools)
expect(proposals).toHaveLength(0)
expect(seen[1]).toContain('At most 20 characters')
expect(seen[2]).toContain('Unknown field')
})
it('stops after maxSteps', async () => {
const { ctrl } = fakeCtrl()
const loop: Engine = { generate: async () => '<tool_call>{"name":"get_page_outline","arguments":{}}</tool_call>' }
expect(await runAgent(loop, 's', [], createTools({ ctrl, propose() {} }), { maxSteps: 2 })).toContain('too many steps')
})
})
describe('applyProposal', () => {
it('rebuilds the nested patch from the fresh node and saves with its version', async () => {
const { ctrl, setData } = fakeCtrl()
await applyProposal(ctrl, { id: 1, kind: 'set', nodeId: 'n1', label: 'Hero', path: 'links.0.text', before: 'a', after: 'b' })
expect(setData).toHaveBeenCalledWith('n1', { links: [{ text: 'b' }] }, { version: 3 })
})
})
describe('more tools', () => {
it('find_text matches case-insensitively with the path', async () => {
const { ctrl } = fakeCtrl()
const t = createTools({ ctrl, propose() {} }).find((x) => x.name === 'find_text')!
expect(await t.run({ query: 'WROLD' })).toEqual([{ id: 'n1', type: 'hero', path: 'title', value: 'Hello wrold' }])
await expect(t.run({})).rejects.toThrow('query')
})
it('select_block selects known blocks and refuses unknown ones', async () => {
const { ctrl } = fakeCtrl()
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')
})
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' })
expect(got[0]).toMatchObject({ kind: 'remove', nodeId: 'n1', summary: 'Hello wrold' })
await applyProposal(ctrl, { ...got[0], id: 1 })
expect(ctrl.remove).toHaveBeenCalledWith('n1')
})
})
describe('streaming', () => {
it('forwards tokens and hides tool-call markup', async () => {
const { ctrl } = fakeCtrl()
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>'
},
}
let n = 0
const wrapped: Engine = { generate: async (m, o) => (n++ ? 'Done.' : engine.generate(m, o)) }
await runAgent(wrapped, 's', [], createTools({ ctrl, propose() {} }), { onText: (p) => seen.push(visibleText(p)) })
expect(seen).toContain('Let me')
expect(seen).toContain('Let me look.')
expect(seen.every((s) => !s.includes('<tool'))).toBe(true)
})
})
+2
View File
@@ -145,6 +145,8 @@ describe('the property editor uses icons, not text glyphs', () => {
} }
expect(q('[aria-label="Add entry"] svg')).not.toBeNull() expect(q('[aria-label="Add entry"] svg')).not.toBeNull()
expect(q('.cms-group > summary svg.cms-chev')).not.toBeNull() expect(q('.cms-group > summary svg.cms-chev')).not.toBeNull()
expect(q('.cms-actions')).toBeNull() // nothing changed yet: no footer
await act(async () => click(q('[aria-label="Add item"]')!))
expect(q('.cms-actions .cms-btn--primary svg')).not.toBeNull() expect(q('.cms-actions .cms-btn--primary svg')).not.toBeNull()
}) })
}) })