feat: agent node

This commit is contained in:
2026-03-11 14:39:38 +01:00
parent 94da42f9fc
commit f201fe92f4
15 changed files with 772 additions and 24 deletions

View File

@@ -2,7 +2,7 @@
* Settings dialog with sidebar-style nav (Appearance, AI). Reference: shadcn sidebar-13.
*/
import React, { useState } from 'react'
import React, { useCallback, useState } from 'react'
import {
Dialog,
DialogContent,
@@ -19,9 +19,11 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Input } from '@/components/ui/input'
import { useTheme } from '@/lib/themeContext'
import type { Theme } from '@/lib/themeContext'
import { usePlatform } from './platformContext'
import type { AiConnection, AiConnectionProvider } from './platformContext'
import { Sun, Sparkles } from 'lucide-react'
type SettingsSection = 'appearance' | 'ai'
@@ -34,7 +36,14 @@ export function SettingsDialog({
const [open, setOpen] = useState(false)
const [section, setSection] = useState<SettingsSection>('appearance')
const { theme, setTheme } = useTheme()
const { showMinimap, setShowMinimap } = usePlatform()
const { showMinimap, setShowMinimap, aiConnection, setAiConnection } = usePlatform()
const updateAiConnection = useCallback(
(partial: Partial<AiConnection>) => {
setAiConnection({ ...aiConnection, ...partial })
},
[aiConnection, setAiConnection]
)
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -110,14 +119,70 @@ export function SettingsDialog({
)}
{section === 'ai' && (
<div className="space-y-6">
<h3 className="text-sm font-medium text-muted-foreground">AI</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<span className="text-sm font-medium text-muted-foreground/80">
AI settings
</span>
<span className="text-xs text-muted-foreground sm:text-right">
Coming soon
</span>
<h3 className="text-sm font-medium text-muted-foreground">AI connection</h3>
<p className="text-xs text-muted-foreground">
Used by the Agent node. Choose OpenAI or a local server (e.g. LM Studio).
</p>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<label htmlFor="settings-ai-provider" className="text-sm font-medium">
Provider
</label>
<Select
value={aiConnection.provider}
onValueChange={(v) => updateAiConnection({ provider: v as AiConnectionProvider })}
>
<SelectTrigger id="settings-ai-provider" className="w-full min-w-[10rem] sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local (LM Studio, Ollama, etc.)</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
</div>
{aiConnection.provider === 'local' && (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_1fr] sm:items-center">
<label htmlFor="settings-ai-baseurl" className="text-sm font-medium">
Base URL
</label>
<Input
id="settings-ai-baseurl"
type="url"
placeholder="http://localhost:1234/v1"
value={aiConnection.baseURL}
onChange={(e) => updateAiConnection({ baseURL: e.target.value })}
className="font-mono text-xs"
/>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_1fr] sm:items-center">
<label htmlFor="settings-ai-model" className="text-sm font-medium">
Model
</label>
<Input
id="settings-ai-model"
type="text"
placeholder={aiConnection.provider === 'local' ? 'local-model' : 'gpt-4o-mini'}
value={aiConnection.model}
onChange={(e) => updateAiConnection({ model: e.target.value })}
className="font-mono text-xs"
/>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_1fr] sm:items-center">
<label htmlFor="settings-ai-apikey" className="text-sm font-medium">
API key
</label>
<Input
id="settings-ai-apikey"
type="password"
autoComplete="off"
placeholder={aiConnection.provider === 'openai' ? 'sk-...' : 'Optional for local'}
value={aiConnection.apiKey}
onChange={(e) => updateAiConnection({ apiKey: e.target.value })}
className="font-mono text-xs"
/>
</div>
</div>
</div>
)}

View File

@@ -12,8 +12,49 @@ const STORAGE_KEY = 'zui_platform_projects'
const ORDER_STORAGE_KEY = 'zui_platform_project_order'
const RECENT_STORAGE_KEY = 'zui_platform_recent_project_ids'
const CANVAS_MINIMAP_KEY = 'zui_canvas_show_minimap'
const AI_CONNECTION_KEY = 'zui_ai_connection'
const RECENT_MAX = 5
export type AiConnectionProvider = 'openai' | 'local'
export type AiConnection = {
provider: AiConnectionProvider
baseURL: string
model: string
apiKey: string
}
const DEFAULT_AI_CONNECTION: AiConnection = {
provider: 'local',
baseURL: 'http://localhost:1234/v1',
model: 'local-model',
apiKey: '',
}
function loadAiConnection(): AiConnection {
try {
const raw = localStorage.getItem(AI_CONNECTION_KEY)
if (!raw) return DEFAULT_AI_CONNECTION
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') return DEFAULT_AI_CONNECTION
const p = parsed as Record<string, unknown>
return {
provider: p.provider === 'openai' ? 'openai' : 'local',
baseURL: typeof p.baseURL === 'string' ? p.baseURL : DEFAULT_AI_CONNECTION.baseURL,
model: typeof p.model === 'string' ? p.model : DEFAULT_AI_CONNECTION.model,
apiKey: typeof p.apiKey === 'string' ? p.apiKey : '',
}
} catch {
return DEFAULT_AI_CONNECTION
}
}
function saveAiConnection(value: AiConnection) {
try {
localStorage.setItem(AI_CONNECTION_KEY, JSON.stringify(value))
} catch {}
}
function loadShowMinimap(): boolean {
try {
const raw = localStorage.getItem(CANVAS_MINIMAP_KEY)
@@ -126,6 +167,9 @@ export type PlatformContextValue = {
/** Canvas: show React Flow minimap (persisted) */
showMinimap: boolean
setShowMinimap: (value: boolean) => void
/** Agent node: AI connection (persisted). Sent to backend when running agent. */
aiConnection: AiConnection
setAiConnection: (value: AiConnection) => void
}
const PlatformContext = createContext<PlatformContextValue | null>(null)
@@ -135,12 +179,18 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
const [projectOrder, setProjectOrder] = useState<string[]>(loadOrder)
const [recentProjectIds, setRecentProjectIds] = useState<string[]>(loadRecentIds)
const [showMinimap, setShowMinimapState] = useState<boolean>(loadShowMinimap)
const [aiConnection, setAiConnectionState] = useState<AiConnection>(loadAiConnection)
const setShowMinimap = useCallback((value: boolean) => {
setShowMinimapState(value)
saveShowMinimap(value)
}, [])
const setAiConnection = useCallback((value: AiConnection) => {
setAiConnectionState(value)
saveAiConnection(value)
}, [])
const persist = useCallback((next: Project[]) => {
setProjects(next)
saveProjects(next)
@@ -247,6 +297,8 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
restoreProject,
showMinimap,
setShowMinimap,
aiConnection,
setAiConnection,
}),
[
projects,
@@ -263,6 +315,8 @@ export function PlatformProvider({ children }: { children: React.ReactNode }) {
restoreProject,
showMinimap,
setShowMinimap,
aiConnection,
setAiConnection,
]
)

View File

@@ -0,0 +1,203 @@
import React, { useCallback, useMemo, useState } from 'react'
import {
AbstractNodeProps,
createAbstractNodeComponent,
useAbstractNode,
} from '@/lib/abstractNode'
import { getConfigContent } from '@/lib/configTypes'
import {
BaseNode,
BaseNodeContent,
BaseNodeFooter,
BaseNodeHeaderRow,
} from '@/components/base/BaseNode'
import { NodeMenubar } from '@/components/base/NodeMenubar'
import { NodeFooterEdgeIndicators } from '@/components/base/NodeFooterEdgeIndicators'
import { NodeHeaderTitle } from '@/components/base/NodeHeaderTitle'
import { InputHandle, OutputHandle } from '@/components/base/NodeHandles'
import { Button } from '@/components/ui/button'
import { usePlatform } from '@/app/platform/platformContext'
import { Bot, Play, Loader2 } from 'lucide-react'
export type AgentNodeData = {
context?: string
outputMarkdown?: string
error?: string
loading?: boolean
}
type Props = AbstractNodeProps<AgentNodeData>
function serializeNodeForContext(nodes: { id: string; type?: string; data?: unknown }[], nodeId: string): string {
const node = nodes.find((n: { id: string }) => n.id === nodeId)
if (!node) return `${nodeId}: (not found)`
const type = node.type ?? 'unknown'
const data = node.data as Record<string, unknown> | undefined
if (type === 'config') {
const content = getConfigContent(data)
return `[config ${nodeId}]\n${content || '(empty)'}`
}
if (type === 'variable') {
const v = data?.value
return `[variable ${nodeId}]: ${v === undefined || v === null ? '' : String(v)}`
}
if (type === 'data') {
const rows = (data?.rows as Record<string, string>[] | undefined) ?? []
const columns = (data?.columns as string[] | undefined) ?? []
const preview = rows.slice(0, 20).map((r) => columns.map((c) => r[c] ?? '').join(', ')).join('\n')
return `[data ${nodeId}] ${columns.length} columns, ${rows.length} rows\n${preview}${rows.length > 20 ? '\n...' : ''}`
}
return `[${type} ${nodeId}]: ${JSON.stringify(data ?? {}).slice(0, 200)}`
}
function AgentNodeComponent({ id, data, width, height, selected }: Props) {
const { nodes, sourceIds, updateData } = useAbstractNode<AgentNodeData>(id, data ?? {})
const { aiConnection } = usePlatform()
const [running, setRunning] = useState(false)
const dimensions =
width != null && height != null && width > 0 && height > 0
? { width, height }
: undefined
const contextText = data?.context ?? ''
const outputMarkdown = data?.outputMarkdown
const error = data?.error
const loading = data?.loading ?? false
const connectedSources = useMemo(() => {
return sourceIds.map((sid) => {
const node = nodes.find((n: { id: string }) => n.id === sid)
return { id: sid, type: (node as { type?: string } | undefined)?.type ?? 'unknown' }
})
}, [sourceIds, nodes])
const runAgent = useCallback(async () => {
const configContents = sourceIds
.filter((sid) => {
const n = nodes.find((n: { id: string }) => n.id === sid)
return (n as { type?: string } | undefined)?.type === 'config'
})
.map((sid) => getConfigContent((nodes.find((n: { id: string }) => n.id === sid)?.data ?? undefined) as Record<string, unknown> | undefined))
const prompt = configContents.length > 0 ? configContents.join('\n\n---\n\n') : 'No prompt provided. Please describe what you want in structured markdown.'
const contextNodes = sourceIds.map((sid) => ({ id: sid, content: serializeNodeForContext(nodes, sid) }))
updateData({ error: undefined, loading: true })
setRunning(true)
try {
const res = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt,
context: contextText.trim() || undefined,
contextNodes,
connection: aiConnection,
}),
})
const json = await res.json().catch(() => ({}))
if (!res.ok) {
updateData({
loading: false,
error: (json as { error?: string }).error ?? `Request failed: ${res.status}`,
outputMarkdown: undefined,
})
return
}
const markdown = (json as { markdown?: string }).markdown ?? ''
updateData({ loading: false, error: undefined, outputMarkdown: markdown })
} catch (err: unknown) {
updateData({
loading: false,
error: err instanceof Error ? err.message : 'Agent request failed',
outputMarkdown: undefined,
})
} finally {
setRunning(false)
}
}, [sourceIds, nodes, contextText, updateData, aiConnection])
const onContextChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => updateData({ context: e.target.value }),
[updateData]
)
return (
<BaseNode
className="min-w-[360px] min-h-[320px]"
dimensions={dimensions}
selected={selected}
handles={
<>
<InputHandle id="in" nodeId={id} />
<OutputHandle id="out" />
</>
}
>
<BaseNodeHeaderRow
icon={<Bot className="size-4" />}
title={<NodeHeaderTitle nodeId={id} displayTitle={id} />}
/>
<BaseNodeContent>
<div className="shrink-0 w-full">
<NodeMenubar nodeId={id} nodeType="agent" />
</div>
<div className="flex flex-col gap-3 p-2 min-h-0 flex-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-foreground">Context</label>
<textarea
className="nodrag nopan w-full min-h-[72px] rounded-md border border-input bg-background px-2 py-1.5 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Additional context for the agent…"
value={contextText}
onChange={onContextChange}
/>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-foreground">Context from connected nodes</span>
{connectedSources.length === 0 ? (
<p className="text-xs text-muted-foreground">Connect Config, Variable, or Data nodes as input.</p>
) : (
<ul className="text-xs text-muted-foreground list-disc list-inside space-y-0.5">
{connectedSources.map(({ id: sid, type }) => (
<li key={sid}>
<code className="rounded bg-muted px-1">{sid}</code> ({type})
</li>
))}
</ul>
)}
</div>
<Button
type="button"
size="sm"
className="w-fit"
onClick={runAgent}
disabled={running || loading}
>
{running || loading ? (
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Run
</Button>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{outputMarkdown != null && outputMarkdown !== '' && !error && (
<div className="text-xs text-muted-foreground border rounded p-2 max-h-24 overflow-auto">
<span className="font-medium">Output:</span> {outputMarkdown.length} chars (connect to Renderer to view)
</div>
)}
</div>
</BaseNodeContent>
<BaseNodeFooter>
<NodeFooterEdgeIndicators nodeId={id} nodeType="agent">
{outputMarkdown != null ? `${outputMarkdown.length} chars` : error ? 'Error' : '—'}
</NodeFooterEdgeIndicators>
</BaseNodeFooter>
</BaseNode>
)
}
export const AgentNode = createAbstractNodeComponent<AgentNodeData>('AgentNode', AgentNodeComponent)
export default AgentNode

View File

@@ -64,10 +64,12 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
const incomingIds = sourceIds
const srcId = incomingIds.length > 0 ? incomingIds[0] : null
const srcNode = nodes.find((n: any) => n.id === srcId)
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : 'plantuml'
const isAgentSource = srcNode?.type === 'agent'
const agentOutputMarkdown = isAgentSource ? ((srcNode.data as { outputMarkdown?: string })?.outputMarkdown ?? '') : ''
const configTypeId = srcNode?.type === 'config' ? getConfigTypeId((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : isAgentSource ? 'markdown' : 'plantuml'
const configType = getConfigType(configTypeId)
const outputType = configType.outputType
const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : ''
const sourceContent = srcNode?.type === 'config' ? getConfigContent((srcNode.data ?? undefined) as Record<string, unknown> | undefined) : isAgentSource ? agentOutputMarkdown : ''
const srcData = srcNode?.data ?? {}
/** Set of node IDs that can affect this render node (configs in the chain + variables/functions feeding them) */
@@ -184,7 +186,10 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
if (!sourceContent && incomingIds.length > 0) {
setRenderedContent(null)
setResolvedContent(null)
setError({ kind: 'no-content', message: 'No content on connected configuration node' })
setError({
kind: 'no-content',
message: isAgentSource ? 'Run the Agent node to generate output.' : 'No content on connected configuration node',
})
setLoading(false)
return
}
@@ -205,6 +210,17 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
setLoading(true)
setError(null)
try {
if (srcNode?.type === 'agent') {
const md = (srcNode.data as { outputMarkdown?: string })?.outputMarkdown ?? ''
setResolvedContent(md)
const markdownType = getConfigType('markdown')
const html = await markdownType.render(md)
if (cancelled || thisRunId !== runIdRef.current) return
setRenderedContent(html)
setError(null)
setLoading(false)
return
}
const configIdsUsed = new Set<string>()
const isReachable = (startId: string, targetId: string) => {
@@ -531,7 +547,7 @@ function RenderingNodeComponent({ id, data, width, height, selected }: Props) {
}
}
// Only re-run when inputs that affect the resolved output change (signatures + source). Debounced to avoid excessive re-renders while typing. retryCount triggers re-run on Retry.
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount])
}, [id, sourceContent, configTypeId, configSignature, edgesSignature, variablesSignature, functionsSignature, dataSignature, viewportWidth, viewportHeight, retryCount, isAgentSource])
const dimensions =
width != null && height != null && width > 0 && height > 0

