diff --git a/apps/api/src/app/config/workflows/workflow-builder.json b/apps/api/src/app/config/workflows/workflow-builder.json index b377846..86423a2 100644 --- a/apps/api/src/app/config/workflows/workflow-builder.json +++ b/apps/api/src/app/config/workflows/workflow-builder.json @@ -147,6 +147,19 @@ }, "ai-handlers-view-render": { "steps": [ + { + "route": ["client", "database"], + "transform": { + "clientMessageType": "update-state", + "actions": [ + { + "actionType": "defaults", + "path": "$state.tests", + "value": "~{ $merge($each(message.document.state.draftConfig.handlers, function($v, $k){ $v.steps[route='ai' or ($type(route)='array' and 'ai' in route)] ? {($k): []} : {} })) }" + } + ] + } + }, { "route": "client", "transform": { @@ -154,14 +167,34 @@ "viewHandler": "aiHandlersView", "layoutConfig": [ { - "componentType": "logTreePanel", - "props": { - "treeData": { - "$map": "$message.document.state.draftConfig.handlers", - "$where": "~{ item.steps[route='ai' or ($type(route)='array' and 'ai' in route)] }", - "$using": { "id": "$key", "name": "$key", "rawData": "$item", "children": [] } - } - } + "componentType": "aiObserverPanel", + "props": { "tests": "@state.tests" }, + "emits": { "addTest": "add-test" } + } + ] + } + } + ] + }, + "add-test": { + "steps": [ + { + "route": "database-query", + "query": { "name": "get-handler-test-skeleton", "responseType": "handler-test-skeleton-ready" } + } + ] + }, + "handler-test-skeleton-ready": { + "steps": [ + { + "route": ["client", "database"], + "transform": { + "clientMessageType": "update-state", + "actions": [ + { + "actionType": "append", + "path": "~{ '$state.tests.' & message.handlerName }", + "value": "$message.skeleton" } ] } diff --git a/apps/api/src/app/websocket/DatabasePersistor.ts b/apps/api/src/app/websocket/DatabasePersistor.ts index 85b9fc2..3f29f7f 100644 --- a/apps/api/src/app/websocket/DatabasePersistor.ts +++ b/apps/api/src/app/websocket/DatabasePersistor.ts @@ -134,6 +134,14 @@ export function createDatabasePersistor(deps: DatabasePersistorDeps) { ); break; } + case 'defaults': { + const fieldRef = `$${mongoPath}`; + await db.collection('artifacts').updateOne( + { _id: artifactId }, + [{ $set: { [mongoPath]: { $mergeObjects: [value, { $ifNull: [fieldRef, {}] }] } } }] as any + ); + break; + } } } diff --git a/apps/api/src/app/websocket/QueryExecutor.ts b/apps/api/src/app/websocket/QueryExecutor.ts index 1c775db..4164c5b 100644 --- a/apps/api/src/app/websocket/QueryExecutor.ts +++ b/apps/api/src/app/websocket/QueryExecutor.ts @@ -223,6 +223,47 @@ export function createQueryExecutor(deps: QueryExecutorDeps) { type: phase === 'building-config' ? 'run-config-ai-step' : 'run-requirements-ai-step', }; } + if (queryName === 'get-handler-test-skeleton') { + const channel = context.message['channel'] as string | undefined; + const handlerName = context.message['handlerName'] as string | undefined; + const artifactId = channel ? await getArtifactIdForChannel(channel) : null; + const doc = artifactId ? await db.collection('artifacts').findOne({ _id: artifactId }, { projection: { state: 1 } }) : null; + const state = (doc?.['state'] as Record | undefined) ?? {}; + const draftConfig = state['draftConfig'] as + | { handlers?: Record[] }> } + | null + | undefined; + const handler = handlerName ? draftConfig?.handlers?.[handlerName] : undefined; + + const skeleton: Record = { createdAt: new Date().toISOString() }; + const scanTargets = (handler?.steps ?? []).map((s) => ({ + transform: s['transform'], + condition: s['condition'], + query: s['query'], + })); + const text = JSON.stringify(scanTargets); + const pattern = /\$?(state|message)\.([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*)/g; + const seen = new Set(); + let m: RegExpExecArray | null; + while ((m = pattern.exec(text))) { + const [, root, subPath] = m; + const key = `${root}.${subPath}`; + if (seen.has(key)) continue; + seen.add(key); + skeleton[root] = skeleton[root] ?? {}; + let curr = skeleton[root] as Record; + const parts = subPath.split('.'); + parts.forEach((p, i) => { + if (i === parts.length - 1) curr[p] = ''; + else { + curr[p] = curr[p] ?? {}; + curr = curr[p] as Record; + } + }); + } + + return { skeleton, handlerName }; + } if (queryName === 'publish-workflow-config') { const channel = context.message['channel'] as string | undefined; const artifactId = channel ? await getArtifactIdForChannel(channel) : null; diff --git a/apps/web/src/app/registry/layoutRegistry.ts b/apps/web/src/app/registry/layoutRegistry.ts index 38dc613..10c75fb 100644 --- a/apps/web/src/app/registry/layoutRegistry.ts +++ b/apps/web/src/app/registry/layoutRegistry.ts @@ -79,6 +79,9 @@ const registry: Partial> = { namedView: lazy(() => import('@/components/layout/NamedView').then((m) => ({ default: m.NamedView as ComponentType })) ), + aiObserverPanel: lazy(() => + import('@/components/layout/AiObserverPanel').then((m) => ({ default: m.AiObserverPanel as ComponentType })) + ), }; export function getLayoutComponent(componentType: string): LazyLayoutComponent | null { diff --git a/apps/web/src/app/services/documentModelStore.ts b/apps/web/src/app/services/documentModelStore.ts index 278d56f..4f65c47 100644 --- a/apps/web/src/app/services/documentModelStore.ts +++ b/apps/web/src/app/services/documentModelStore.ts @@ -151,6 +151,10 @@ function applyAction(next: Record, action: ActionItem): Record< const existing = (getAtPath(next, resolvedPath) as unknown[]) ?? []; return setAtPath(next, resolvedPath, existing.slice(action.start, action.end)); } + case 'defaults': { + const existing = (getAtPath(next, resolvedPath) as Record) ?? {}; + return setAtPath(next, resolvedPath, { ...(value as Record), ...existing }); + } default: return next; } diff --git a/apps/web/src/app/styles/ai-observer-panel.css b/apps/web/src/app/styles/ai-observer-panel.css new file mode 100644 index 0000000..18c7436 --- /dev/null +++ b/apps/web/src/app/styles/ai-observer-panel.css @@ -0,0 +1,56 @@ +/* AiObserverPanel */ +.ai-observer-panel-empty { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + color: hsl(var(--muted-foreground)); + font-size: 0.875rem; + font-style: italic; +} +.ai-observer-panel-right { + display: flex; + flex-direction: column; + height: 100%; +} +.ai-observer-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid hsl(var(--border)); +} +.ai-observer-panel-title { + font-size: 0.875rem; + font-weight: 600; +} + +/* JsonEditor */ +.json-editor { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} +.json-editor__field { + flex: 1; + width: 100%; + box-sizing: border-box; + font: inherit; + font-family: monospace; + font-size: 0.8125rem; + padding: 1rem; + border: none; + resize: none; + outline: none; + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); +} +.json-editor__error { + padding: 0.5rem 1rem; + margin: 0; + font-size: 0.8125rem; + color: hsl(var(--destructive)); + border-top: 1px solid hsl(var(--border)); +} diff --git a/apps/web/src/app/styles/global.css b/apps/web/src/app/styles/global.css index a862fdd..1d912fa 100644 --- a/apps/web/src/app/styles/global.css +++ b/apps/web/src/app/styles/global.css @@ -13,6 +13,7 @@ @import './progress-bar.css'; @import './dialog.css'; @import './writing-area.css'; +@import './ai-observer-panel.css'; :root { --background: 0 0% 100%; diff --git a/apps/web/src/components/layout/AiObserverPanel.tsx b/apps/web/src/components/layout/AiObserverPanel.tsx new file mode 100644 index 0000000..3bd7412 --- /dev/null +++ b/apps/web/src/components/layout/AiObserverPanel.tsx @@ -0,0 +1,101 @@ +import { useMemo, useRef, useState, useEffect } from 'react'; +import { TreeView, type TreeDataItem } from '../ui/tree-view'; +import { TwoColumnPanel } from './TwoColumnPanel'; +import { JsonEditor } from './JsonEditor'; +import { Button } from '@/components/ui/button'; + +interface TestEntry { + createdAt: string; + [key: string]: unknown; +} + +interface NodeInfo { + handlerName: string; + test?: TestEntry; +} + +interface Props { + tests?: Record; + onAddTest?: (payload: { handlerName: string }) => void; + [key: string]: unknown; +} + +function buildTree(tests: Record): { items: TreeDataItem[]; nodeInfo: Map } { + const nodeInfo = new Map(); + const items: TreeDataItem[] = Object.entries(tests).map(([handlerName, handlerTests]) => { + nodeInfo.set(handlerName, { handlerName }); + const children: TreeDataItem[] = (handlerTests ?? []).map((test, i) => { + const id = test.createdAt; + nodeInfo.set(id, { handlerName, test }); + return { id, name: `Test ${i + 1}` }; + }); + return { id: handlerName, name: handlerName, children: children.length > 0 ? children : undefined }; + }); + return { items, nodeInfo }; +} + +export function AiObserverPanel({ tests, onAddTest }: Props) { + const testsObj = (tests ?? {}) as Record; + const { items, nodeInfo } = useMemo(() => buildTree(testsObj), [testsObj]); + + const [selectedId, setSelectedId] = useState(undefined); + const pendingAddRef = useRef(null); + const prevCountsRef = useRef>({}); + + useEffect(() => { + const pendingHandler = pendingAddRef.current; + if (pendingHandler) { + const currentCount = (testsObj[pendingHandler] ?? []).length; + const prevCount = prevCountsRef.current[pendingHandler] ?? 0; + if (currentCount > prevCount) { + const newest = testsObj[pendingHandler][currentCount - 1]; + setSelectedId(newest.createdAt); + pendingAddRef.current = null; + } + } + for (const [handlerName, handlerTests] of Object.entries(testsObj)) { + prevCountsRef.current[handlerName] = (handlerTests ?? []).length; + } + }, [testsObj]); + + const handleSelectChange = (item: TreeDataItem | undefined) => { + setSelectedId(item?.id); + }; + + const selected = selectedId ? nodeInfo.get(selectedId) : undefined; + + const handleAddTest = () => { + if (!selected) return; + pendingAddRef.current = selected.handlerName; + onAddTest?.({ handlerName: selected.handlerName }); + }; + + return ( + No AI-routed handlers yet.

+ ) : ( + + ) + } + right={ + selected ? ( +
+
+ {selected.handlerName} + +
+ {selected.test ? ( + + ) : ( +

Select a test to edit its input, or click Add Test to create one.

+ )} +
+ ) : ( +

Select a handler or test to view details.

+ ) + } + /> + ); +} diff --git a/apps/web/src/components/layout/JsonEditor.tsx b/apps/web/src/components/layout/JsonEditor.tsx new file mode 100644 index 0000000..c6dbdb2 --- /dev/null +++ b/apps/web/src/components/layout/JsonEditor.tsx @@ -0,0 +1,40 @@ +import { useState, useEffect, ChangeEvent } from 'react'; + +interface Props { + value: unknown; + onChange?: (parsed: unknown, raw: string) => void; +} + +export function JsonEditor({ value, onChange }: Props) { + const [text, setText] = useState(() => JSON.stringify(value, null, 2)); + const [error, setError] = useState(null); + + useEffect(() => { + setText(JSON.stringify(value, null, 2)); + setError(null); + }, [value]); + + function handleChange(e: ChangeEvent) { + const next = e.target.value; + setText(next); + try { + const parsed = JSON.parse(next); + setError(null); + onChange?.(parsed, next); + } catch (err) { + setError(err instanceof Error ? err.message : 'Invalid JSON'); + } + } + + return ( +
+