initial ai assistance
This commit is contained in:
1 parent
1af9ec3172
commit
2e0bf1e88e
24 files changed
+1293
-60
No files matched your search
+3
-3
@@ -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'))) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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 ''
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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())
|
||||||
@@ -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
@@ -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 }),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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); } }
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user