View File

@@ -71,6 +71,7 @@ export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number
variable: { width: 224, height: 180 },
function: { width: 288, height: 260 },
data: { width: 360, height: 280 },
agent: { width: 360, height: 320 },
}
/** Default data for a new node. Uses nodeRegistry when type is registered. */

View File

@@ -1,6 +1,6 @@
import type React from 'react'
export type NodeType = 'config' | 'render' | 'variable' | 'function' | 'data'
export type NodeType = 'config' | 'render' | 'variable' | 'function' | 'data' | 'agent'
export type NodeHelpEntry = {
title: string
@@ -90,6 +90,19 @@ export const NODE_HELP: Record<NodeType, NodeHelpEntry> = {
</>
),
},
agent: {
title: 'Agent node',
content: (
<>
<Section title="How to use">
<p>Agent nodes use AI to produce structured markdown. Connect Config nodes as the <strong>prompt</strong> (their content is sent as the main prompt). Optionally connect Variable or Data nodes; they are passed as context. Define additional context in the text area. Click Run to execute the agent; output is markdown. Connect this node to a Renderer to display the result.</p>
</Section>
<Section title="Output">
<p>When connected to a Renderer node, the agents markdown output is rendered there. Ensure the backend <Code>/api/agent</Code> is running and (if using OpenAI) <Code>OPENAI_API_KEY</Code> is set.</p>
</Section>
</>
),
},
}
export function getNodeHelp(nodeType: NodeType): NodeHelpEntry {

View File

@@ -7,13 +7,14 @@
import type React from 'react'
import type { Node } from '@xyflow/react'
/** Node classification for UI: Psyche (Form/Config, Variable, Behavior/Function), Pneuma (Rendering), Physis (TBD). */
export type NodeClassification = 'psyche' | 'pneuma' | 'physis'
/** Node classification for UI: Psyche, Pneuma, Physis, Archon (AI Agent). */
export type NodeClassification = 'psyche' | 'pneuma' | 'physis' | 'archon'
export const NODE_CLASSIFICATION_LABELS: Record<NodeClassification, string> = {
psyche: 'Psyche',
pneuma: 'Pneuma',
physis: 'Physis',
archon: 'Archon',
}
export type NodeHelpEntry = {
@@ -59,7 +60,7 @@ export function getNodeType(id: string): NodeTypeDescriptor | undefined {
return registry.get(id)
}
const CLASSIFICATION_ORDER: NodeClassification[] = ['psyche', 'pneuma', 'physis']
const CLASSIFICATION_ORDER: NodeClassification[] = ['psyche', 'pneuma', 'physis', 'archon']
export function getRegisteredNodeTypes(): NodeTypeDescriptor[] {
return Array.from(registry.values())

View File

@@ -8,7 +8,8 @@ import type { RenderingNodeData } from '@/components/nodes/RenderingNode'
import type { VariableNodeData } from '@/components/nodes/VariableNode'
import type { FunctionNodeData } from '@/components/nodes/FunctionNode'
import type { DataNodeData } from '@/components/nodes/DataNode'
import type { AgentNodeData } from '@/components/nodes/AgentNode'
export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData | DataNodeData
export type AppNodeData = ConfigNodeData | RenderingNodeData | VariableNodeData | FunctionNodeData | DataNodeData | AgentNodeData
export type AppNode = Node<AppNodeData>
export type AppEdge = Edge

View File

@@ -4,7 +4,7 @@
*/
import React from 'react'
import { ScrollText, Sparkles, Variable, Code2, Database } from 'lucide-react'
import { ScrollText, Sparkles, Variable, Code2, Database, Bot } from 'lucide-react'
import { registerNodeType } from './nodeRegistry'
import { NODE_HELP } from './nodeHelp'
import ConfigNode from '../components/nodes/ConfigNode'
@@ -12,6 +12,7 @@ import RenderingNode from '../components/nodes/RenderingNode'
import VariableNode from '../components/nodes/VariableNode'
import FunctionNode from '../components/nodes/FunctionNode'
import DataNode from '../components/nodes/DataNode'
import AgentNode from '../components/nodes/AgentNode'
const ICON_CLASS = 'mr-2 h-4 w-4'
@@ -26,7 +27,7 @@ export function registerBuiltinNodes(): void {
hasOutput: true,
classification: 'psyche',
allowedSourceTypes: ['config', 'variable', 'function', 'data'],
allowedTargetTypes: ['config', 'render'],
allowedTargetTypes: ['config', 'render', 'agent'],
help: NODE_HELP.config,
menuLabel: 'Config',
menuIcon: <ScrollText className={ICON_CLASS} />,
@@ -52,13 +53,30 @@ export function registerBuiltinNodes(): void {
hasInput: true,
hasOutput: false,
classification: 'pneuma',
allowedSourceTypes: ['config'],
allowedSourceTypes: ['config', 'agent'],
help: NODE_HELP.render,
menuLabel: 'Renderer',
menuIcon: <Sparkles className={ICON_CLASS} />,
connectionLabel: 'rendering',
})
registerNodeType({
id: 'agent',
component: AgentNode,
defaultStyle: { width: 360, height: 320 },
defaultData: { context: '' },
idPrefix: 'agt_',
hasInput: true,
hasOutput: true,
classification: 'archon',
allowedSourceTypes: ['config', 'variable', 'data'],
allowedTargetTypes: ['render'],
help: NODE_HELP.agent,
menuLabel: 'Agent',
menuIcon: <Bot className={ICON_CLASS} />,
connectionLabel: 'prompt/context',
})
registerNodeType({
id: 'variable',
component: VariableNode,
@@ -68,7 +86,7 @@ export function registerBuiltinNodes(): void {
hasInput: false,
hasOutput: true,
classification: 'psyche',
allowedTargetTypes: ['config', 'function'],
allowedTargetTypes: ['config', 'function', 'agent'],
help: NODE_HELP.variable,
menuLabel: 'Variable',
menuIcon: <Variable className={ICON_CLASS} />,
@@ -83,7 +101,7 @@ export function registerBuiltinNodes(): void {
hasInput: false,
hasOutput: true,
classification: 'physis',
allowedTargetTypes: ['config'],
allowedTargetTypes: ['config', 'agent'],
help: NODE_HELP.data,
menuLabel: 'Data',
menuIcon: <Database className={ICON_CLASS} />,

View File

@@ -20,6 +20,10 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
},
'/api/agent': {
target: 'http://localhost:8080',
changeOrigin: true,
},
// Kroki diagram service
'/api/kroki': {
target: 'https://kroki.io',