From d31b821963fc1b3542a67aaefd5922c63657cd5e Mon Sep 17 00:00:00 2001 From: Adil Bayramoglu Date: Tue, 17 Feb 2026 20:15:44 +0400 Subject: [PATCH] feat: add AI agent feature with tools, streaming, and auth --- apps/desktop/src/main/index.ts | 25 + apps/desktop/src/preload/index.ts | 5 + apps/desktop/src/renderer/env.d.ts | 5 + apps/desktop/src/renderer/main.tsx | 3 + packages/client/package.json | 3 + .../client/src/app/router/route-tree.gen.ts | 266 +-- packages/client/src/app/styles.css | 70 + .../client/src/features/agent/agent-logger.ts | 195 +++ .../src/features/agent/context-builder.ts | 544 +++++++ packages/client/src/features/agent/index.ts | 16 + packages/client/src/features/agent/layout.ts | 159 ++ .../src/features/agent/tool-executor.ts | 1437 +++++++++++++++++ .../client/src/features/agent/tool-schemas.ts | 175 ++ packages/client/src/features/agent/types.ts | 99 ++ .../src/features/agent/use-agent-chat.ts | 1030 ++++++++++++ .../src/features/agent/use-openrouter-key.ts | 32 + .../client/src/pages/flow/agent-panel.tsx | 744 +++++++++ packages/client/src/pages/flow/context.tsx | 2 + packages/client/src/pages/flow/edit.tsx | 56 +- packages/spec/api/flow.tsp | 97 +- packages/spec/api/http.tsp | 4 +- packages/spec/api/main.tsp | 11 +- packages/spec/package.json | 12 +- packages/spec/tsconfig.lib.json | 2 +- packages/spec/tspconfig.yaml | 1 + pnpm-lock.yaml | 1327 +++++++++++++-- pnpm-workspace.yaml | 16 +- tools/spec-lib/package.json | 13 +- tools/spec-lib/src/ai-tools/emitter.tsx | 376 +++++ tools/spec-lib/src/ai-tools/field-schema.ts | 175 ++ tools/spec-lib/src/ai-tools/index.ts | 2 + tools/spec-lib/src/ai-tools/lib.ts | 96 ++ tools/spec-lib/src/ai-tools/main.tsp | 39 + tools/spec-lib/src/common.ts | 167 ++ 34 files changed, 6865 insertions(+), 339 deletions(-) create mode 100644 packages/client/src/features/agent/agent-logger.ts create mode 100644 packages/client/src/features/agent/context-builder.ts create mode 100644 packages/client/src/features/agent/index.ts create mode 100644 packages/client/src/features/agent/layout.ts create mode 100644 packages/client/src/features/agent/tool-executor.ts create mode 100644 packages/client/src/features/agent/tool-schemas.ts create mode 100644 packages/client/src/features/agent/types.ts create mode 100644 packages/client/src/features/agent/use-agent-chat.ts create mode 100644 packages/client/src/features/agent/use-openrouter-key.ts create mode 100644 packages/client/src/pages/flow/agent-panel.tsx create mode 100644 tools/spec-lib/src/ai-tools/emitter.tsx create mode 100644 tools/spec-lib/src/ai-tools/field-schema.ts create mode 100644 tools/spec-lib/src/ai-tools/index.ts create mode 100644 tools/spec-lib/src/ai-tools/lib.ts create mode 100644 tools/spec-lib/src/ai-tools/main.tsp create mode 100644 tools/spec-lib/src/common.ts diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 0c365b940..b6a00abc8 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -4,6 +4,7 @@ import * as NodeRuntime from '@effect/platform-node/NodeRuntime'; import { Config, Console, Effect, pipe, Runtime, String } from 'effect'; import { app, BrowserWindow, dialog, Dialog, globalShortcut, ipcMain, nativeTheme, protocol, shell } from 'electron'; import { autoUpdater } from 'electron-updater'; +import fs from 'node:fs'; import os from 'node:os'; import { Agent } from 'undici'; import icon from '../../build/icon.ico?asset'; @@ -200,6 +201,30 @@ const onReady = Effect.gen(function* () { ipcMain.on('update:start', () => void autoUpdater.downloadUpdate()); autoUpdater.on('download-progress', (_) => void mainWindow.webContents.send('update:progress', _)); autoUpdater.on('update-downloaded', () => void autoUpdater.quitAndInstall()); + + // Agent logging + const logDir = path.join(app.getPath('userData'), 'logs', 'agent'); + fs.mkdirSync(logDir, { recursive: true }); + + ipcMain.on('agent-log:write', (_event, fileName: string, jsonLine: string) => { + const filePath = path.join(logDir, path.basename(fileName)); + void fs.promises.appendFile(filePath, jsonLine); + }); + + ipcMain.on('agent-log:cleanup', () => { + const maxAge = 7 * 24 * 60 * 60 * 1000; + fs.readdir(logDir, (err, files) => { + if (err) return; + const now = Date.now(); + for (const file of files) { + const filePath = path.join(logDir, file); + fs.stat(filePath, (err, stats) => { + if (err) return; + if (now - stats.mtimeMs > maxAge) void fs.promises.unlink(filePath).catch(() => undefined); + }); + } + }); + }); }); const onActivate = Effect.gen(function* () { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 4abc6a5a8..854cdc14c 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -17,4 +17,9 @@ contextBridge.exposeInMainWorld('electron', { onProgress: (callback: (info: ProgressInfo) => void) => ipcRenderer.on('update:progress', (_, info) => void callback(info as ProgressInfo)), }, + + agentLog: { + cleanup: () => void ipcRenderer.send('agent-log:cleanup'), + write: (fileName: string, jsonLine: string) => void ipcRenderer.send('agent-log:write', fileName, jsonLine), + }, }); diff --git a/apps/desktop/src/renderer/env.d.ts b/apps/desktop/src/renderer/env.d.ts index 2868decab..cfe84dff1 100644 --- a/apps/desktop/src/renderer/env.d.ts +++ b/apps/desktop/src/renderer/env.d.ts @@ -15,6 +15,11 @@ declare global { onProgress: (callback: (info: ProgressInfo) => void) => void; }; + + agentLog: { + cleanup: () => void; + write: (fileName: string, jsonLine: string) => void; + }; }; } } diff --git a/apps/desktop/src/renderer/main.tsx b/apps/desktop/src/renderer/main.tsx index 3bd70ca33..0b27b0e3f 100644 --- a/apps/desktop/src/renderer/main.tsx +++ b/apps/desktop/src/renderer/main.tsx @@ -18,6 +18,9 @@ setTheme(); pipe(configProviderFromMetaEnv({ VERSION: packageJson.version }), Layer.setConfigProvider, addGlobalLayer); +// Trigger cleanup of old agent log files (7-day retention) +window.electron.agentLog.cleanup(); + const updateCheckAtom = runtimeAtom.atom( Effect.gen(function* () { const client = pipe( diff --git a/packages/client/package.json b/packages/client/package.json index 5ea3717ed..adfa823d6 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -48,6 +48,7 @@ "@xyflow/react": "catalog:", "effect": "catalog:", "id128": "catalog:", + "openai": "catalog:", "prettier": "catalog:", "react": "catalog:", "react-aria": "catalog:", @@ -55,10 +56,12 @@ "react-dom": "catalog:", "react-error-boundary": "catalog:", "react-icons": "catalog:", + "react-markdown": "catalog:", "react-resizable-panels": "catalog:", "react-scan": "catalog:", "react-stately": "catalog:", "react-timeago": "catalog:", + "remark-gfm": "catalog:", "tailwind-merge": "catalog:", "tailwind-variants": "catalog:", "use-debounce": "catalog:" diff --git a/packages/client/src/app/router/route-tree.gen.ts b/packages/client/src/app/router/route-tree.gen.ts index dbbe4df20..138ce8a78 100644 --- a/packages/client/src/app/router/route-tree.gen.ts +++ b/packages/client/src/app/router/route-tree.gen.ts @@ -9,137 +9,139 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRouteImport } from './../../pages/dashboard/routes/index' -import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteImport } from './../../pages/workspace/routes/workspace/$workspaceIdCan/route' -import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRouteImport } from './../../pages/workspace/routes/workspace/$workspaceIdCan/index' -import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteImport } from './../../pages/http/routes/http/$httpIdCan/route' -import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteImport } from './../../pages/flow/routes/flow/$flowIdCan/route' -import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRouteImport } from './../../pages/http/routes/http/$httpIdCan/index' -import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRouteImport } from './../../pages/flow/routes/flow/$flowIdCan/index' -import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRouteImport } from './../../pages/credential/routes/credential/$credentialIdCan/index' -import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRouteImport } from './../../pages/flow/routes/flow/$flowIdCan/history' -import { Route as dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRouteImport } from './../../pages/http/routes/http/$httpIdCan/delta.$deltaHttpIdCan' +import { Route as dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesIndexRouteImport } from './../../pages/dashboard/routes/index' +import { Route as dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteImport } from './../../pages/workspace/routes/workspace/$workspaceIdCan/route' +import { Route as dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRouteImport } from './../../pages/workspace/routes/workspace/$workspaceIdCan/index' +import { Route as dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteImport } from './../../pages/http/routes/http/$httpIdCan/route' +import { Route as dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteImport } from './../../pages/flow/routes/flow/$flowIdCan/route' +import { Route as dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRouteImport } from './../../pages/http/routes/http/$httpIdCan/index' +import { Route as dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRouteImport } from './../../pages/flow/routes/flow/$flowIdCan/index' +import { Route as dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRouteImport } from './../../pages/credential/routes/credential/$credentialIdCan/index' +import { Route as dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRouteImport } from './../../pages/flow/routes/flow/$flowIdCan/history' +import { Route as dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRouteImport } from './../../pages/http/routes/http/$httpIdCan/delta.$deltaHttpIdCan' -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRoute = - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRouteImport.update({ - id: '/(dashboard)/', - path: '/', - getParentRoute: () => rootRouteImport, - } as any) -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute = - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteImport.update( +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesIndexRoute = + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesIndexRouteImport.update( + { + id: '/(dashboard)/', + path: '/', + getParentRoute: () => rootRouteImport, + } as any, + ) +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute = + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteImport.update( { id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan', path: '/workspace/$workspaceIdCan', getParentRoute: () => rootRouteImport, } as any, ) -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute = - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRouteImport.update( +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute = + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRouteImport.update( { id: '/', path: '/', getParentRoute: () => - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute, } as any, ) -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute = - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteImport.update( +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute = + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteImport.update( { id: '/(http)/http/$httpIdCan', path: '/http/$httpIdCan', getParentRoute: () => - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute, } as any, ) -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute = - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteImport.update( +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute = + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteImport.update( { id: '/(flow)/flow/$flowIdCan', path: '/flow/$flowIdCan', getParentRoute: () => - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute, } as any, ) -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute = - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRouteImport.update( +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute = + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRouteImport.update( { id: '/', path: '/', getParentRoute: () => - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute, } as any, ) -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute = - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRouteImport.update( +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute = + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRouteImport.update( { id: '/', path: '/', getParentRoute: () => - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute, } as any, ) -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute = - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRouteImport.update( +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute = + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRouteImport.update( { id: '/(credential)/credential/$credentialIdCan/', path: '/credential/$credentialIdCan/', getParentRoute: () => - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute, } as any, ) -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute = - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRouteImport.update( +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute = + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRouteImport.update( { id: '/history', path: '/history', getParentRoute: () => - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute, } as any, ) -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute = - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRouteImport.update( +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute = + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRouteImport.update( { id: '/delta/$deltaHttpIdCan', path: '/delta/$deltaHttpIdCan', getParentRoute: () => - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute, } as any, ) export interface FileRoutesByFullPath { - '/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRoute - '/workspace/$workspaceIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteWithChildren - '/workspace/$workspaceIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute - '/workspace/$workspaceIdCan/flow/$flowIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren - '/workspace/$workspaceIdCan/http/$httpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren - '/workspace/$workspaceIdCan/flow/$flowIdCan/history': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute - '/workspace/$workspaceIdCan/credential/$credentialIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute - '/workspace/$workspaceIdCan/flow/$flowIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute - '/workspace/$workspaceIdCan/http/$httpIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute - '/workspace/$workspaceIdCan/http/$httpIdCan/delta/$deltaHttpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute + '/': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesIndexRoute + '/workspace/$workspaceIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteWithChildren + '/workspace/$workspaceIdCan/': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute + '/workspace/$workspaceIdCan/flow/$flowIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren + '/workspace/$workspaceIdCan/http/$httpIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren + '/workspace/$workspaceIdCan/flow/$flowIdCan/history': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute + '/workspace/$workspaceIdCan/credential/$credentialIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute + '/workspace/$workspaceIdCan/flow/$flowIdCan/': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute + '/workspace/$workspaceIdCan/http/$httpIdCan/': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute + '/workspace/$workspaceIdCan/http/$httpIdCan/delta/$deltaHttpIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute } export interface FileRoutesByTo { - '/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRoute - '/workspace/$workspaceIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute - '/workspace/$workspaceIdCan/flow/$flowIdCan/history': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute - '/workspace/$workspaceIdCan/credential/$credentialIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute - '/workspace/$workspaceIdCan/flow/$flowIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute - '/workspace/$workspaceIdCan/http/$httpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute - '/workspace/$workspaceIdCan/http/$httpIdCan/delta/$deltaHttpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute + '/': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesIndexRoute + '/workspace/$workspaceIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute + '/workspace/$workspaceIdCan/flow/$flowIdCan/history': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute + '/workspace/$workspaceIdCan/credential/$credentialIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute + '/workspace/$workspaceIdCan/flow/$flowIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute + '/workspace/$workspaceIdCan/http/$httpIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute + '/workspace/$workspaceIdCan/http/$httpIdCan/delta/$deltaHttpIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute } export interface FileRoutesById { __root__: typeof rootRouteImport - '/(dashboard)/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRoute - '/(dashboard)/(workspace)/workspace/$workspaceIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteWithChildren - '/(dashboard)/(workspace)/workspace/$workspaceIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute - '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren - '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren - '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/history': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute - '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(credential)/credential/$credentialIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute - '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute - '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute - '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/delta/$deltaHttpIdCan': typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute + '/(dashboard)/': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesIndexRoute + '/(dashboard)/(workspace)/workspace/$workspaceIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteWithChildren + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/history': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(credential)/credential/$credentialIdCan/': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute + '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/delta/$deltaHttpIdCan': typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -178,8 +180,8 @@ export interface FileRouteTypes { fileRoutesById: FileRoutesById } export interface RootRouteChildren { - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRoute - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteWithChildren + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesIndexRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesIndexRoute + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteWithChildren } declare module '@tanstack/react-router' { @@ -188,140 +190,140 @@ declare module '@tanstack/react-router' { id: '/(dashboard)/' path: '/' fullPath: '/' - preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRouteImport + preLoaderRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesIndexRouteImport parentRoute: typeof rootRouteImport } '/(dashboard)/(workspace)/workspace/$workspaceIdCan': { id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan' path: '/workspace/$workspaceIdCan' fullPath: '/workspace/$workspaceIdCan' - preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteImport + preLoaderRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteImport parentRoute: typeof rootRouteImport } '/(dashboard)/(workspace)/workspace/$workspaceIdCan/': { id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan/' path: '/' fullPath: '/workspace/$workspaceIdCan/' - preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRouteImport - parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute + preLoaderRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRouteImport + parentRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute } '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan': { id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan' path: '/http/$httpIdCan' fullPath: '/workspace/$workspaceIdCan/http/$httpIdCan' - preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteImport - parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute + preLoaderRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteImport + parentRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute } '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan': { id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan' path: '/flow/$flowIdCan' fullPath: '/workspace/$workspaceIdCan/flow/$flowIdCan' - preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteImport - parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute + preLoaderRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteImport + parentRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute } '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/': { id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/' path: '/' fullPath: '/workspace/$workspaceIdCan/http/$httpIdCan/' - preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRouteImport - parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute + preLoaderRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRouteImport + parentRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute } '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/': { id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/' path: '/' fullPath: '/workspace/$workspaceIdCan/flow/$flowIdCan/' - preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRouteImport - parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute + preLoaderRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRouteImport + parentRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute } '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(credential)/credential/$credentialIdCan/': { id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(credential)/credential/$credentialIdCan/' path: '/credential/$credentialIdCan' fullPath: '/workspace/$workspaceIdCan/credential/$credentialIdCan' - preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRouteImport - parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute + preLoaderRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRouteImport + parentRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute } '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/history': { id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(flow)/flow/$flowIdCan/history' path: '/history' fullPath: '/workspace/$workspaceIdCan/flow/$flowIdCan/history' - preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRouteImport - parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute + preLoaderRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRouteImport + parentRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute } '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/delta/$deltaHttpIdCan': { id: '/(dashboard)/(workspace)/workspace/$workspaceIdCan/(http)/http/$httpIdCan/delta/$deltaHttpIdCan' path: '/delta/$deltaHttpIdCan' fullPath: '/workspace/$workspaceIdCan/http/$httpIdCan/delta/$deltaHttpIdCan' - preLoaderRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRouteImport - parentRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute + preLoaderRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRouteImport + parentRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute } } } -interface dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteChildren { - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute +interface dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteChildren { + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute } -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteChildren: dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteChildren = +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteChildren: dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteChildren = { - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute: - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute, - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute: - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute: + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanHistoryRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute: + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanIndexRoute, } -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren = - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute._addFileChildren( - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteChildren, +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren = + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute._addFileChildren( + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteChildren, ) -interface dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteChildren { - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute +interface dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteChildren { + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute } -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteChildren: dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteChildren = +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteChildren: dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteChildren = { - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute: - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute, - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute: - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute: + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanIndexRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute: + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanDeltaDotdeltaHttpIdCanRoute, } -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren = - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute._addFileChildren( - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteChildren, +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren = + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute._addFileChildren( + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteChildren, ) -interface dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteChildren { - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute: typeof dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute +interface dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteChildren { + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute: typeof dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute } -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteChildren: dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteChildren = +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteChildren: dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteChildren = { - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute: - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute, - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute: - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren, - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute: - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren, - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute: - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute: + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanIndexRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRoute: + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotFlowRoutesFlowFlowIdCanRouteRouteWithChildren, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRoute: + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotHttpRoutesHttpHttpIdCanRouteRouteWithChildren, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute: + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotCredentialRoutesCredentialCredentialIdCanIndexRoute, } -const dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteWithChildren = - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute._addFileChildren( - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteChildren, +const dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteWithChildren = + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute._addFileChildren( + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteChildren, ) const rootRouteChildren: RootRouteChildren = { - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRoute: - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesIndexRoute, - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute: - dashboardDotDotDotDotDotDotDotDotPagesDashboardRoutesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteWithChildren, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesIndexRoute: + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesIndexRoute, + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRoute: + dashboardDotDotChar92DotDotChar92DotDotChar92DotDotChar92pagesChar92dashboardChar92routesDotDotDotDotWorkspaceRoutesWorkspaceWorkspaceIdCanRouteRouteWithChildren, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/packages/client/src/app/styles.css b/packages/client/src/app/styles.css index 18a3effde..f5b5faa90 100644 --- a/packages/client/src/app/styles.css +++ b/packages/client/src/app/styles.css @@ -6,6 +6,76 @@ @source '..'; +:root { + --surface-1: #fefefe; + --surface-2: #ffffff; + --surface-3: #f7f7f7; + --surface-4: #f5f5f5; + --surface-5: #f3f3f3; + --surface-6: #f0f0f0; + --surface-7: #ececec; + --border: #e0e0e0; + --border-1: #e0e0e0; + --divider: #ededed; + --text-primary: #2d2d2d; + --text-secondary: #404040; + --text-tertiary: #5c5c5c; + --text-muted: #737373; + --text-subtle: #8c8c8c; + --text-inverse: #ffffff; + --text-error: #ef4444; + --brand-400: #8e4cfb; + --brand-secondary: #33b4ff; + --brand-tertiary-2: #32bd7e; + --shimmer-highlight: rgba(0, 0, 0, 0.7); +} + +.dark { + --surface-1: #1e1e1e; + --surface-2: #232323; + --surface-3: #242424; + --surface-4: #292929; + --surface-5: #363636; + --surface-6: #454545; + --surface-7: #454545; + --border: #2c2c2c; + --border-1: #3d3d3d; + --divider: #393939; + --text-primary: #e6e6e6; + --text-secondary: #cccccc; + --text-tertiary: #b3b3b3; + --text-muted: #787878; + --text-subtle: #7d7d7d; + --text-inverse: #1b1b1b; + --text-error: #ef4444; + --brand-400: #8e4cfb; + --brand-secondary: #33b4ff; + --brand-tertiary-2: #32bd7e; + --bg: #1b1b1b; + --shimmer-highlight: rgba(255, 255, 255, 0.85); +} + +@keyframes thinking-shimmer { + 0% { + background-position: 150% 0; + } + 50% { + background-position: 0% 0; + } + 100% { + background-position: -150% 0; + } +} + +@keyframes toolcall-shimmer { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + html, body, #root { diff --git a/packages/client/src/features/agent/agent-logger.ts b/packages/client/src/features/agent/agent-logger.ts new file mode 100644 index 000000000..80cba603b --- /dev/null +++ b/packages/client/src/features/agent/agent-logger.ts @@ -0,0 +1,195 @@ +/** JSON stringify with BigInt support */ +const safeStringify = (value: unknown): string => + JSON.stringify(value, (_key: string, v: unknown) => (typeof v === 'bigint' ? v.toString() : v)); + +/** Truncate a string to maxLen, appending '...[truncated]' if needed */ +const truncate = (s: string, maxLen = 2048): string => (s.length <= maxLen ? s : s.slice(0, maxLen) + '...[truncated]'); + +interface AgentLogIpc { + cleanup: () => void; + write: (fileName: string, jsonLine: string) => void; +} + +interface LogEntry { + [key: string]: unknown; + event: string; + sessionId: string; + ts: string; +} + +/** Get the agentLog IPC bridge if running inside Electron, null otherwise */ +const getAgentLogIpc = (): AgentLogIpc | null => { + if (typeof window === 'undefined') return null; + const electron = (window as unknown as { electron?: { agentLog?: AgentLogIpc } }).electron; + return electron?.agentLog ?? null; +}; + +/** + * JSONL logger for agent conversations. + * Writes to local files via Electron IPC. Silent no-op when running outside Electron. + */ +export class AgentLogger { + private buffer: string[] = []; + private fileName: string; + private flushTimer: null | ReturnType = null; + private ipc: AgentLogIpc | null; + private sessionId: string; + private sessionStart: number; + + constructor(flowId: string) { + this.sessionId = crypto.randomUUID(); + this.sessionStart = performance.now(); + this.ipc = getAgentLogIpc(); + const shortFlowId = flowId.slice(0, 8); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + this.fileName = `agent-${shortFlowId}-${ts}-${this.sessionId.slice(0, 8)}.jsonl`; + } + + private write(entry: LogEntry) { + if (!this.ipc) return; + this.buffer.push(safeStringify(entry)); + this.flushTimer ??= setTimeout(() => void this.flush(), 100); + } + + private flush() { + if (!this.ipc || this.buffer.length === 0) return; + const batch = this.buffer.join('\n') + '\n'; + this.buffer = []; + this.ipc.write(this.fileName, batch); + } + + // --- Event methods --- + + logSessionStart(flowId: string, messageContent: string) { + this.write({ + event: 'session_start', + flowId, + sessionId: this.sessionId, + ts: new Date().toISOString(), + userMessagePreview: truncate(messageContent, 500), + }); + } + + logSessionEnd(success: boolean, aborted: boolean) { + this.write({ + aborted, + durationMs: Math.round(performance.now() - this.sessionStart), + event: 'session_end', + sessionId: this.sessionId, + success, + ts: new Date().toISOString(), + }); + // Flush synchronously on close + this.close(); + } + + logSystemPrompt(prompt: string, contextStats: { edges: number; nodes: number; variables: number }) { + this.write({ + contextStats, + event: 'system_prompt', + promptLength: prompt.length, + sessionId: this.sessionId, + ts: new Date().toISOString(), + }); + } + + logUserMessage(content: string) { + this.write({ + content: truncate(content), + event: 'user_message', + sessionId: this.sessionId, + ts: new Date().toISOString(), + }); + } + + logAssistantMessage(content: string) { + this.write({ + content: truncate(content), + event: 'assistant_message', + sessionId: this.sessionId, + ts: new Date().toISOString(), + }); + } + + logApiRequest(model: string, messageCount: number, hasTools: boolean) { + this.write({ + event: 'api_request', + hasTools, + messageCount, + model, + sessionId: this.sessionId, + ts: new Date().toISOString(), + }); + } + + logApiResponse( + latencyMs: number, + finishReason: null | string | undefined, + usage: null | undefined | { completion_tokens?: number; prompt_tokens?: number; total_tokens?: number }, + ) { + this.write({ + event: 'api_response', + finishReason: finishReason ?? 'unknown', + latencyMs: Math.round(latencyMs), + sessionId: this.sessionId, + ts: new Date().toISOString(), + usage: usage ?? null, + }); + } + + logToolCallStart(toolCallId: string, toolName: string, args: Record) { + this.write({ + args: truncate(safeStringify(args)), + event: 'tool_call_start', + sessionId: this.sessionId, + toolCallId, + toolName, + ts: new Date().toISOString(), + }); + } + + logToolCallEnd(toolCallId: string, toolName: string, durationMs: number, result: string, error?: string) { + this.write({ + durationMs: Math.round(durationMs), + error: error ?? undefined, + event: 'tool_call_end', + result: truncate(result), + sessionId: this.sessionId, + toolCallId, + toolName, + ts: new Date().toISOString(), + }); + } + + logValidation(orphanCount: number, orphanNames: string[]) { + this.write({ + event: 'validation', + orphanCount, + orphanNames, + sessionId: this.sessionId, + ts: new Date().toISOString(), + }); + } + + logError(error: unknown, phase: string) { + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + this.write({ + event: 'error', + message, + phase, + sessionId: this.sessionId, + stack, + ts: new Date().toISOString(), + }); + } + + /** Flush remaining buffer immediately */ + close() { + if (this.flushTimer) { + clearTimeout(this.flushTimer); + this.flushTimer = null; + } + this.flush(); + } +} diff --git a/packages/client/src/features/agent/context-builder.ts b/packages/client/src/features/agent/context-builder.ts new file mode 100644 index 000000000..b746b5b88 --- /dev/null +++ b/packages/client/src/features/agent/context-builder.ts @@ -0,0 +1,544 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +import { eq, useLiveQuery } from '@tanstack/react-db'; +import { Ulid } from 'id128'; +import { FlowItemState, NodeKind } from '@the-dev-tools/spec/buf/api/flow/v1/flow_pb'; +import { HttpMethod } from '@the-dev-tools/spec/buf/api/http/v1/http_pb'; +import { + EdgeCollectionSchema, + FlowVariableCollectionSchema, + NodeCollectionSchema, + NodeExecutionCollectionSchema, + NodeHttpCollectionSchema, +} from '@the-dev-tools/spec/tanstack-db/v1/api/flow'; +import { HttpCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/http'; +import { useApiCollection } from '~/shared/api'; +import { queryCollection } from '~/shared/lib'; +import type { EdgeInfo, FlowContextData, NodeExecutionInfo, NodeInfo, VariableInfo } from './types'; + +const NODE_KIND_NAMES: Record = { + [NodeKind.AI]: 'Ai', + [NodeKind.CONDITION]: 'Condition', + [NodeKind.FOR]: 'For', + [NodeKind.FOR_EACH]: 'ForEach', + [NodeKind.HTTP]: 'HTTP', + [NodeKind.JS]: 'JavaScript', + [NodeKind.MANUAL_START]: 'ManualStart', + [NodeKind.UNSPECIFIED]: 'Unknown', +}; + +const FLOW_ITEM_STATE_NAMES: Record = { + [FlowItemState.CANCELED]: 'Canceled', + [FlowItemState.FAILURE]: 'Failure', + [FlowItemState.RUNNING]: 'Running', + [FlowItemState.SUCCESS]: 'Success', + [FlowItemState.UNSPECIFIED]: 'Idle', +}; + +const HTTP_METHOD_NAMES: Record = { + [HttpMethod.DELETE]: 'DELETE', + [HttpMethod.GET]: 'GET', + [HttpMethod.HEAD]: 'HEAD', + [HttpMethod.OPTIONS]: 'OPTIONS', + [HttpMethod.PATCH]: 'PATCH', + [HttpMethod.POST]: 'POST', + [HttpMethod.PUT]: 'PUT', + [HttpMethod.UNSPECIFIED]: 'UNSPECIFIED', +}; + +const escapeXml = (s: string): string => + s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + +export const useFlowContext = (flowId: Uint8Array): FlowContextData => { + const nodeCollection = useApiCollection(NodeCollectionSchema); + const edgeCollection = useApiCollection(EdgeCollectionSchema); + const variableCollection = useApiCollection(FlowVariableCollectionSchema); + const executionCollection = useApiCollection(NodeExecutionCollectionSchema); + const nodeHttpCollection = useApiCollection(NodeHttpCollectionSchema); + const httpCollection = useApiCollection(HttpCollectionSchema); + + const { data: nodesData } = useLiveQuery( + (_) => _.from({ node: nodeCollection }).where((_) => eq(_.node.flowId, flowId)), + [nodeCollection, flowId], + ); + + const { data: edgesData } = useLiveQuery( + (_) => _.from({ edge: edgeCollection }).where((_) => eq(_.edge.flowId, flowId)), + [edgeCollection, flowId], + ); + + const { data: variablesData } = useLiveQuery( + (_) => _.from({ variable: variableCollection }).where((_) => eq(_.variable.flowId, flowId)), + [variableCollection, flowId], + ); + + // Get all node IDs from the current flow as a Set for efficient lookup + const nodeIdSet = new Set( + (nodesData ?? []).filter((n) => n.nodeId != null).map((n) => Ulid.construct(n.nodeId).toCanonical()), + ); + + // Get all executions - we'll filter in memory by node IDs + const { data: allExecutionsData } = useLiveQuery((_) => _.from({ exec: executionCollection }), [executionCollection]); + + // Filter executions to only those belonging to nodes in this flow + const executionsData = (allExecutionsData ?? []).filter( + (e) => e.nodeId != null && nodeIdSet.has(Ulid.construct(e.nodeId).toCanonical()), + ); + + // Get all nodeHttp mappings for HTTP nodes + const { data: nodeHttpData } = useLiveQuery((_) => _.from({ nodeHttp: nodeHttpCollection }), [nodeHttpCollection]); + + // Build a map of nodeId -> httpId for quick lookup + const nodeHttpMap = new Map( + (nodeHttpData ?? []) + .filter((nh) => nh.nodeId != null && nh.httpId != null) + .map((nh) => [Ulid.construct(nh.nodeId).toCanonical(), Ulid.construct(nh.httpId).toCanonical()]), + ); + + // Get all HTTP requests to fetch their methods + const { data: httpData } = useLiveQuery((_) => _.from({ http: httpCollection }), [httpCollection]); + + // Build a map of httpId -> method for quick lookup + const httpMethodMap = new Map( + (httpData ?? []) + .filter((h) => h.httpId != null) + .map((h) => [Ulid.construct(h.httpId).toCanonical(), HTTP_METHOD_NAMES[h.method] ?? 'UNSPECIFIED']), + ); + + const nodes: NodeInfo[] = (nodesData ?? []) + .filter((n) => n.nodeId != null) + .map((n) => { + const nodeIdStr = Ulid.construct(n.nodeId).toCanonical(); + const httpId = n.kind === NodeKind.HTTP ? nodeHttpMap.get(nodeIdStr) : undefined; + const httpMethod = httpId ? httpMethodMap.get(httpId) : undefined; + return { + httpId, + httpMethod, + id: nodeIdStr, + info: n.info ?? undefined, + kind: NODE_KIND_NAMES[n.kind] ?? 'Unknown', + name: n.name, + position: { x: n.position?.x ?? 0, y: n.position?.y ?? 0 }, + state: FLOW_ITEM_STATE_NAMES[n.state] ?? 'Idle', + }; + }); + + const edges: EdgeInfo[] = (edgesData ?? []) + .filter((e) => e.edgeId != null) + .map((e) => ({ + id: Ulid.construct(e.edgeId).toCanonical(), + sourceHandle: e.sourceHandle !== undefined ? String(e.sourceHandle) : undefined, + sourceId: Ulid.construct(e.sourceId).toCanonical(), + targetId: Ulid.construct(e.targetId).toCanonical(), + })); + + const variables: VariableInfo[] = (variablesData ?? []) + .filter((v) => v.flowVariableId != null) + .map((v) => ({ + enabled: v.enabled, + id: Ulid.construct(v.flowVariableId).toCanonical(), + key: v.key, + value: v.value, + })); + + // Only keep the most recent execution per node to limit context size + // Input/output are stored but will be truncated when accessed via getNodeOutput + const executionsByNode = new Map(); + for (const e of executionsData ?? []) { + if (e.nodeExecutionId == null) continue; + const nodeIdStr = Ulid.construct(e.nodeId).toCanonical(); + const existing = executionsByNode.get(nodeIdStr); + if (!existing || (e.completedAt && (!existing.completedAt || e.completedAt > existing.completedAt))) { + executionsByNode.set(nodeIdStr, e); + } + } + + const executions: NodeExecutionInfo[] = Array.from(executionsByNode.values()).map((e) => ({ + completedAt: e.completedAt instanceof Date ? e.completedAt.toISOString() : e.completedAt, + error: e.error ?? undefined, + id: Ulid.construct(e.nodeExecutionId).toCanonical(), + input: e.input ?? undefined, + name: e.name, + nodeId: Ulid.construct(e.nodeId).toCanonical(), + output: e.output ?? undefined, + state: FLOW_ITEM_STATE_NAMES[e.state] ?? 'Idle', + })); + + return { + edges, + executions, + flowId: Ulid.construct(flowId).toCanonical(), + nodes, + variables, + }; +}; + +interface FlowCollections { + edgeCollection: ReturnType>; + executionCollection: ReturnType>; + httpCollection: ReturnType>; + nodeCollection: ReturnType>; + nodeHttpCollection: ReturnType>; + variableCollection: ReturnType>; +} + +/** + * Async version of useFlowContext that queries collections directly. + * Use this outside React's render cycle (e.g. in the agent tool loop) + * to get a fresh snapshot of flow data after mutations. + */ +export const refreshFlowContext = async ( + flowId: Uint8Array, + collections: FlowCollections, +): Promise => { + const { + edgeCollection, + executionCollection, + httpCollection, + nodeCollection, + nodeHttpCollection, + variableCollection, + } = collections; + + const nodesData = await queryCollection((_) => + _.from({ node: nodeCollection }).where((_) => eq(_.node.flowId, flowId)), + ); + + const edgesData = await queryCollection((_) => + _.from({ edge: edgeCollection }).where((_) => eq(_.edge.flowId, flowId)), + ); + + const variablesData = await queryCollection((_) => + _.from({ variable: variableCollection }).where((_) => eq(_.variable.flowId, flowId)), + ); + + const nodeIdSet = new Set( + nodesData.filter((n) => n.nodeId != null).map((n) => Ulid.construct(n.nodeId).toCanonical()), + ); + + const allExecutionsData = await queryCollection((_) => _.from({ exec: executionCollection })); + const executionsData = allExecutionsData.filter( + (e) => e.nodeId != null && nodeIdSet.has(Ulid.construct(e.nodeId).toCanonical()), + ); + + const nodeHttpData = await queryCollection((_) => _.from({ nodeHttp: nodeHttpCollection })); + const nodeHttpMap = new Map( + nodeHttpData + .filter((nh) => nh.nodeId != null && nh.httpId != null) + .map((nh) => [Ulid.construct(nh.nodeId).toCanonical(), Ulid.construct(nh.httpId).toCanonical()]), + ); + + const httpData = await queryCollection((_) => _.from({ http: httpCollection })); + const httpMethodMap = new Map( + httpData + .filter((h) => h.httpId != null) + .map((h) => [Ulid.construct(h.httpId).toCanonical(), HTTP_METHOD_NAMES[h.method] ?? 'UNSPECIFIED']), + ); + + const nodes: NodeInfo[] = nodesData + .filter((n) => n.nodeId != null) + .map((n) => { + const nodeIdStr = Ulid.construct(n.nodeId).toCanonical(); + const httpId = n.kind === NodeKind.HTTP ? nodeHttpMap.get(nodeIdStr) : undefined; + const httpMethod = httpId ? httpMethodMap.get(httpId) : undefined; + return { + httpId, + httpMethod, + id: nodeIdStr, + info: n.info ?? undefined, + kind: NODE_KIND_NAMES[n.kind] ?? 'Unknown', + name: n.name, + position: { x: n.position?.x ?? 0, y: n.position?.y ?? 0 }, + state: FLOW_ITEM_STATE_NAMES[n.state] ?? 'Idle', + }; + }); + + const edges: EdgeInfo[] = edgesData + .filter((e) => e.edgeId != null) + .map((e) => ({ + id: Ulid.construct(e.edgeId).toCanonical(), + sourceHandle: e.sourceHandle !== undefined ? String(e.sourceHandle) : undefined, + sourceId: Ulid.construct(e.sourceId).toCanonical(), + targetId: Ulid.construct(e.targetId).toCanonical(), + })); + + const variables: VariableInfo[] = variablesData + .filter((v) => v.flowVariableId != null) + .map((v) => ({ + enabled: v.enabled, + id: Ulid.construct(v.flowVariableId).toCanonical(), + key: v.key, + value: v.value, + })); + + const executionsByNode = new Map(); + for (const e of executionsData) { + if (e.nodeExecutionId == null) continue; + const nodeIdStr = Ulid.construct(e.nodeId).toCanonical(); + const existing = executionsByNode.get(nodeIdStr); + if (!existing || (e.completedAt && (!existing.completedAt || e.completedAt > existing.completedAt))) { + executionsByNode.set(nodeIdStr, e); + } + } + + const executions: NodeExecutionInfo[] = Array.from(executionsByNode.values()).map((e) => ({ + completedAt: e.completedAt instanceof Date ? e.completedAt.toISOString() : e.completedAt, + error: e.error ?? undefined, + id: Ulid.construct(e.nodeExecutionId).toCanonical(), + input: e.input ?? undefined, + name: e.name, + nodeId: Ulid.construct(e.nodeId).toCanonical(), + output: e.output ?? undefined, + state: FLOW_ITEM_STATE_NAMES[e.state] ?? 'Idle', + })); + + return { + edges, + executions, + flowId: Ulid.construct(flowId).toCanonical(), + nodes, + variables, + }; +}; + +/** + * Detect orphan nodes that are not reachable from ManualStart via BFS. + * Reusable by both the system prompt builder and the post-execution validation loop. + */ +export const detectOrphanNodes = ( + nodes: Pick[], + edges: Pick[], +): Pick[] => { + const startNode = nodes.find((n) => n.kind === 'ManualStart'); + if (!startNode) return []; + + // Build outgoing edge map + const outgoing = new Map(); + for (const e of edges) { + const list = outgoing.get(e.sourceId) ?? []; + list.push(e.targetId); + outgoing.set(e.sourceId, list); + } + + // BFS to find reachable nodes + const reachable = new Set(); + const queue = [startNode.id]; + while (queue.length > 0) { + const nodeId = queue.shift()!; + if (reachable.has(nodeId)) continue; + reachable.add(nodeId); + queue.push(...(outgoing.get(nodeId) ?? [])); + } + + return nodes.filter((n) => n.kind !== 'ManualStart' && !reachable.has(n.id)); +}; + +/** + * Detect dead-end nodes: reachable from Start but have no outgoing edges. + * Only flags as problematic when there are many dead-ends AND the flow has + * deeper interior nodes — indicating the model forgot fan-in connections. + */ +export const detectDeadEndNodes = ( + nodes: Pick[], + edges: Pick[], +): Pick[] => { + const hasOutgoing = new Set(edges.map((e) => e.sourceId)); + const hasIncoming = new Set(edges.map((e) => e.targetId)); + + // Dead-ends: non-start nodes with incoming edges but no outgoing edges + const deadEnds = nodes.filter((n) => n.kind !== 'ManualStart' && hasIncoming.has(n.id) && !hasOutgoing.has(n.id)); + + // Interior nodes: non-start nodes that DO have outgoing edges (flow has depth) + const interiorNodes = nodes.filter((n) => n.kind !== 'ManualStart' && hasOutgoing.has(n.id)); + + // Only flag when: many dead-ends AND flow has interior depth + if (deadEnds.length > 3 && interiorNodes.length > 0) { + return deadEnds; + } + + return []; +}; + +const buildXmlFlowBlock = (context: FlowContextData): string => { + // 1. Build outgoing edge map: sourceId -> EdgeInfo[] + const outgoingEdges = new Map(); + for (const e of context.edges) { + const list = outgoingEdges.get(e.sourceId) ?? []; + list.push(e); + outgoingEdges.set(e.sourceId, list); + } + + // 2. Build node-name lookup + const nodeNameMap = new Map(); + for (const n of context.nodes) { + nodeNameMap.set(n.id, n.name); + } + + // 3. Compute orphan set + const orphanNodes = detectOrphanNodes(context.nodes, context.edges); + const orphanSet = new Set(orphanNodes.map((n) => n.id)); + + // 4. Compute endpoint set (sequential nodes with no outgoing edges) + const endpointSet = new Set( + context.nodes + .filter((n) => ['HTTP', 'JavaScript', 'ManualStart'].includes(n.kind) && !outgoingEdges.has(n.id)) + .map((n) => n.id), + ); + + // 5. Compute selected set + const selectedSet = new Set(context.selectedNodeIds ?? []); + + // 6. Build execution error map: nodeId -> error string + const errorMap = new Map(); + for (const exec of context.executions) { + if (exec.state === 'Failure' && exec.error) { + errorMap.set(exec.nodeId, exec.error); + } + } + + // 7. Build XML nodes + const lines: string[] = ['']; + + for (const node of context.nodes) { + const attrs: string[] = [ + `id="${escapeXml(node.id)}"`, + `name="${escapeXml(node.name)}"`, + `type="${escapeXml(node.kind)}"`, + ]; + + if (node.httpMethod) attrs.push(`method="${escapeXml(node.httpMethod)}"`); + if (node.state !== 'Idle') attrs.push(`state="${escapeXml(node.state)}"`); + + // Prefer execution error over node.info + const errorDetail = errorMap.get(node.id) ?? node.info; + if (errorDetail) attrs.push(`error="${escapeXml(errorDetail)}"`); + + if (selectedSet.has(node.id)) attrs.push('selected="true"'); + if (orphanSet.has(node.id)) attrs.push('orphan="true"'); + if (endpointSet.has(node.id)) attrs.push('endpoint="true"'); + + const edges = outgoingEdges.get(node.id); + if (!edges || edges.length === 0) { + lines.push(` `); + } else { + lines.push(` `); + for (const edge of edges) { + const targetName = nodeNameMap.get(edge.targetId) ?? edge.targetId; + const edgeAttrs = [`id="${escapeXml(edge.id)}"`, `target="${escapeXml(targetName)}"`]; + if (edge.sourceHandle) edgeAttrs.push(`handle="${escapeXml(edge.sourceHandle)}"`); + lines.push(` `); + } + lines.push(' '); + } + } + + // 8. Variables block (only enabled, skip if empty) + const enabledVars = context.variables.filter((v) => v.enabled); + if (enabledVars.length > 0) { + lines.push(' '); + for (const v of enabledVars) { + lines.push(` `); + } + lines.push(' '); + } + + lines.push(''); + return lines.join('\n'); +}; + +const buildXmlCompactSummary = (context: FlowContextData): string => { + const orphans = detectOrphanNodes(context.nodes, context.edges); + + // Find endpoint nodes + const outgoing = new Set(context.edges.map((e) => e.sourceId)); + const endpoints = context.nodes.filter( + (n) => ['HTTP', 'JavaScript', 'ManualStart'].includes(n.kind) && !outgoing.has(n.id), + ); + + const lines: string[] = [``]; + + for (const ep of endpoints) { + lines.push(` `); + } + + for (const o of orphans) { + lines.push(` `); + } + + if (endpoints.length > 5) { + lines.push( + ` `, + ); + } + + lines.push(''); + return lines.join('\n'); +}; + +export const buildXmlValidationMessage = ( + orphans: Pick[], + deadEnds: Pick[], +): string => { + if (orphans.length > 0) { + const orphanElements = orphans + .map((n) => ` `) + .join('\n'); + return `\n${orphanElements}\n\nConnect these nodes using connectChain before responding.`; + } + + const deadEndElements = deadEnds + .map((n) => ` `) + .join('\n'); + return `\n${deadEndElements}\n\nUse connectChain with nested arrays for fan-in: [["NodeA","NodeB"],"TargetNode"].`; +}; + +export const buildCompactStateSummary = (context: FlowContextData): string => { + return buildXmlCompactSummary(context); +}; + +export const buildSystemPrompt = (context: FlowContextData): string => { + return `You are a workflow automation assistant. You help users create and modify workflow nodes using natural language. + +Current Workflow State (ID: ${context.flowId}): + +${buildXmlFlowBlock(context)} + +IMPORTANT RULES: +1. To find the start node, look for a node with type "ManualStart". +2. When connecting nodes, use the node IDs from the workflow XML. +3. Node outputs are stored by node name. In JS code use ctx["NodeName"]. HTTP nodes output { response: { status, body }, request }. ForEach nodes expose { item, key } during iteration. In HTTP fields use {{NodeName.response.body.field}} interpolation — see . +4. A node can connect to multiple targets for parallel execution (all branches run and complete before downstream nodes continue). To run steps sequentially, chain them: Start → A → B → C. Only create Condition nodes when "then" and "else" lead to DIFFERENT destinations — if both go to the same node, skip the Condition. +5. ALWAYS use connectChain for ALL connections — sequential, branching (auto-applies "then"), fan-out, and fan-in. Examples: ["A","B"] single, ["A","B","C"] chain, ["A",["B","C"],"D"] fan-out/fan-in, [["B","C"],"D"] fan-in only. Pass sourceHandle: "else" or "loop" for non-default branches. Use edge id attributes from \`\` elements when calling disconnectNodes. +6. Always confirm what you did after executing tools. +7. If a node has state="Failure", use inspectNode to get detailed error and config information. +8. Use inspectNode with includeOutput: true to see the input/output data of a node's most recent execution. +9. Use updateNode to modify any node's configuration — condition expressions, loop iterations/paths, JS code, HTTP settings, or node names. Provide only the fields to change. Arrays (headers, searchParams, assertions) replace the full existing set. +10. Nodes with selected="true" are currently selected on canvas — prefer operating on those nodes unless the user specifies otherwise. +11. Nodes with endpoint="true" are the last in their chain — new nodes connect there. +12. Nodes with orphan="true" are mistakes — they must be connected to the flow via connectChain. +13. Create ALL nodes first, then connect them all at once with connectChain. Do not alternate between creating and connecting. +14. For multi-phase flows, use SEPARATE connectChain calls per phase with a shared fan-in node. Example: ["Start",["GET1","GET2"],"ProcessData"] then ["ProcessData",["POST1","POST2"],"End"]. NEVER use consecutive nested arrays — split them across calls. +15. NEVER delete a node to work around an error. If a node fails or cannot be configured with available tools, explain the problem to the user and suggest what they need to do manually. Deleting user-requested nodes and replacing them with a different type is not allowed unless the user explicitly asks for it. +16. AI nodes require a connected AI Provider node that supplies the LLM model and credentials. The agent cannot create or configure AI Provider nodes — this must be done by the user on the canvas. If an AI node fails with a provider-related error, tell the user they need to add and connect an AI Provider node to it with the appropriate credentials. +17. Use patchHttpNode to add or remove individual headers, query params, or assertions without affecting the rest. Use updateNode only when you want to replace the entire set. + + +All text fields in HTTP nodes (url, headers, body, query params) support {{}} interpolation. +The server resolves these at runtime — use variable references, not hardcoded values. + +Syntax: +- Flow/node variable: {{BASE_URL}}, {{user_id}} +- Node output path: {{NodeName.response.body.field}}, {{NodeName.response.status}} +- Environment var: {{#env:HOME}}, {{#env:API_SECRET}} +- Functions: {{uuid()}}, {{uuid("v7")}}, {{ulid()}}, {{now()}} +- File content: {{#file:/path/to/file}} + +Examples: +- URL: {{BASE_URL}}/api/users/{{Get_User.response.body.id}} +- Header: Bearer {{Auth.response.body.token}} +- Body: {"id": "{{uuid()}}", "name": "{{user_name}}"} + +The block in the flow XML shows available flow variables — reference them via {{key}}. +When a value (base URL, API key) appears in multiple nodes, create a variable with createVariable and reference it. +Node names use underscores for spaces: "Get User" → Get_User in references. +`; +}; diff --git a/packages/client/src/features/agent/index.ts b/packages/client/src/features/agent/index.ts new file mode 100644 index 000000000..3be15a184 --- /dev/null +++ b/packages/client/src/features/agent/index.ts @@ -0,0 +1,16 @@ +export { buildSystemPrompt, useFlowContext } from './context-builder'; +export { executeToolCall } from './tool-executor'; +export * from './tool-schemas.ts'; +export type { + AgentChatState, + EdgeInfo, + FlowContextData, + Message, + NodeExecutionInfo, + NodeInfo, + ToolCall, + ToolResult, + VariableInfo, +} from './types'; +export { useAgentChat } from './use-agent-chat'; +export { useOpenRouterKey } from './use-openrouter-key'; diff --git a/packages/client/src/features/agent/layout.ts b/packages/client/src/features/agent/layout.ts new file mode 100644 index 000000000..405becc4f --- /dev/null +++ b/packages/client/src/features/agent/layout.ts @@ -0,0 +1,159 @@ +import type { EdgeInfo, NodeInfo } from './types'; + +export type LayoutOrientation = 'horizontal' | 'vertical'; + +export interface LayoutConfig { + orientation: LayoutOrientation; + spacingPrimary: number; + spacingSecondary: number; + startX: number; + startY: number; +} + +export interface Position { + x: number; + y: number; +} + +export interface LayoutResult { + levels: Map; + maxLevel: number; + positions: Map; +} + +export const defaultHorizontalConfig = (): LayoutConfig => ({ + orientation: 'horizontal', + spacingPrimary: 300, + spacingSecondary: 150, + startX: 0, + startY: 0, +}); + +export const defaultVerticalConfig = (): LayoutConfig => ({ + orientation: 'vertical', + spacingPrimary: 300, + spacingSecondary: 400, + startX: 0, + startY: 0, +}); + +const buildOutgoingAdjacency = (edges: EdgeInfo[]): Map => { + const adj = new Map(); + for (const e of edges) { + const existing = adj.get(e.sourceId) ?? []; + existing.push(e.targetId); + adj.set(e.sourceId, existing); + } + return adj; +}; + +const findStartNode = (nodes: NodeInfo[]): NodeInfo | undefined => nodes.find((n) => n.kind === 'ManualStart'); + +/** + * Layout computes node positions using BFS-based level assignment. + * Each node's level is max(parent_levels) + 1, ensuring proper dependency ordering. + * Cycles are handled by only visiting each node once. + */ +export const layout = ( + nodes: NodeInfo[], + edges: EdgeInfo[], + startNodeId: string, + config: LayoutConfig, +): LayoutResult => { + if (nodes.length === 0) { + return { + levels: new Map(), + maxLevel: 0, + positions: new Map(), + }; + } + + const outgoingEdges = buildOutgoingAdjacency(edges); + + const nodeLevels = new Map(); + const levelNodes = new Map(); + const visited = new Set(); + + // Start BFS from start node + const queue: string[] = [startNodeId]; + nodeLevels.set(startNodeId, 0); + levelNodes.set(0, [startNodeId]); + visited.add(startNodeId); + + while (queue.length > 0) { + const currentNodeId = queue.shift()!; + const currentLevel = nodeLevels.get(currentNodeId) ?? 0; + + // Process all children + const children = outgoingEdges.get(currentNodeId) ?? []; + for (const childId of children) { + // Skip if already visited (handles cycles) + if (visited.has(childId)) continue; + + // Child level is parent level + 1 + const childLevel = currentLevel + 1; + + // Mark as visited and assign level + visited.add(childId); + nodeLevels.set(childId, childLevel); + const currentLevelNodes = levelNodes.get(childLevel) ?? []; + currentLevelNodes.push(childId); + levelNodes.set(childLevel, currentLevelNodes); + queue.push(childId); + } + } + + // Find max level + let maxLevel = 0; + for (const level of levelNodes.keys()) { + if (level > maxLevel) maxLevel = level; + } + + // Calculate positions based on orientation + const positions = new Map(); + + for (let level = 0; level <= maxLevel; level++) { + const nodesAtLevel = levelNodes.get(level) ?? []; + if (nodesAtLevel.length === 0) continue; + + // Calculate primary axis position (depth direction) + let primaryPos = config.orientation === 'horizontal' ? config.startX : config.startY; + primaryPos += level * config.spacingPrimary; + + // Calculate secondary axis positions (centered around start) + const totalSecondary = (nodesAtLevel.length - 1) * config.spacingSecondary; + let startSecondary = config.orientation === 'horizontal' ? config.startY : config.startX; + startSecondary -= totalSecondary / 2; + + for (let i = 0; i < nodesAtLevel.length; i++) { + const nodeId = nodesAtLevel[i]!; + const secondaryPos = startSecondary + i * config.spacingSecondary; + + const pos: Position = + config.orientation === 'horizontal' ? { x: primaryPos, y: secondaryPos } : { x: secondaryPos, y: primaryPos }; + + positions.set(nodeId, pos); + } + } + + return { + levels: nodeLevels, + maxLevel, + positions, + }; +}; + +/** + * layoutNodes is a convenience function that finds the start node and performs layout. + * Returns null if no start node is found. + */ +export const layoutNodes = ( + nodes: NodeInfo[], + edges: EdgeInfo[], + config: LayoutConfig = defaultHorizontalConfig(), +): LayoutResult | null => { + const startNode = findStartNode(nodes); + if (!startNode) return null; + + return layout(nodes, edges, startNode.id, config); +}; diff --git a/packages/client/src/features/agent/tool-executor.ts b/packages/client/src/features/agent/tool-executor.ts new file mode 100644 index 000000000..c42600101 --- /dev/null +++ b/packages/client/src/features/agent/tool-executor.ts @@ -0,0 +1,1437 @@ +/* eslint-disable @typescript-eslint/await-thenable, @typescript-eslint/no-base-to-string, @typescript-eslint/no-confusing-void-expression, @typescript-eslint/no-unnecessary-condition, @typescript-eslint/no-unnecessary-type-conversion, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return, @typescript-eslint/restrict-template-expressions */ +import type { Transport } from '@connectrpc/connect'; +import { eq } from '@tanstack/react-db'; +import { Ulid } from 'id128'; +import { FileKind } from '@the-dev-tools/spec/buf/api/file_system/v1/file_system_pb'; +import { FlowItemState, FlowService, HandleKind, NodeKind } from '@the-dev-tools/spec/buf/api/flow/v1/flow_pb'; +import { HttpBodyKind, HttpMethod } from '@the-dev-tools/spec/buf/api/http/v1/http_pb'; +import { request } from '~/shared/api'; +import { queryCollection } from '~/shared/lib'; +import type { FlowContextData, ToolCall, ToolResult } from './types'; + +type CollectionUtils = ReturnType['utils']; +type CollectionData = ReturnType; + +/** + * Normalizes JS code references by replacing whitespace with underscores in node names. + * - ["Node Name"].field → ["Node_Name"].field + */ +function normalizeJsCodeReferences(code: string): string { + if (!code) return code; + + // Pattern: ["NodeName"] - replace whitespace in node name with underscores + return code.replace(/\["([^"]+)"\]/g, (_, nodeName) => `["${nodeName.replace(/\s+/g, '_')}"]`); +} + +/** + * Normalizes condition expressions by: + * - Removing bracket/quote syntax: ["NodeName"].field → NodeName.field + * - Replacing whitespace with underscores in node names + * - Converting JS strict equality/inequality to expr-lang operators + */ +function normalizeConditionSyntax(expr: string): string { + if (!expr) return expr; + + // Pattern: ["NodeName"] - convert to plain identifier with underscores + let normalized = expr.replace(/\["([^"]+)"\]/g, (_, nodeName) => nodeName.replace(/\s+/g, '_')); + + // Convert JS strict equality/inequality to expr-lang operators + normalized = normalized.replace(/===/g, '=='); + normalized = normalized.replace(/!==/g, '!='); + + return normalized; +} + +/** + * Normalizes node names by replacing whitespace with underscores. + */ +function normalizeNodeName(name: string): string { + if (!name) return name; + return name.replace(/\s+/g, '_'); +} + +interface Collections { + aiCollection: { utils: CollectionUtils }; + conditionCollection: { utils: CollectionUtils }; + edgeCollection: { utils: CollectionUtils }; + executionCollection: CollectionData; + fileCollection: CollectionData; + forCollection: { utils: CollectionUtils }; + forEachCollection: { utils: CollectionUtils }; + httpAssertCollection: { utils: CollectionUtils }; + httpBodyRawCollection: { utils: CollectionUtils }; + httpCollection: { utils: CollectionUtils }; + httpHeaderCollection: { utils: CollectionUtils }; + httpSearchParamCollection: { utils: CollectionUtils }; + jsCollection: { utils: CollectionUtils }; + nodeCollection: { utils: CollectionUtils }; + nodeHttpCollection: { utils: CollectionUtils }; + variableCollection: { utils: CollectionUtils }; +} + +interface ToolExecutorContext { + collections: Collections; + flowContext: FlowContextData; + sessionCreatedNodeIds: Set; + transport: Transport; + waitForFlowCompletion: () => Promise; + workspaceId: Uint8Array; +} + +const parseUlid = (id: string): Uint8Array => Ulid.fromCanonical(id).bytes; + +const HANDLE_KIND_MAP: Record = { + ai_tools: HandleKind.AI_TOOLS, + else: HandleKind.ELSE, + loop: HandleKind.LOOP, + then: HandleKind.THEN, +}; + +const HTTP_METHOD_MAP: Record = { + DELETE: HttpMethod.DELETE, + GET: HttpMethod.GET, + HEAD: HttpMethod.HEAD, + OPTIONS: HttpMethod.OPTIONS, + PATCH: HttpMethod.PATCH, + POST: HttpMethod.POST, + PUT: HttpMethod.PUT, +}; + +const NODE_KIND_NAMES: Record = { + [NodeKind.AI]: 'Ai', + [NodeKind.CONDITION]: 'Condition', + [NodeKind.FOR]: 'For', + [NodeKind.FOR_EACH]: 'ForEach', + [NodeKind.HTTP]: 'HTTP', + [NodeKind.JS]: 'JavaScript', + [NodeKind.MANUAL_START]: 'ManualStart', + [NodeKind.UNSPECIFIED]: 'Unknown', +}; + +const FLOW_ITEM_STATE_NAMES: Record = { + [FlowItemState.CANCELED]: 'Canceled', + [FlowItemState.FAILURE]: 'Failure', + [FlowItemState.RUNNING]: 'Running', + [FlowItemState.SUCCESS]: 'Success', + [FlowItemState.UNSPECIFIED]: 'Idle', +}; + +const AGENT_MAX_FILE_ORDER = 1_000_000_000; + +const areBytesEqual = (left: Uint8Array, right: Uint8Array): boolean => { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i++) { + if (left[i] !== right[i]) return false; + } + return true; +}; + +const getNextAgentFileOrder = async (fileCollection: CollectionData, workspaceId: Uint8Array): Promise => { + const files = await queryCollection((_) => _.from({ item: fileCollection })); + + let maxOrder = 0; + for (const file of files) { + if (typeof file !== 'object' || file === null) continue; + + const fileData = file as Record; + const fileWorkspaceId = fileData['workspaceId']; + + if (!(fileWorkspaceId instanceof Uint8Array)) continue; + if (!areBytesEqual(fileWorkspaceId, workspaceId)) continue; + + const order = fileData['order']; + if (typeof order !== 'number') continue; + if (!Number.isFinite(order)) continue; + if (Math.abs(order) > AGENT_MAX_FILE_ORDER) continue; + if (order > maxOrder) maxOrder = order; + } + + return maxOrder + 1; +}; + +const MUTATION_TOOLS = new Set([ + 'connectChain', + 'createAiNode', + 'createConditionNode', + 'createForEachNode', + 'createForNode', + 'createHttpNode', + 'createJsNode', + 'deleteNode', + 'disconnectNodes', + 'patchHttpNode', + 'updateNode', +]); + +export const executeToolCall = async ( + toolCall: ToolCall, + flowId: Uint8Array, + context: ToolExecutorContext, +): Promise => { + const { arguments: args, id, name } = toolCall; + const isMutation = MUTATION_TOOLS.has(name); + + try { + const result = await executeToolInternal(name, args, flowId, context); + return { isMutation, result, toolCallId: id }; + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + isMutation, + result: null, + toolCallId: id, + }; + } +}; + +const executeToolInternal = async ( + name: string, + args: Record, + flowId: Uint8Array, + context: ToolExecutorContext, +): Promise => { + const { collections, flowContext, transport, workspaceId } = context; + const { + aiCollection, + conditionCollection, + edgeCollection, + executionCollection, + fileCollection, + forCollection, + forEachCollection, + httpAssertCollection, + httpBodyRawCollection, + httpCollection, + httpHeaderCollection, + httpSearchParamCollection, + jsCollection, + nodeCollection, + nodeHttpCollection, + variableCollection, + } = collections; + + switch (name) { + case 'connectChain': { + const nodeIds = args.nodeIds as (string | string[])[]; + const handleOverride = args.sourceHandle as string | undefined; + if (handleOverride && !['ai_tools', 'else', 'loop', 'then'].includes(handleOverride)) { + throw new Error(`Invalid sourceHandle "${handleOverride}". Valid values: "then", "else", "loop", "ai_tools".`); + } + if (!nodeIds || nodeIds.length < 2) { + throw new Error('connectChain requires at least 2 elements.'); + } + + // Validate: no consecutive nested arrays + for (let i = 0; i < nodeIds.length - 1; i++) { + if (Array.isArray(nodeIds[i]) && Array.isArray(nodeIds[i + 1])) { + throw new Error( + `connectChain: consecutive nested arrays at positions ${i} and ${i + 1} are not allowed. ` + + `Insert a shared fan-in node between the groups, or split into separate connectChain calls. ` + + `Example: instead of ["A",["B","C"],["D","E"],"F"], use ["A",["B","C"],"Mid"] then ["Mid",["D","E"],"F"].`, + ); + } + } + + // Validate: parallel groups have ≥2 unique IDs + for (let i = 0; i < nodeIds.length; i++) { + const el = nodeIds[i]!; + if (Array.isArray(el)) { + const unique = new Set(el); + if (unique.size < 2) { + throw new Error(`connectChain: parallel group at position ${i} must have at least 2 unique node IDs.`); + } + if (unique.size !== el.length) { + throw new Error(`connectChain: parallel group at position ${i} contains duplicate node IDs.`); + } + } + } + + // Expand consecutive element pairs into edge pairs + const edgePairs: [string, string][] = []; + for (let i = 0; i < nodeIds.length - 1; i++) { + const current = nodeIds[i]!; + const next = nodeIds[i + 1]!; + const sources = Array.isArray(current) ? current : [current]; + const targets = Array.isArray(next) ? next : [next]; + for (const s of sources) for (const t of targets) edgePairs.push([s, t]); + } + + const edgeIds: string[] = []; + const errors: string[] = []; + + // Process SEQUENTIALLY to avoid parallel race conditions + for (let idx = 0; idx < edgePairs.length; idx++) { + const [sourceIdStr, targetIdStr] = edgePairs[idx]!; + + try { + const sourceId = parseUlid(sourceIdStr); + const targetId = parseUlid(targetIdStr); + const edgeId = Ulid.generate().bytes; + + // Query live edges to check for existing outgoing connections + const existingEdges = await queryCollection((_) => + _.from({ e: edgeCollection }).where((_) => eq(_.e.sourceId, sourceId)), + ); + + const duplicateEdge = existingEdges.find((e) => Ulid.construct(e.targetId).toCanonical() === targetIdStr); + if (duplicateEdge) { + errors.push(`Edge ${idx}: Edge from ${sourceIdStr} to ${targetIdStr} already exists. Skipped.`); + continue; + } + + // Determine handle kind for branching nodes + const sourceNode = flowContext.nodes.find((n) => n.id === sourceIdStr); + const isBranching = sourceNode && ['Condition', 'For', 'ForEach'].includes(sourceNode.kind); + const isAiSource = sourceNode?.kind === 'Ai'; + + // Validate handle is valid for the specific node type + if (isBranching && handleOverride) { + const validHandles = sourceNode.kind === 'Condition' ? ['then', 'else'] : ['then', 'loop']; + if (!validHandles.includes(handleOverride)) { + errors.push( + `Edge ${idx}: Invalid sourceHandle "${handleOverride}" for ${sourceNode.kind} node "${sourceNode.name}". ` + + `Valid handles: ${validHandles.join(', ')}. Skipped.`, + ); + continue; + } + } + + if (isAiSource && handleOverride) { + const validHandles = ['ai_tools']; + if (!validHandles.includes(handleOverride)) { + errors.push( + `Edge ${idx}: Invalid sourceHandle "${handleOverride}" for Ai node "${sourceNode.name}". ` + + `Valid handles: ${validHandles.join(', ')}. Skipped.`, + ); + continue; + } + } + + const edgeHandle = isBranching + ? (HANDLE_KIND_MAP[handleOverride ?? 'then'] ?? HandleKind.THEN) + : isAiSource && handleOverride + ? HANDLE_KIND_MAP[handleOverride] + : undefined; + + await edgeCollection.utils.insert({ + edgeId, + flowId, + sourceId, + targetId, + ...(edgeHandle !== undefined ? { sourceHandle: edgeHandle } : {}), + }); + + edgeIds.push(Ulid.construct(edgeId).toCanonical()); + } catch (error) { + errors.push(`Edge ${idx}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + return { + edgeIds, + edgesCreated: edgeIds.length, + ...(errors.length > 0 ? { errors } : {}), + }; + } + + case 'createAiNode': { + const nodeId = Ulid.generate().bytes; + const position = (args.position as { x: number; y: number }) ?? { x: 0, y: 0 }; + const nodeName = normalizeNodeName(args.name as string); + const prompt = args.prompt as string; + const maxIterations = (args.maxIterations as number | undefined) ?? 5; + + if (!Number.isInteger(maxIterations) || maxIterations <= 0) { + throw new Error(`maxIterations must be a positive integer, got: ${maxIterations}`); + } + + // Call both inserts before awaiting to ensure optimistic updates happen + // synchronously before any sync responses can arrive from the server + const nodePromise = nodeCollection.utils.insert({ + flowId, + kind: NodeKind.AI, + name: nodeName, + nodeId, + position, + }); + + const aiPromise = aiCollection.utils.insert({ + maxIterations, + nodeId, + prompt, + }); + + await Promise.all([nodePromise, aiPromise]); + + const canonicalId = Ulid.construct(nodeId).toCanonical(); + context.sessionCreatedNodeIds.add(canonicalId); + return { name: nodeName, nodeId: canonicalId }; + } + + case 'createConditionNode': { + const nodeId = Ulid.generate().bytes; + const position = (args.position as { x: number; y: number }) ?? { x: 0, y: 0 }; + const condition = normalizeConditionSyntax(args.condition as string); + const nodeName = normalizeNodeName(args.name as string); + + // Call both inserts before awaiting to ensure optimistic updates happen + // synchronously before any sync responses can arrive from the server + const nodePromise = nodeCollection.utils.insert({ + flowId, + kind: NodeKind.CONDITION, + name: nodeName, + nodeId, + position, + }); + + const conditionPromise = conditionCollection.utils.insert({ + condition, + nodeId, + }); + + await Promise.all([nodePromise, conditionPromise]); + + { + const canonicalId = Ulid.construct(nodeId).toCanonical(); + context.sessionCreatedNodeIds.add(canonicalId); + return { name: nodeName, nodeId: canonicalId }; + } + } + + case 'createForEachNode': { + // Validate path is provided + const rawPath = args.path as string | undefined; + if (!rawPath || rawPath.trim() === '') { + throw new Error( + 'path is required for ForEach nodes. ' + + 'Provide an expression for the array/object to iterate. ' + + 'Example: HTTP_Request.response.body.items', + ); + } + + // Validate break condition is provided + const rawCondition = args.condition as string | undefined; + if (!rawCondition || rawCondition.trim() === '') { + throw new Error( + 'condition (break condition) is required for ForEach nodes. ' + + 'Provide an expression that evaluates to true to exit the loop early. ' + + 'Example: ForEach_Loop.key >= 5', + ); + } + + const nodeId = Ulid.generate().bytes; + const position = (args.position as { x: number; y: number }) ?? { x: 0, y: 0 }; + const path = normalizeConditionSyntax(rawPath); + const condition = normalizeConditionSyntax(rawCondition); + const errorHandling = args.errorHandling as string; + const nodeName = normalizeNodeName(args.name as string); + + // Call both inserts before awaiting to ensure optimistic updates happen + // synchronously before any sync responses can arrive from the server + const nodePromise = nodeCollection.utils.insert({ + flowId, + kind: NodeKind.FOR_EACH, + name: nodeName, + nodeId, + position, + }); + + const forEachPromise = forEachCollection.utils.insert({ + condition, + errorHandling: errorHandling === 'break' ? 1 : 0, + nodeId, + path, + }); + + await Promise.all([nodePromise, forEachPromise]); + + { + const canonicalId = Ulid.construct(nodeId).toCanonical(); + context.sessionCreatedNodeIds.add(canonicalId); + return { name: nodeName, nodeId: canonicalId }; + } + } + + case 'createForNode': { + // Validate iterations is a positive integer + const iterations = args.iterations as number | undefined; + if (iterations === undefined || iterations === null) { + throw new Error('iterations is required for For nodes. Specify the number of times to iterate.'); + } + if (!Number.isInteger(iterations) || iterations <= 0) { + throw new Error(`iterations must be a positive integer, got: ${iterations}`); + } + + // Validate break condition is provided + const rawCondition = args.condition as string | undefined; + if (!rawCondition || rawCondition.trim() === '') { + throw new Error( + 'condition (break condition) is required for For nodes. ' + + 'Provide an expression that evaluates to true to exit the loop early. ' + + 'Example: Counter.count >= 10', + ); + } + + const nodeId = Ulid.generate().bytes; + const position = (args.position as { x: number; y: number }) ?? { x: 0, y: 0 }; + const condition = normalizeConditionSyntax(rawCondition); + const errorHandling = args.errorHandling as string; + const nodeName = normalizeNodeName(args.name as string); + + // Call both inserts before awaiting to ensure optimistic updates happen + // synchronously before any sync responses can arrive from the server + const nodePromise = nodeCollection.utils.insert({ + flowId, + kind: NodeKind.FOR, + name: nodeName, + nodeId, + position, + }); + + const forPromise = forCollection.utils.insert({ + condition, + errorHandling: errorHandling === 'break' ? 1 : 0, + iterations, + nodeId, + }); + + await Promise.all([nodePromise, forPromise]); + + { + const canonicalId = Ulid.construct(nodeId).toCanonical(); + context.sessionCreatedNodeIds.add(canonicalId); + return { name: nodeName, nodeId: canonicalId }; + } + } + + case 'createHttpNode': { + const nodeId = Ulid.generate().bytes; + const position = (args.position as { x: number; y: number }) ?? { x: 0, y: 0 }; + const nodeName = normalizeNodeName(args.name as string); + + let httpId: Uint8Array; + let httpIdStr: string; + const insertPromises: Promise[] = []; + + if (args.httpId) { + // Use existing HTTP request + httpId = parseUlid(args.httpId as string); + httpIdStr = args.httpId as string; + } else { + // Validate HTTP method + const methodStr = ((args.method as string) ?? '').toUpperCase(); + if (!methodStr) { + throw new Error( + 'method is required when creating a new HTTP node. ' + + 'Valid methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS', + ); + } + const method = HTTP_METHOD_MAP[methodStr]; + if (method === undefined) { + throw new Error( + `Invalid HTTP method: "${args.method}". ` + 'Valid methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS', + ); + } + + const url = (args.url as string) ?? ''; + const methodsWithBody = new Set(['PATCH', 'POST', 'PUT']); + const needsBody = methodsWithBody.has(methodStr); + + // Create new HTTP request with appropriate bodyKind + httpId = Ulid.generate().bytes; + httpIdStr = Ulid.construct(httpId).toCanonical(); + + insertPromises.push( + httpCollection.utils.insert({ + bodyKind: needsBody ? HttpBodyKind.RAW : HttpBodyKind.UNSPECIFIED, + httpId, + method, + name: nodeName, + url, + }), + getNextAgentFileOrder(fileCollection, workspaceId).then((order) => + fileCollection.utils.insert({ + fileId: httpId, + kind: FileKind.HTTP, + order, + workspaceId, + }), + ), + ); + + // If a body is provided and the method supports it, insert the raw body + const body = args.body as string | undefined; + if (body && needsBody) { + insertPromises.push( + collections.httpBodyRawCollection.utils.insert({ + data: body, + httpId, + }), + ); + } else if (body && !needsBody) { + throw new Error( + `Cannot set body for ${methodStr} requests. ` + 'Only POST, PUT, and PATCH methods support a request body.', + ); + } + } + + // Call all inserts before awaiting to ensure optimistic updates happen + // synchronously before any sync responses can arrive from the server + insertPromises.push( + nodeCollection.utils.insert({ + flowId, + kind: NodeKind.HTTP, + name: nodeName, + nodeId, + position, + }), + nodeHttpCollection.utils.insert({ + httpId, + nodeId, + }), + ); + + await Promise.all(insertPromises); + + { + const canonicalId = Ulid.construct(nodeId).toCanonical(); + context.sessionCreatedNodeIds.add(canonicalId); + return { httpId: httpIdStr, name: nodeName, nodeId: canonicalId }; + } + } + + case 'createJsNode': { + const nodeId = Ulid.generate().bytes; + const position = (args.position as { x: number; y: number }) ?? { x: 0, y: 0 }; + const code = normalizeJsCodeReferences(args.code as string); + const nodeName = normalizeNodeName(args.name as string); + + // Call both inserts before awaiting to ensure optimistic updates happen + // synchronously before any sync responses can arrive from the server + const nodePromise = nodeCollection.utils.insert({ + flowId, + kind: NodeKind.JS, + name: nodeName, + nodeId, + position, + }); + + const jsPromise = jsCollection.utils.insert({ + code: `export default function(ctx) {\n ${code}\n}`, + nodeId, + }); + + await Promise.all([nodePromise, jsPromise]); + + { + const canonicalId = Ulid.construct(nodeId).toCanonical(); + context.sessionCreatedNodeIds.add(canonicalId); + return { name: nodeName, nodeId: canonicalId }; + } + } + + case 'createVariable': { + const flowVariableId = Ulid.generate().bytes; + const key = args.key as string; + const value = args.value as string; + const enabled = args.enabled as boolean; + const description = args.description as string; + const order = args.order as number; + + // Await to ensure server persistence before returning + await variableCollection.utils.insert({ + description, + enabled, + flowId, + flowVariableId, + key, + order, + value, + }); + + return { flowVariableId: Ulid.construct(flowVariableId).toCanonical() }; + } + + case 'deleteNode': { + const nodeIdStr = args.nodeId as string; + + if (context.sessionCreatedNodeIds.has(nodeIdStr)) { + return { + blocked: true, + message: + 'Cannot delete a node you just created. If the node has an error, explain the issue to the user and suggest what they can do to fix it (e.g., adding an AI Provider node). Do NOT delete and recreate with a different node type.', + }; + } + + const nodeId = parseUlid(nodeIdStr); + + // Query live edges from collection to avoid stale flowContext during batched tool calls. + const liveEdges = await queryCollection((_) => + _.from({ edge: edgeCollection }).where((_) => eq(_.edge.flowId, flowId)), + ); + const connectedEdgeIds = liveEdges + .filter((edge) => edge.edgeId != null && edge.sourceId != null && edge.targetId != null) + .filter((edge) => areBytesEqual(edge.sourceId, nodeId) || areBytesEqual(edge.targetId, nodeId)) + .map((edge) => edge.edgeId); + + for (const edgeId of connectedEdgeIds) { + edgeCollection.utils.delete({ edgeId }); + } + + nodeCollection.utils.delete({ nodeId }); + return { deletedEdges: connectedEdgeIds.length, success: true }; + } + + case 'disconnectNodes': { + const edgeId = parseUlid(args.edgeId as string); + edgeCollection.utils.delete({ edgeId }); + return { success: true }; + } + + case 'flowRunRequest': { + await request({ + input: { flowId }, + method: FlowService.method.flowRun, + transport, + }); + + await context.waitForFlowCompletion(); + + return { + message: 'Flow execution completed. Use getFlowExecutionSummary to inspect results.', + success: true, + }; + } + + case 'flowStopRequest': { + await request({ + input: { flowId }, + method: FlowService.method.flowStop, + transport, + }); + return { message: 'Flow execution stopped', success: true }; + } + + case 'getFlowExecutionSummary': { + // Query fresh nodes from the collection + const freshNodes = await queryCollection((_) => + _.from({ node: collections.nodeCollection }).where((_) => eq(_.node.flowId, flowId)), + ); + + // Build a set of node IDs belonging to this flow + const nodeIdSet = new Set( + freshNodes.filter((n) => n.nodeId != null).map((n) => Ulid.construct(n.nodeId).toCanonical()), + ); + + // Query all executions and filter to this flow's nodes + const allExecs = await queryCollection((_) => _.from({ exec: collections.executionCollection })); + const flowExecs = allExecs.filter( + (e) => e.nodeId != null && nodeIdSet.has(Ulid.construct(e.nodeId).toCanonical()), + ); + const executedNodeIds = new Set(flowExecs.map((e) => Ulid.construct(e.nodeId).toCanonical())); + + // Build executed nodes list with state from execution records + const executedNodes = freshNodes + .filter((n) => n.nodeId != null && executedNodeIds.has(Ulid.construct(n.nodeId).toCanonical())) + .map((n) => { + const nodeExecs = flowExecs + .filter((e) => Ulid.construct(e.nodeId).toCanonical() === Ulid.construct(n.nodeId).toCanonical()) + .sort((a, b) => { + if (!a.completedAt && !b.completedAt) return 0; + if (!a.completedAt) return 1; + if (!b.completedAt) return -1; + return Number(b.completedAt - a.completedAt); + }); + const latestExec = nodeExecs[0]; + return { + id: Ulid.construct(n.nodeId).toCanonical(), + name: n.name, + state: latestExec ? (FLOW_ITEM_STATE_NAMES[latestExec.state] ?? 'Unknown') : 'Unknown', + }; + }); + + // Never-reached: non-ManualStart nodes without any executions + const neverReachedNodes = freshNodes + .filter( + (n) => + n.nodeId != null && + n.kind !== NodeKind.MANUAL_START && + !executedNodeIds.has(Ulid.construct(n.nodeId).toCanonical()), + ) + .map((n) => ({ + id: Ulid.construct(n.nodeId).toCanonical(), + kind: NODE_KIND_NAMES[n.kind] ?? 'Unknown', + name: n.name, + })); + + return { + executedNodes, + neverReachedNodes, + warning: + neverReachedNodes.length > 0 + ? `${neverReachedNodes.length} node(s) were never reached during execution. This may indicate an untaken branch or a wiring problem.` + : undefined, + }; + } + + case 'inspectNode': { + const nodeIdStr = args.nodeId as string; + const includeOutput = (args.includeOutput as boolean) ?? false; + const node = flowContext.nodes.find((n) => n.id === nodeIdStr); + if (!node) throw new Error(`Node not found: ${nodeIdStr}`); + + const nodeIdBytes = parseUlid(nodeIdStr); + + // Base info (always returned) + const result: Record = { + error: node.info ?? undefined, + id: node.id, + kind: node.kind, + name: node.name, + state: node.state, + }; + + // Type-specific config + switch (node.kind) { + case 'Condition': { + const [condData] = await queryCollection((_) => + _.from({ cond: conditionCollection }) + .where((_) => eq(_.cond.nodeId, nodeIdBytes)) + .findOne(), + ); + result.condition = condData?.condition ?? ''; + break; + } + case 'For': { + const [forData] = await queryCollection((_) => + _.from({ f: forCollection }) + .where((_) => eq(_.f.nodeId, nodeIdBytes)) + .findOne(), + ); + result.iterations = forData?.iterations; + result.condition = forData?.condition ?? ''; + result.errorHandling = forData?.errorHandling === 1 ? 'break' : 'continue'; + break; + } + case 'ForEach': { + const [feData] = await queryCollection((_) => + _.from({ fe: forEachCollection }) + .where((_) => eq(_.fe.nodeId, nodeIdBytes)) + .findOne(), + ); + result.path = feData?.path ?? ''; + result.condition = feData?.condition ?? ''; + result.errorHandling = feData?.errorHandling === 1 ? 'break' : 'continue'; + break; + } + case 'HTTP': { + if (!node.httpId) break; + const httpIdBytes = parseUlid(node.httpId); + + const [httpData] = await queryCollection((_) => + _.from({ http: httpCollection }) + .where((_) => eq(_.http.httpId, httpIdBytes)) + .findOne(), + ); + + const searchParams = await queryCollection((_) => + _.from({ sp: httpSearchParamCollection }).where((_) => eq(_.sp.httpId, httpIdBytes)), + ); + + const headers = await queryCollection((_) => + _.from({ h: httpHeaderCollection }).where((_) => eq(_.h.httpId, httpIdBytes)), + ); + + const bodyRaw = await queryCollection((_) => + _.from({ br: httpBodyRawCollection }).where((_) => eq(_.br.httpId, httpIdBytes)), + ); + + const asserts = await queryCollection((_) => + _.from({ a: httpAssertCollection }).where((_) => eq(_.a.httpId, httpIdBytes)), + ); + + const HTTP_METHOD_NAMES: Record = { + 0: 'UNSPECIFIED', + 1: 'GET', + 2: 'POST', + 3: 'PUT', + 4: 'PATCH', + 5: 'DELETE', + 6: 'HEAD', + 7: 'OPTIONS', + 8: 'CONNECT', + }; + + result.httpId = node.httpId; + result.url = httpData?.url ?? ''; + result.method = HTTP_METHOD_NAMES[httpData?.method ?? 0] ?? 'UNSPECIFIED'; + result.headers = headers.map((h) => ({ + enabled: h.enabled, + id: h.httpHeaderId ? Ulid.construct(h.httpHeaderId).toCanonical() : undefined, + key: h.key, + value: h.value, + })); + result.searchParams = searchParams.map((sp) => ({ + enabled: sp.enabled, + id: sp.httpSearchParamId ? Ulid.construct(sp.httpSearchParamId).toCanonical() : undefined, + key: sp.key, + value: sp.value, + })); + result.body = bodyRaw.length > 0 ? bodyRaw[0]?.data : undefined; + result.assertions = asserts.map((a) => ({ + enabled: a.enabled, + id: a.httpAssertId ? Ulid.construct(a.httpAssertId).toCanonical() : undefined, + value: a.value, + })); + break; + } + case 'Ai': { + const [aiData] = await queryCollection((_) => + _.from({ ai: aiCollection }) + .where((_) => eq(_.ai.nodeId, nodeIdBytes)) + .findOne(), + ); + result.prompt = aiData?.prompt ?? ''; + result.maxIterations = aiData?.maxIterations ?? 5; + break; + } + case 'JavaScript': { + const [jsData] = await queryCollection((_) => + _.from({ js: jsCollection }) + .where((_) => eq(_.js.nodeId, nodeIdBytes)) + .findOne(), + ); + result.code = jsData?.code ?? ''; + break; + } + } + + // Query execution data fresh from collection (not cached flowContext) + const allExecs = await queryCollection((_) => _.from({ exec: executionCollection })); + const nodeExecs = allExecs + .filter((e) => e.nodeId != null && Ulid.construct(e.nodeId).toCanonical() === nodeIdStr) + .sort((a, b) => { + if (!a.completedAt && !b.completedAt) return 0; + if (!a.completedAt) return 1; + if (!b.completedAt) return -1; + return Number(b.completedAt - a.completedAt); + }); + + if (nodeExecs.length > 0) { + const latest = nodeExecs[0]!; + result.execution = { + completedAt: + latest.completedAt instanceof Date + ? latest.completedAt.toISOString() + : latest.completedAt + ? String(latest.completedAt) + : undefined, + error: latest.error ?? undefined, + state: FLOW_ITEM_STATE_NAMES[latest.state] ?? 'Unknown', + }; + + if (includeOutput) { + const MAX_OUTPUT_LENGTH = 10000; + const truncateData = (data: unknown): unknown => { + if (data == null) return data; + const str = typeof data === 'string' ? data : JSON.stringify(data); + if (str.length <= MAX_OUTPUT_LENGTH) return data; + return { + _originalLength: str.length, + _truncated: true, + preview: str.slice(0, MAX_OUTPUT_LENGTH) + '...', + }; + }; + (result.execution as Record).input = truncateData(latest.input); + (result.execution as Record).output = truncateData(latest.output); + } + } + + return result; + } + + case 'updateNode': { + const nodeIdStr = args.nodeId as string; + const node = flowContext.nodes.find((n) => n.id === nodeIdStr); + if (!node) throw new Error(`Node not found: ${nodeIdStr}`); + + const nodeIdBytes = parseUlid(nodeIdStr); + const updatedFields: string[] = []; + + // --- Base fields (any node type) --- + if (args.name !== undefined) { + nodeCollection.utils.update({ + name: normalizeNodeName(args.name as string), + nodeId: nodeIdBytes, + }); + updatedFields.push('name'); + } + + // --- Type-specific fields --- + switch (node.kind) { + case 'Ai': { + const aiUpdates: Record = { nodeId: nodeIdBytes }; + let hasAiUpdates = false; + + if (args.prompt !== undefined) { + aiUpdates.prompt = args.prompt; + hasAiUpdates = true; + updatedFields.push('prompt'); + } + if (args.maxIterations !== undefined) { + const maxIterations = args.maxIterations as number; + if (!Number.isInteger(maxIterations) || maxIterations <= 0) { + throw new Error(`maxIterations must be a positive integer, got: ${maxIterations}`); + } + aiUpdates.maxIterations = maxIterations; + hasAiUpdates = true; + updatedFields.push('maxIterations'); + } + if (hasAiUpdates) aiCollection.utils.update(aiUpdates); + break; + } + case 'Condition': { + if (args.condition !== undefined) { + conditionCollection.utils.update({ + condition: normalizeConditionSyntax(args.condition as string), + nodeId: nodeIdBytes, + }); + updatedFields.push('condition'); + } + break; + } + case 'For': { + const forUpdates: Record = { nodeId: nodeIdBytes }; + let hasForUpdates = false; + + if (args.iterations !== undefined) { + const iterations = args.iterations as number; + if (!Number.isInteger(iterations) || iterations <= 0) { + throw new Error(`iterations must be a positive integer, got: ${iterations}`); + } + forUpdates.iterations = iterations; + hasForUpdates = true; + updatedFields.push('iterations'); + } + if (args.condition !== undefined) { + forUpdates.condition = normalizeConditionSyntax(args.condition as string); + hasForUpdates = true; + updatedFields.push('condition'); + } + if (args.errorHandling !== undefined) { + forUpdates.errorHandling = args.errorHandling === 'break' ? 1 : 0; + hasForUpdates = true; + updatedFields.push('errorHandling'); + } + if (hasForUpdates) forCollection.utils.update(forUpdates); + break; + } + case 'ForEach': { + const feUpdates: Record = { nodeId: nodeIdBytes }; + let hasFeUpdates = false; + + if (args.path !== undefined) { + feUpdates.path = normalizeConditionSyntax(args.path as string); + hasFeUpdates = true; + updatedFields.push('path'); + } + if (args.condition !== undefined) { + feUpdates.condition = normalizeConditionSyntax(args.condition as string); + hasFeUpdates = true; + updatedFields.push('condition'); + } + if (args.errorHandling !== undefined) { + feUpdates.errorHandling = args.errorHandling === 'break' ? 1 : 0; + hasFeUpdates = true; + updatedFields.push('errorHandling'); + } + if (hasFeUpdates) forEachCollection.utils.update(feUpdates); + break; + } + case 'JavaScript': { + if (args.code !== undefined) { + jsCollection.utils.update({ + code: `export default function(ctx) {\n ${normalizeJsCodeReferences(args.code as string)}\n}`, + nodeId: nodeIdBytes, + }); + updatedFields.push('code'); + } + break; + } + case 'HTTP': { + if (!node.httpId) throw new Error(`HTTP node "${node.name}" has no associated HTTP request`); + const httpIdBytes = parseUlid(node.httpId); + const METHODS_WITH_BODY = new Set(['PATCH', 'POST', 'PUT']); + const HTTP_METHOD_NAMES_LOCAL: Record = { + 0: 'UNSPECIFIED', + 1: 'GET', + 2: 'POST', + 3: 'PUT', + 4: 'PATCH', + 5: 'DELETE', + 6: 'HEAD', + 7: 'OPTIONS', + 8: 'CONNECT', + }; + + const [httpData] = await queryCollection((_) => + _.from({ http: httpCollection }) + .where((_) => eq(_.http.httpId, httpIdBytes)) + .findOne(), + ); + + const clearHttpBody = async () => { + httpCollection.utils.update({ bodyKind: HttpBodyKind.UNSPECIFIED, httpId: httpIdBytes }); + const existingBody = await queryCollection((_) => + _.from({ br: httpBodyRawCollection }).where((_) => eq(_.br.httpId, httpIdBytes)), + ); + if (existingBody.length > 0) { + httpBodyRawCollection.utils.update({ data: '', httpId: httpIdBytes }); + } + }; + + // Update method/url + const httpUpdates: Record = { httpId: httpIdBytes }; + let hasHttpUpdates = false; + const currentMethod = HTTP_METHOD_NAMES_LOCAL[httpData?.method ?? 0] ?? 'UNSPECIFIED'; + let effectiveMethod = currentMethod; + + if (args.method !== undefined) { + const methodStr = (args.method as string).toUpperCase(); + const method = HTTP_METHOD_MAP[methodStr]; + if (method === undefined) { + throw new Error( + `Invalid HTTP method: "${args.method}". Valid: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS`, + ); + } + effectiveMethod = methodStr; + httpUpdates.method = method; + hasHttpUpdates = true; + updatedFields.push('method'); + } + + if (args.url !== undefined) { + httpUpdates.url = args.url; + hasHttpUpdates = true; + updatedFields.push('url'); + } + + if (hasHttpUpdates) { + httpCollection.utils.update(httpUpdates); + } + + // Replace headers if provided + if (args.headers !== undefined) { + const existingHeaders = await queryCollection((_) => + _.from({ h: httpHeaderCollection }).where((_) => eq(_.h.httpId, httpIdBytes)), + ); + for (const h of existingHeaders) { + if (h.httpHeaderId) httpHeaderCollection.utils.delete({ httpHeaderId: h.httpHeaderId }); + } + const newHeaders = args.headers as { + description?: string; + enabled?: boolean; + key: string; + value?: string; + }[]; + for (let i = 0; i < newHeaders.length; i++) { + const h = newHeaders[i]!; + await httpHeaderCollection.utils.insert({ + description: h.description ?? '', + enabled: h.enabled ?? true, + httpHeaderId: Ulid.generate().bytes, + httpId: httpIdBytes, + key: h.key, + order: i, + value: h.value ?? '', + }); + } + updatedFields.push('headers'); + } + + // Replace search params if provided + if (args.searchParams !== undefined) { + const existingParams = await queryCollection((_) => + _.from({ sp: httpSearchParamCollection }).where((_) => eq(_.sp.httpId, httpIdBytes)), + ); + for (const sp of existingParams) { + if (sp.httpSearchParamId) + httpSearchParamCollection.utils.delete({ httpSearchParamId: sp.httpSearchParamId }); + } + const newParams = args.searchParams as { + description?: string; + enabled?: boolean; + key: string; + value?: string; + }[]; + for (let i = 0; i < newParams.length; i++) { + const sp = newParams[i]!; + await httpSearchParamCollection.utils.insert({ + description: sp.description ?? '', + enabled: sp.enabled ?? true, + httpId: httpIdBytes, + httpSearchParamId: Ulid.generate().bytes, + key: sp.key, + order: i, + value: sp.value ?? '', + }); + } + updatedFields.push('searchParams'); + } + + // Method-body guard: validate body is only set for methods that support it + if (args.body !== undefined && args.body !== null) { + if (!METHODS_WITH_BODY.has(effectiveMethod)) { + throw new Error( + `Cannot set body for ${effectiveMethod} requests. ` + + 'Only POST, PUT, and PATCH methods support a request body. ' + + 'Either change the method first or remove the body.', + ); + } + } + + // If method is changed to a no-body method and body wasn't explicitly provided, + // clear any existing body to keep method/body state consistent. + if (args.method !== undefined && args.body === undefined && !METHODS_WITH_BODY.has(effectiveMethod)) { + await clearHttpBody(); + updatedFields.push('body'); + } + + // Set or clear body + if (args.body !== undefined) { + const body = args.body as null | string; + if (body === null) { + await clearHttpBody(); + } else { + httpCollection.utils.update({ bodyKind: HttpBodyKind.RAW, httpId: httpIdBytes }); + const existingBody = await queryCollection((_) => + _.from({ br: httpBodyRawCollection }).where((_) => eq(_.br.httpId, httpIdBytes)), + ); + if (existingBody.length > 0) { + httpBodyRawCollection.utils.update({ data: body, httpId: httpIdBytes }); + } else { + await httpBodyRawCollection.utils.insert({ data: body, httpId: httpIdBytes }); + } + } + updatedFields.push('body'); + } + + // Replace assertions if provided + if (args.assertions !== undefined) { + const existingAsserts = await queryCollection((_) => + _.from({ a: httpAssertCollection }).where((_) => eq(_.a.httpId, httpIdBytes)), + ); + for (const a of existingAsserts) { + if (a.httpAssertId) httpAssertCollection.utils.delete({ httpAssertId: a.httpAssertId }); + } + const newAsserts = args.assertions as { enabled?: boolean; value: string }[]; + for (let i = 0; i < newAsserts.length; i++) { + const a = newAsserts[i]!; + await httpAssertCollection.utils.insert({ + enabled: a.enabled ?? true, + httpAssertId: Ulid.generate().bytes, + httpId: httpIdBytes, + order: i, + value: a.value, + }); + } + updatedFields.push('assertions'); + } + break; + } + } + + if (updatedFields.length === 0) { + return { message: `No applicable fields provided for ${node.kind} node "${node.name}"`, success: false }; + } + + return { success: true, updatedFields }; + } + + case 'patchHttpNode': { + const nodeIdStr = args.nodeId as string; + const node = flowContext.nodes.find((n) => n.id === nodeIdStr); + if (!node) throw new Error(`Node not found: ${nodeIdStr}`); + if (node.kind !== 'HTTP') throw new Error(`patchHttpNode only works on HTTP nodes, got: ${node.kind}`); + if (!node.httpId) throw new Error(`HTTP node "${node.name}" has no associated HTTP request`); + + const httpIdBytes = parseUlid(node.httpId); + const patchedFields: string[] = []; + const warnings: string[] = []; + + // --- Remove headers --- + const removeHeaderIds = args.removeHeaderIds as string[] | undefined; + const addHeaders = args.addHeaders as + | { description?: string; enabled?: boolean; key: string; value?: string }[] + | undefined; + + if (removeHeaderIds?.length) { + const existingHeaders = await queryCollection((_) => + _.from({ h: httpHeaderCollection }).where((_) => eq(_.h.httpId, httpIdBytes)), + ); + const existingHeaderIds = new Set( + existingHeaders + .filter((h) => h.httpHeaderId != null) + .map((h) => Ulid.construct(h.httpHeaderId).toCanonical()), + ); + let removedCount = 0; + for (const id of removeHeaderIds) { + if (!existingHeaderIds.has(id)) continue; + httpHeaderCollection.utils.delete({ httpHeaderId: parseUlid(id) }); + removedCount++; + } + if (removedCount > 0) { + patchedFields.push(`removedHeaders(${removedCount})`); + } + const skippedCount = removeHeaderIds.length - removedCount; + if (skippedCount > 0) { + warnings.push(`Skipped ${skippedCount} header ID(s) not belonging to this HTTP node.`); + } + } + + // --- Add headers --- + if (addHeaders?.length) { + const existingHeaders = await queryCollection((_) => + _.from({ h: httpHeaderCollection }).where((_) => eq(_.h.httpId, httpIdBytes)), + ); + const maxOrder = existingHeaders.reduce((max, h) => Math.max(max, h.order ?? -1), -1); + let nextOrder = maxOrder + 1; + for (const h of addHeaders) { + await httpHeaderCollection.utils.insert({ + description: h.description ?? '', + enabled: h.enabled ?? true, + httpHeaderId: Ulid.generate().bytes, + httpId: httpIdBytes, + key: h.key, + order: nextOrder++, + value: h.value ?? '', + }); + } + patchedFields.push(`addedHeaders(${addHeaders.length})`); + } + + // --- Remove search params --- + const removeSearchParamIds = args.removeSearchParamIds as string[] | undefined; + const addSearchParams = args.addSearchParams as + | { description?: string; enabled?: boolean; key: string; value?: string }[] + | undefined; + + if (removeSearchParamIds?.length) { + const existingSearchParams = await queryCollection((_) => + _.from({ sp: httpSearchParamCollection }).where((_) => eq(_.sp.httpId, httpIdBytes)), + ); + const existingSearchParamIds = new Set( + existingSearchParams + .filter((sp) => sp.httpSearchParamId != null) + .map((sp) => Ulid.construct(sp.httpSearchParamId).toCanonical()), + ); + let removedCount = 0; + for (const id of removeSearchParamIds) { + if (!existingSearchParamIds.has(id)) continue; + httpSearchParamCollection.utils.delete({ httpSearchParamId: parseUlid(id) }); + removedCount++; + } + if (removedCount > 0) { + patchedFields.push(`removedSearchParams(${removedCount})`); + } + const skippedCount = removeSearchParamIds.length - removedCount; + if (skippedCount > 0) { + warnings.push(`Skipped ${skippedCount} query param ID(s) not belonging to this HTTP node.`); + } + } + + // --- Add search params --- + if (addSearchParams?.length) { + const existingSearchParams = await queryCollection((_) => + _.from({ sp: httpSearchParamCollection }).where((_) => eq(_.sp.httpId, httpIdBytes)), + ); + const maxOrder = existingSearchParams.reduce((max, sp) => Math.max(max, sp.order ?? -1), -1); + let nextOrder = maxOrder + 1; + for (const sp of addSearchParams) { + await httpSearchParamCollection.utils.insert({ + description: sp.description ?? '', + enabled: sp.enabled ?? true, + httpId: httpIdBytes, + httpSearchParamId: Ulid.generate().bytes, + key: sp.key, + order: nextOrder++, + value: sp.value ?? '', + }); + } + patchedFields.push(`addedSearchParams(${addSearchParams.length})`); + } + + // --- Remove assertions --- + const removeAssertionIds = args.removeAssertionIds as string[] | undefined; + const addAssertions = args.addAssertions as { enabled?: boolean; value: string }[] | undefined; + + if (removeAssertionIds?.length) { + const existingAssertions = await queryCollection((_) => + _.from({ a: httpAssertCollection }).where((_) => eq(_.a.httpId, httpIdBytes)), + ); + const existingAssertionIds = new Set( + existingAssertions + .filter((a) => a.httpAssertId != null) + .map((a) => Ulid.construct(a.httpAssertId).toCanonical()), + ); + let removedCount = 0; + for (const id of removeAssertionIds) { + if (!existingAssertionIds.has(id)) continue; + httpAssertCollection.utils.delete({ httpAssertId: parseUlid(id) }); + removedCount++; + } + if (removedCount > 0) { + patchedFields.push(`removedAssertions(${removedCount})`); + } + const skippedCount = removeAssertionIds.length - removedCount; + if (skippedCount > 0) { + warnings.push(`Skipped ${skippedCount} assertion ID(s) not belonging to this HTTP node.`); + } + } + + // --- Add assertions --- + if (addAssertions?.length) { + const existingAssertions = await queryCollection((_) => + _.from({ a: httpAssertCollection }).where((_) => eq(_.a.httpId, httpIdBytes)), + ); + const maxOrder = existingAssertions.reduce((max, a) => Math.max(max, a.order ?? -1), -1); + let nextOrder = maxOrder + 1; + for (const a of addAssertions) { + await httpAssertCollection.utils.insert({ + enabled: a.enabled ?? true, + httpAssertId: Ulid.generate().bytes, + httpId: httpIdBytes, + order: nextOrder++, + value: a.value, + }); + } + patchedFields.push(`addedAssertions(${addAssertions.length})`); + } + + if (patchedFields.length === 0) { + return { message: 'No patch operations provided', success: false }; + } + + return { patchedFields, success: true, warnings: warnings.length > 0 ? warnings : undefined }; + } + + case 'updateVariable': { + const flowVariableId = parseUlid(args.flowVariableId as string); + const updates: Record = { flowVariableId }; + + if (args.key !== undefined) updates.key = args.key; + if (args.value !== undefined) updates.value = args.value; + if (args.enabled !== undefined) updates.enabled = args.enabled; + if (args.description !== undefined) updates.description = args.description; + if (args.order !== undefined) updates.order = args.order; + + variableCollection.utils.update(updates); + return { success: true }; + } + + default: + throw new Error(`Unknown tool: ${name}`); + } +}; + +export type { Collections, ToolExecutorContext }; diff --git a/packages/client/src/features/agent/tool-schemas.ts b/packages/client/src/features/agent/tool-schemas.ts new file mode 100644 index 000000000..03f100398 --- /dev/null +++ b/packages/client/src/features/agent/tool-schemas.ts @@ -0,0 +1,175 @@ +/** + * Runtime tool schema utilities - converts Effect Schemas to JSON Schema tool definitions. + * These utilities are used by the agent to handle AI tool calling. + */ + +import { JSONSchema, Schema } from 'effect'; + +import { ExecutionSchemas } from '@the-dev-tools/spec/tools/execution'; +import { MutationSchemas } from '@the-dev-tools/spec/tools/mutation'; + +// Re-export schemas for convenience +export { ExecutionSchemas, MutationSchemas }; +export * from '@the-dev-tools/spec-lib/common'; + +// ============================================================================= +// Tool Definition Type +// ============================================================================= + +export interface ToolDefinition { + description: string; + name: string; + parameters: object; +} + +// ============================================================================= +// JSON Schema Generation +// ============================================================================= + +/** Recursively resolve $ref references in a JSON Schema */ +function resolveRefs(obj: unknown, defs: Record): unknown { + if (obj === null || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map((item) => resolveRefs(item, defs)); + + const record = obj as Record; + + if ('$ref' in record && typeof record['$ref'] === 'string') { + const defName = record['$ref'].replace('#/$defs/', ''); + const resolved = defs[defName]; + if (resolved) { + const { $ref: _, ...rest } = record; + return { ...(resolveRefs(resolved, defs) as Record), ...rest }; + } + } + + if ('allOf' in record && Array.isArray(record['allOf']) && record['allOf'].length === 1) { + const first = record['allOf'][0] as Record; + if ('$ref' in first) { + const { allOf: _, ...rest } = record; + return { ...(resolveRefs(first, defs) as Record), ...rest }; + } + } + + const result: Record = {}; + for (const [key, value] of Object.entries(record)) { + if (key === '$defs' || key === '$schema') continue; + result[key] = resolveRefs(value, defs); + } + return result; +} + +/** Convert an Effect Schema to a tool definition with JSON Schema parameters */ +function schemaToToolDefinition(schema: Schema.Schema): ToolDefinition { + const jsonSchema = JSONSchema.make(schema) as { + $defs: Record; + $ref: string; + $schema: string; + }; + + const defs = jsonSchema.$defs; + const defName = jsonSchema.$ref.replace('#/$defs/', ''); + const def = defs[defName] as + | undefined + | { + description?: string; + properties: Record; + required?: string[]; + type: string; + }; + + return { + description: def?.description ?? '', + name: defName || 'unknown', + parameters: def + ? { + additionalProperties: false, + properties: resolveRefs(def.properties, defs), + required: def.required, + type: def.type, + } + : jsonSchema, + }; +} + +// ============================================================================= +// Auto-generated Tool Definitions +// ============================================================================= + +export const executionSchemas = Object.values(ExecutionSchemas).map((s) => + schemaToToolDefinition(s as Schema.Schema), +); + +export const mutationSchemas = Object.values(MutationSchemas).map((s) => + schemaToToolDefinition(s as Schema.Schema), +); + +// Patch CreateHttpNode to include optional body field (executor already handles it) +const createHttpNodeDef = mutationSchemas.find((t) => t.name === 'createHttpNode'); +if (createHttpNodeDef) { + const params = createHttpNodeDef.parameters as { + properties: Record; + required?: string[]; + }; + params.properties['body'] = { + description: + 'Optional JSON request body for POST, PUT, or PATCH requests. Only valid for methods that support a body. Supports {{variable}} interpolation.', + type: 'string', + }; + // Remove additionalProperties:false so the extra field is accepted + delete (params as Record)['additionalProperties']; + + if (params.properties['url']) { + params.properties['url'] = { + ...(params.properties['url'] as object), + description: 'The URL for the HTTP request. Supports {{variable}} interpolation, e.g. {{BASE_URL}}/api/users', + }; + } +} + +/** All tool schemas combined - ready for AI tool calling */ +export const allToolSchemas = [...executionSchemas, ...mutationSchemas]; + +// ============================================================================= +// Effect Schemas (for runtime validation) +// ============================================================================= + +export const EffectSchemas = { + Execution: ExecutionSchemas, + Mutation: MutationSchemas, +} as const; + +// ============================================================================= +// Validation Helper +// ============================================================================= + +const schemaMap: Record> = Object.fromEntries( + Object.entries(EffectSchemas).flatMap(([, group]) => + Object.entries(group).map(([name, schema]) => [ + name.charAt(0).toLowerCase() + name.slice(1), + schema as Schema.Schema, + ]), + ), +); + +/** + * Validate tool input against the Effect Schema + */ +export function validateToolInput( + toolName: string, + input: unknown, +): { data: unknown; success: true } | { errors: string[]; success: false } { + const schema = schemaMap[toolName]; + if (!schema) { + return { errors: [`Unknown tool: ${toolName}`], success: false }; + } + + try { + const decoded = Schema.decodeUnknownSync(schema)(input); + return { data: decoded, success: true }; + } catch (error) { + if (error instanceof Error) { + return { errors: [error.message], success: false }; + } + return { errors: ['Unknown validation error'], success: false }; + } +} diff --git a/packages/client/src/features/agent/types.ts b/packages/client/src/features/agent/types.ts new file mode 100644 index 000000000..388c4125f --- /dev/null +++ b/packages/client/src/features/agent/types.ts @@ -0,0 +1,99 @@ +import type { ChatCompletionMessageParam, ChatCompletionTool } from 'openai/resources/chat/completions'; + +export type MessageRole = 'assistant' | 'system' | 'tool' | 'user'; + +export interface Message { + content: string; + id: string; + role: MessageRole; + timestamp: number; + toolCallId?: string; + toolCalls?: ToolCall[]; +} + +export interface ToolCall { + arguments: Record; + id: string; + name: string; +} + +export interface ToolResult { + error?: string; + isMutation?: boolean; + result: unknown; + toolCallId: string; +} + +export interface AgentChatState { + error: null | string; + isLoading: boolean; + messages: Message[]; + streamingContent: string; +} + +export interface FlowContextData { + edges: EdgeInfo[]; + executions: NodeExecutionInfo[]; + flowId: string; + nodes: NodeInfo[]; + selectedNodeIds?: string[]; + variables: VariableInfo[]; +} + +export interface NodeInfo { + httpId?: string; + httpMethod?: string; + id: string; + info?: string; + kind: string; + name: string; + position: { x: number; y: number }; + state: string; +} + +export interface NodeExecutionInfo { + completedAt?: string; + error?: string; + id: string; + input?: unknown; + name: string; + nodeId: string; + output?: unknown; + state: string; +} + +export interface EdgeInfo { + id: string; + sourceHandle?: string; + sourceId: string; + targetId: string; +} + +export interface VariableInfo { + enabled: boolean; + id: string; + key: string; + value: string; +} + +export interface ToolSchema { + description: string; + name: string; + parameters: { + additionalProperties?: boolean; + properties: Record; + required?: string[]; + type: 'object'; + }; +} + +export const formatToolAsOpenAI = (schema: ToolSchema): ChatCompletionTool => ({ + function: { + description: schema.description, + name: schema.name, + parameters: schema.parameters, + }, + type: 'function', +}); + +export type OpenAIMessage = ChatCompletionMessageParam; diff --git a/packages/client/src/features/agent/use-agent-chat.ts b/packages/client/src/features/agent/use-agent-chat.ts new file mode 100644 index 000000000..5026332de --- /dev/null +++ b/packages/client/src/features/agent/use-agent-chat.ts @@ -0,0 +1,1030 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +import { eq } from '@tanstack/react-db'; +import { Ulid } from 'id128'; +import OpenAI from 'openai'; +import { useCallback, useRef, useSyncExternalStore } from 'react'; +import { FlowItemState, NodeKind } from '@the-dev-tools/spec/buf/api/flow/v1/flow_pb'; +import { + EdgeCollectionSchema, + FlowCollectionSchema, + FlowVariableCollectionSchema, + NodeAiCollectionSchema, + NodeCollectionSchema, + NodeConditionCollectionSchema, + NodeExecutionCollectionSchema, + NodeForCollectionSchema, + NodeForEachCollectionSchema, + NodeHttpCollectionSchema, + NodeJsCollectionSchema, +} from '@the-dev-tools/spec/tanstack-db/v1/api/flow'; +import { FileCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/file_system'; +import { + HttpAssertCollectionSchema, + HttpBodyRawCollectionSchema, + HttpCollectionSchema, + HttpHeaderCollectionSchema, + HttpSearchParamCollectionSchema, +} from '@the-dev-tools/spec/tanstack-db/v1/api/http'; +import { useApiCollection } from '~/shared/api'; +import { queryCollection } from '~/shared/lib'; +import { routes } from '~/shared/routes'; +import { AgentLogger } from './agent-logger'; +import { + buildCompactStateSummary, + buildSystemPrompt, + buildXmlValidationMessage, + detectDeadEndNodes, + detectOrphanNodes, + refreshFlowContext, + useFlowContext, +} from './context-builder'; +import { defaultHorizontalConfig, layoutNodes } from './layout'; +import { type Collections, executeToolCall, type ToolExecutorContext } from './tool-executor'; +import { allToolSchemas } from './tool-schemas'; +import { + type AgentChatState, + formatToolAsOpenAI, + type FlowContextData, + type Message, + type OpenAIMessage, + type ToolCall, + type ToolResult, + type ToolSchema, +} from './types'; + +const MODEL = 'minimax/minimax-m2.5'; + +const createOpenRouterClient = (apiKey: string) => + new OpenAI({ + apiKey, + baseURL: 'https://openrouter.ai/api/v1', + dangerouslyAllowBrowser: true, + }); + +const generateId = () => crypto.randomUUID(); + +/** JSON stringify with BigInt support */ +const safeStringify = (value: unknown): string => + JSON.stringify(value, (_key: string, v: unknown) => (typeof v === 'bigint' ? v.toString() : v)); + +// --------------------------------------------------------------------------- +// Streaming helpers +// --------------------------------------------------------------------------- + +interface StreamedMessage { + content: null | string; + tool_calls?: { + function: { arguments: string; name: string }; + id: string; + type: 'function'; + }[]; +} + +interface StreamMeta { + finishReason: null | string | undefined; + usage: unknown; +} + +/** + * Consumes an OpenAI streaming response, accumulating content and tool calls. + * Calls `onContent` with the accumulated text after every content delta so the + * UI can render tokens in real-time. + */ +const consumeStream = async ( + stream: AsyncIterable, + onContent: (accumulated: string) => void, +): Promise<{ message: StreamedMessage; meta: StreamMeta }> => { + let content = ''; + let hasContent = false; + const toolCallsMap = new Map(); + let finishReason: null | string | undefined = null; + let usage: unknown = undefined; + + for await (const chunk of stream) { + const choice = chunk.choices[0]; + if (!choice) { + // Final chunk may carry only usage data + if (chunk.usage) usage = chunk.usage; + continue; + } + + if (choice.finish_reason) finishReason = choice.finish_reason; + if (chunk.usage) usage = chunk.usage; + + const delta = choice.delta; + if (delta?.content) { + content += delta.content; + hasContent = true; + onContent(content); + } + + if (delta?.tool_calls) { + for (const tc of delta.tool_calls) { + const existing = toolCallsMap.get(tc.index); + if (existing) { + if (tc.function?.name) existing.name += tc.function.name; + if (tc.function?.arguments) existing.arguments += tc.function.arguments; + } else { + toolCallsMap.set(tc.index, { + arguments: tc.function?.arguments ?? '', + id: tc.id ?? '', + name: tc.function?.name ?? '', + }); + } + } + } + } + + const toolCalls = + toolCallsMap.size > 0 + ? Array.from(toolCallsMap.entries()) + .sort(([a], [b]) => a - b) + .map(([, tc]) => ({ + function: { arguments: tc.arguments, name: tc.name }, + id: tc.id, + type: 'function' as const, + })) + : undefined; + + return { + message: { + content: hasContent ? content : null, + tool_calls: toolCalls, + }, + meta: { finishReason, usage }, + }; +}; + +type NodeCollection = ReturnType>; +type EdgeCollection = ReturnType>; + +const NODE_KIND_NAMES: Record = { + [NodeKind.AI]: 'Ai', + [NodeKind.CONDITION]: 'Condition', + [NodeKind.FOR]: 'For', + [NodeKind.FOR_EACH]: 'ForEach', + [NodeKind.HTTP]: 'HTTP', + [NodeKind.JS]: 'JavaScript', + [NodeKind.MANUAL_START]: 'ManualStart', + [NodeKind.UNSPECIFIED]: 'Unknown', +}; + +/** + * Query fresh nodes and edges directly from collections, then apply layout. + * This avoids stale context issues when mutations haven't propagated to React state yet. + */ +const applyLayoutToFlow = async ( + flowId: Uint8Array, + nodeCollection: NodeCollection, + edgeCollection: EdgeCollection, +): Promise => { + // Query fresh nodes directly from the collection + const freshNodes = await queryCollection((_) => + _.from({ node: nodeCollection }).where((_) => eq(_.node.flowId, flowId)), + ); + + // Query fresh edges directly from the collection + const freshEdges = await queryCollection((_) => + _.from({ edge: edgeCollection }).where((_) => eq(_.edge.flowId, flowId)), + ); + + // Build node info for layout + const nodes = freshNodes + .filter((n) => n.nodeId != null) + .map((n) => ({ + id: Ulid.construct(n.nodeId).toCanonical(), + kind: NODE_KIND_NAMES[n.kind] ?? 'Unknown', + name: n.name, + position: { x: n.position?.x ?? 0, y: n.position?.y ?? 0 }, + state: 'Idle', + })); + + // Build a set of valid node IDs for filtering + const validNodeIds = new Set(nodes.map((n) => n.id)); + + // Build edge info for layout - only include edges where both source and target exist + const edges = freshEdges + .filter((e) => e.edgeId != null && e.sourceId != null && e.targetId != null) + .map((e) => ({ + id: Ulid.construct(e.edgeId).toCanonical(), + sourceHandle: e.sourceHandle !== undefined ? String(e.sourceHandle) : undefined, + sourceId: Ulid.construct(e.sourceId).toCanonical(), + targetId: Ulid.construct(e.targetId).toCanonical(), + })) + .filter((e) => validNodeIds.has(e.sourceId) && validNodeIds.has(e.targetId)); + + const result = layoutNodes(nodes, edges, defaultHorizontalConfig()); + if (!result) return; + + for (const [nodeId, position] of result.positions) { + nodeCollection.utils.update({ + nodeId: Ulid.fromCanonical(nodeId).bytes, + position: { x: position.x, y: position.y }, + }); + } +}; + +const clientToolSchemas: ToolSchema[] = [ + { + description: + "Inspect a node's full config and execution state. Returns type-specific config (HTTP: url/method/headers/params/body/assertions, JS: code, Condition: expression, For: iterations/condition, ForEach: path/condition) plus execution state/error. " + + 'Set includeOutput: true to also get execution input/output payloads (can be large).', + name: 'inspectNode', + parameters: { + additionalProperties: false, + properties: { + includeOutput: { + description: + 'Include execution input/output payloads (default: false). Only use when you need to see actual request/response data.', + type: 'boolean', + }, + nodeId: { description: 'The node ID to inspect', type: 'string' }, + }, + required: ['nodeId'], + type: 'object', + }, + }, + { + description: 'Get a summary of the latest flow execution showing which nodes ran and which were never reached.', + name: 'getFlowExecutionSummary', + parameters: { + additionalProperties: false, + properties: {}, + required: [], + type: 'object', + }, + }, + { + description: + "Update any node's configuration in a single call. Provide nodeId and only the fields to change — unspecified fields stay unchanged. " + + 'Base fields (name) work on any node. Type-specific fields: ' + + 'Ai: prompt, maxIterations. Condition: condition. For: iterations, condition (break), errorHandling. ' + + 'ForEach: path, condition (break), errorHandling. JS: code. ' + + 'HTTP: method, url, headers, searchParams, body, assertions (arrays replace existing set).', + name: 'updateNode', + parameters: { + additionalProperties: false, + properties: { + assertions: { + description: 'Replaces all existing assertions (HTTP only)', + items: { + properties: { + enabled: { type: 'boolean' }, + value: { type: 'string' }, + }, + required: ['value'], + type: 'object', + }, + type: 'array', + }, + body: { + description: + 'Raw body content (JSON string). Set to null to clear. (HTTP only) Supports {{variable}} interpolation.', + type: ['string', 'null'], + }, + code: { + description: 'JavaScript code (JS nodes only)', + type: 'string', + }, + condition: { + description: + 'For Condition nodes: branching expression. For For/ForEach: break condition (expr-lang syntax).', + type: 'string', + }, + errorHandling: { + description: 'Error handling strategy (For/ForEach only)', + enum: ['ignore', 'break'], + type: 'string', + }, + headers: { + description: 'Replaces all existing headers (HTTP only)', + items: { + properties: { + enabled: { type: 'boolean' }, + key: { type: 'string' }, + value: { + description: 'Supports {{variable}} interpolation, e.g. Bearer {{Auth.response.body.token}}', + type: 'string', + }, + }, + required: ['key'], + type: 'object', + }, + type: 'array', + }, + iterations: { + description: 'Number of loop iterations, must be positive (For nodes only)', + type: 'integer', + }, + maxIterations: { + description: 'Maximum number of agentic iterations, must be positive (Ai nodes only)', + type: 'integer', + }, + method: { + description: 'HTTP method (HTTP nodes only)', + enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'], + type: 'string', + }, + name: { + description: 'New node name (any node type)', + type: 'string', + }, + nodeId: { description: 'The node ID to update', type: 'string' }, + path: { + description: 'Collection expression to iterate (ForEach nodes only, expr-lang syntax)', + type: 'string', + }, + prompt: { + description: 'The prompt or system instructions for the AI agent (Ai nodes only)', + type: 'string', + }, + searchParams: { + description: 'Replaces all existing query parameters (HTTP only)', + items: { + properties: { + enabled: { type: 'boolean' }, + key: { type: 'string' }, + value: { description: 'Supports {{variable}} interpolation.', type: 'string' }, + }, + required: ['key'], + type: 'object', + }, + type: 'array', + }, + url: { + description: + 'Request URL (HTTP nodes only). Supports {{variable}} interpolation, e.g. {{BASE_URL}}/api/users/{{id}}', + type: 'string', + }, + }, + required: ['nodeId'], + type: 'object', + }, + }, + { + description: + 'Incrementally add or remove headers, query params, or assertions on an HTTP node without replacing the full set. ' + + 'Use this when modifying individual items. For full replacement, use updateNode instead.', + name: 'patchHttpNode', + parameters: { + additionalProperties: false, + properties: { + nodeId: { description: 'The HTTP node ID to patch', type: 'string' }, + addHeaders: { + description: 'Headers to append. Supports {{variable}} interpolation in values.', + items: { + properties: { + description: { type: 'string' }, + enabled: { type: 'boolean' }, + key: { type: 'string' }, + value: { description: 'Supports {{variable}} interpolation', type: 'string' }, + }, + required: ['key'], + type: 'object', + }, + type: 'array', + }, + removeHeaderIds: { + description: 'IDs of headers to remove (get IDs from inspectNode)', + items: { type: 'string' }, + type: 'array', + }, + addSearchParams: { + description: 'Query params to append. Supports {{variable}} interpolation in values.', + items: { + properties: { + description: { type: 'string' }, + enabled: { type: 'boolean' }, + key: { type: 'string' }, + value: { description: 'Supports {{variable}} interpolation', type: 'string' }, + }, + required: ['key'], + type: 'object', + }, + type: 'array', + }, + removeSearchParamIds: { + description: 'IDs of query params to remove (get IDs from inspectNode)', + items: { type: 'string' }, + type: 'array', + }, + addAssertions: { + description: 'Assertions to append', + items: { + properties: { + enabled: { type: 'boolean' }, + value: { type: 'string' }, + }, + required: ['value'], + type: 'object', + }, + type: 'array', + }, + removeAssertionIds: { + description: 'IDs of assertions to remove (get IDs from inspectNode)', + items: { type: 'string' }, + type: 'array', + }, + }, + required: ['nodeId'], + type: 'object', + }, + }, + { + description: + 'PREFERRED tool for ALL node connections. Connects nodes into a chain with optional parallel fan-out. ' + + 'Flat array: sequential chain. Nested array: parallel branches. ' + + 'Example: ["Start",["A","B"],"End"] creates Start→A, Start→B, A→End, B→End. ' + + 'Works for ALL node types. For branching nodes (Condition, For, ForEach, Ai), auto-applies "then" handle by default. ' + + 'Use sourceHandle "else" or "loop" to override for non-default branches. ' + + 'Use sourceHandle "ai_tools" to connect tool nodes to an Ai node.', + name: 'connectChain', + parameters: { + additionalProperties: false, + properties: { + nodeIds: { + description: + 'Ordered list of node IDs. Use nested arrays for fan-out/fan-in: ' + + '["A","B","C"] chains A→B→C. ' + + '["A",["B","C"],"D"] fans out A→B, A→C then fans in B→D, C→D. ' + + 'Minimum 2 elements. No consecutive nested arrays.', + items: { oneOf: [{ type: 'string' }, { items: { type: 'string' }, type: 'array' }] }, + type: 'array', + }, + sourceHandle: { + description: + 'Handle for branching source nodes. Defaults to "then". ' + + 'Use "else" for Condition false-branch, "loop" for For/ForEach loop-body, ' + + '"ai_tools" for connecting tool nodes to an Ai node.', + enum: ['then', 'else', 'loop', 'ai_tools'], + type: 'string', + }, + }, + required: ['nodeIds'], + type: 'object', + }, + }, +]; + +interface UseAgentChatOptions { + apiKey: string; + flowId: Uint8Array; + selectedNodeIds?: string[]; +} + +const createInitialAgentChatState = (): AgentChatState => ({ + error: null, + isLoading: false, + messages: [], + streamingContent: '', +}); + +// --------------------------------------------------------------------------- +// Module-level external store – survives React component remounts +// --------------------------------------------------------------------------- + +interface ChatStoreEntry { + abortController: AbortController | null; + state: AgentChatState; +} + +const chatStoreEntries = new Map(); +const chatStoreListeners = new Map void>>(); + +const chatStore = { + getAbortController(key: string): AbortController | null { + return chatStoreEntries.get(key)?.abortController ?? null; + }, + + getState(key: string): AgentChatState { + let entry = chatStoreEntries.get(key); + if (!entry) { + entry = { abortController: null, state: createInitialAgentChatState() }; + chatStoreEntries.set(key, entry); + } + return entry.state; + }, + + notify(key: string) { + chatStoreListeners.get(key)?.forEach((cb) => cb()); + }, + + setAbortController(key: string, ac: AbortController | null) { + let entry = chatStoreEntries.get(key); + if (!entry) { + entry = { abortController: null, state: createInitialAgentChatState() }; + chatStoreEntries.set(key, entry); + } + entry.abortController = ac; + }, + + setState(key: string, updater: ((prev: AgentChatState) => AgentChatState) | AgentChatState) { + let entry = chatStoreEntries.get(key); + if (!entry) { + entry = { abortController: null, state: createInitialAgentChatState() }; + chatStoreEntries.set(key, entry); + } + entry.state = typeof updater === 'function' ? updater(entry.state) : updater; + chatStore.notify(key); + }, + + subscribe(key: string, callback: () => void): () => void { + let listeners = chatStoreListeners.get(key); + if (!listeners) { + listeners = new Set(); + chatStoreListeners.set(key, listeners); + } + listeners.add(callback); + return () => { + listeners.delete(callback); + if (listeners.size === 0) chatStoreListeners.delete(key); + }; + }, +}; + +export const useAgentChat = ({ apiKey, flowId, selectedNodeIds }: UseAgentChatOptions) => { + const flowIdKey = Ulid.construct(flowId).toCanonical(); + + const state = useSyncExternalStore( + useCallback((cb: () => void) => chatStore.subscribe(flowIdKey, cb), [flowIdKey]), + useCallback(() => chatStore.getState(flowIdKey), [flowIdKey]), + ); + + const { transport } = routes.root.useRouteContext(); + const { workspaceId } = routes.dashboard.workspace.route.useLoaderData(); + const flowContext = useFlowContext(flowId); + + // Use refs to always access latest values in callbacks + const flowContextRef = useRef(flowContext); + flowContextRef.current = flowContext; + + const selectedNodeIdsRef = useRef(selectedNodeIds); + selectedNodeIdsRef.current = selectedNodeIds; + + const messagesRef = useRef(state.messages); + messagesRef.current = state.messages; + + const nodeCollection = useApiCollection(NodeCollectionSchema); + const edgeCollection = useApiCollection(EdgeCollectionSchema); + const variableCollection = useApiCollection(FlowVariableCollectionSchema); + const aiCollection = useApiCollection(NodeAiCollectionSchema); + const jsCollection = useApiCollection(NodeJsCollectionSchema); + const conditionCollection = useApiCollection(NodeConditionCollectionSchema); + const forCollection = useApiCollection(NodeForCollectionSchema); + const forEachCollection = useApiCollection(NodeForEachCollectionSchema); + const nodeHttpCollection = useApiCollection(NodeHttpCollectionSchema); + const httpCollection = useApiCollection(HttpCollectionSchema); + const httpSearchParamCollection = useApiCollection(HttpSearchParamCollectionSchema); + const httpHeaderCollection = useApiCollection(HttpHeaderCollectionSchema); + const httpBodyRawCollection = useApiCollection(HttpBodyRawCollectionSchema); + const httpAssertCollection = useApiCollection(HttpAssertCollectionSchema); + const executionCollection = useApiCollection(NodeExecutionCollectionSchema); + const fileCollection = useApiCollection(FileCollectionSchema); + const flowCollection = useApiCollection(FlowCollectionSchema); + + const sendMessage = useCallback( + async (content: string) => { + // Cancel any existing request + chatStore.getAbortController(flowIdKey)?.abort(); + const abortController = new AbortController(); + chatStore.setAbortController(flowIdKey, abortController); + + const openai = createOpenRouterClient(apiKey); + + // Use ref to get latest flowContext at execution time + const currentFlowContext = { + ...flowContextRef.current, + selectedNodeIds: selectedNodeIdsRef.current, + }; + + // Build context fresh at execution time to avoid stale closures + const collections: Collections = { + aiCollection, + conditionCollection, + edgeCollection, + executionCollection, + fileCollection, + forCollection, + forEachCollection, + httpAssertCollection, + httpBodyRawCollection, + httpCollection, + httpHeaderCollection, + httpSearchParamCollection, + jsCollection, + nodeCollection, + nodeHttpCollection, + variableCollection, + }; + + const waitForFlowCompletion = async (): Promise => { + const POLL_INTERVAL = 500; + const MAX_WAIT = 30_000; + const INITIAL_DELAY = 500; + let elapsed = 0; + + await new Promise((r) => setTimeout(r, INITIAL_DELAY)); + elapsed += INITIAL_DELAY; + + while (elapsed < MAX_WAIT) { + await new Promise((r) => setTimeout(r, POLL_INTERVAL)); + elapsed += POLL_INTERVAL; + + const [flow] = await queryCollection((_) => + _.from({ item: flowCollection }) + .where((_) => eq(_.item.flowId, flowId)) + .findOne(), + ); + if (flow && !flow.running) break; + } + }; + + const toolContext: ToolExecutorContext = { + collections, + flowContext: currentFlowContext, + sessionCreatedNodeIds: new Set(), + transport, + waitForFlowCompletion, + workspaceId, + }; + + const userMessage: Message = { + content, + id: generateId(), + role: 'user', + timestamp: Date.now(), + }; + + const logger = new AgentLogger(currentFlowContext.flowId); + logger.logSessionStart(currentFlowContext.flowId, content); + + chatStore.setState(flowIdKey, (prev) => ({ + ...prev, + error: null, + isLoading: true, + messages: [...prev.messages, userMessage], + })); + + try { + const systemPrompt = buildSystemPrompt(currentFlowContext); + const tools = [...allToolSchemas, ...clientToolSchemas].map(formatToolAsOpenAI); + + logger.logSystemPrompt(systemPrompt, { + edges: currentFlowContext.edges.length, + nodes: currentFlowContext.nodes.length, + variables: currentFlowContext.variables.length, + }); + logger.logUserMessage(content); + + const openAIMessages: OpenAIMessage[] = [ + { content: systemPrompt, role: 'system' }, + ...messagesRef.current.map(messageToOpenAI), + { content, role: 'user' }, + ]; + + logger.logApiRequest(MODEL, openAIMessages.length, true); + let apiStart = performance.now(); + + const updateStreamingContent = (content: string) => { + chatStore.setState(flowIdKey, (prev) => ({ ...prev, streamingContent: content })); + }; + + let stream = await openai.chat.completions.create( + { + messages: openAIMessages, + model: MODEL, + stream: true, + tool_choice: 'auto', + tools, + }, + { signal: abortController.signal }, + ); + + let { message: streamedMsg, meta } = await consumeStream(stream, updateStreamingContent); + chatStore.setState(flowIdKey, (prev) => ({ ...prev, streamingContent: '' })); + + logger.logApiResponse(performance.now() - apiStart, meta.finishReason, meta.usage); + let assistantMessage = streamedMsg; + + let validationRetries = 0; + const MAX_VALIDATION_RETRIES = 2; + + for (;;) { + // === Existing tool call loop === + while (assistantMessage?.tool_calls && assistantMessage.tool_calls.length > 0) { + const toolCalls: ToolCall[] = assistantMessage.tool_calls.map((tc) => ({ + arguments: JSON.parse(tc.function.arguments) as Record, + id: tc.id, + name: tc.function.name, + })); + + const toolMessage: Message = { + content: assistantMessage.content ?? '', + id: generateId(), + role: 'assistant', + timestamp: Date.now(), + toolCalls, + }; + + chatStore.setState(flowIdKey, (prev) => ({ + ...prev, + messages: [...prev.messages, toolMessage], + })); + + for (const tc of toolCalls) { + logger.logToolCallStart(tc.id, tc.name, tc.arguments); + } + + const toolCallTimers: number[] = []; + const toolResults: ToolResult[] = []; + for (const tc of toolCalls) { + toolCallTimers.push(performance.now()); + toolResults.push(await executeToolCall(tc, flowId, toolContext)); + } + + for (let i = 0; i < toolResults.length; i++) { + const tr = toolResults[i]!; + const tc = toolCalls[i]!; + const elapsed = performance.now() - toolCallTimers[i]!; + logger.logToolCallEnd(tc.id, tc.name, elapsed, tr.error ?? safeStringify(tr.result), tr.error); + } + + // Apply layout and refresh context after mutations + const hadMutations = toolResults.some((tr: ToolResult) => tr.isMutation && !tr.error); + if (hadMutations) { + // Query fresh data directly from collections to avoid stale React context + await applyLayoutToFlow(flowId, nodeCollection, edgeCollection); + + // Refresh flow context so subsequent tool calls see newly created nodes + toolContext.flowContext = { + ...(await refreshFlowContext(flowId, { + edgeCollection, + executionCollection, + httpCollection, + nodeCollection, + nodeHttpCollection, + variableCollection, + })), + selectedNodeIds: selectedNodeIdsRef.current, + }; + + // Inject updated flow state so LLM sees current topology + const stateSummary = buildCompactStateSummary(toolContext.flowContext); + openAIMessages.push({ content: stateSummary, role: 'system' }); + } + + const toolResultMessages: Message[] = toolResults.map((tr) => ({ + content: tr.error ?? safeStringify(tr.result), + id: generateId(), + role: 'tool' as const, + timestamp: Date.now(), + toolCallId: tr.toolCallId, + })); + + chatStore.setState(flowIdKey, (prev) => ({ + ...prev, + messages: [...prev.messages, ...toolResultMessages], + })); + + openAIMessages.push({ + content: assistantMessage.content, + role: 'assistant', + tool_calls: assistantMessage.tool_calls, + }); + + // Collapse identical error messages to reduce noise + const errorGroups = new Map(); + for (const tr of toolResults) { + if (tr.error) { + const existing = errorGroups.get(tr.error); + if (existing) { + existing.count++; + } else { + errorGroups.set(tr.error, { count: 1, firstId: tr.toolCallId }); + } + } + } + + for (const tr of toolResults) { + const errorGroup = tr.error ? errorGroups.get(tr.error) : undefined; + let content: string; + if (tr.error && errorGroup && errorGroup.count > 1) { + if (tr.toolCallId === errorGroup.firstId) { + content = `${tr.error} (this error occurred ${errorGroup.count} times in this batch)`; + } else { + content = `Same error as ${errorGroup.firstId}`; + } + } else { + content = tr.error ?? safeStringify(tr.result); + } + openAIMessages.push({ + content, + role: 'tool', + tool_call_id: tr.toolCallId, + }); + } + + logger.logApiRequest(MODEL, openAIMessages.length, true); + apiStart = performance.now(); + + stream = await openai.chat.completions.create( + { + messages: openAIMessages, + model: MODEL, + stream: true, + tool_choice: 'auto', + tools, + }, + { signal: abortController.signal }, + ); + + ({ message: streamedMsg, meta } = await consumeStream(stream, updateStreamingContent)); + chatStore.setState(flowIdKey, (prev) => ({ ...prev, streamingContent: '' })); + + logger.logApiResponse(performance.now() - apiStart, meta.finishReason, meta.usage); + assistantMessage = streamedMsg; + } + + // === Post-execution validation: check for orphan nodes === + if (validationRetries >= MAX_VALIDATION_RETRIES) break; + + const freshNodes = await queryCollection((_) => + _.from({ node: nodeCollection }).where((_) => eq(_.node.flowId, flowId)), + ); + const freshEdges = await queryCollection((_) => + _.from({ edge: edgeCollection }).where((_) => eq(_.edge.flowId, flowId)), + ); + + const nodeInfos = freshNodes + .filter((n) => n.nodeId != null) + .map((n) => ({ + id: Ulid.construct(n.nodeId).toCanonical(), + kind: NODE_KIND_NAMES[n.kind] ?? 'Unknown', + name: n.name, + })); + const edgeInfos = freshEdges + .filter((e) => e.edgeId != null) + .map((e) => ({ + sourceId: Ulid.construct(e.sourceId).toCanonical(), + targetId: Ulid.construct(e.targetId).toCanonical(), + })); + + const orphans = detectOrphanNodes(nodeInfos, edgeInfos); + const deadEnds = orphans.length === 0 ? detectDeadEndNodes(nodeInfos, edgeInfos) : []; + logger.logValidation( + orphans.length, + orphans.map((n) => n.name), + ); + if (orphans.length === 0 && deadEnds.length === 0) break; + + validationRetries++; + + const validationContent = buildXmlValidationMessage(orphans, deadEnds); + + // Add the assistant's text response to messages before injecting validation + if (assistantMessage?.content) { + openAIMessages.push({ + content: assistantMessage.content, + role: 'assistant', + }); + } + + openAIMessages.push({ + content: validationContent, + role: 'user', + }); + + logger.logApiRequest(MODEL, openAIMessages.length, true); + apiStart = performance.now(); + + stream = await openai.chat.completions.create( + { messages: openAIMessages, model: MODEL, stream: true, tool_choice: 'auto', tools }, + { signal: abortController.signal }, + ); + + ({ message: streamedMsg, meta } = await consumeStream(stream, updateStreamingContent)); + chatStore.setState(flowIdKey, (prev) => ({ ...prev, streamingContent: '' })); + + logger.logApiResponse(performance.now() - apiStart, meta.finishReason, meta.usage); + assistantMessage = streamedMsg; + } + + const finalMessage: Message = { + content: assistantMessage?.content ?? '', + id: generateId(), + role: 'assistant', + timestamp: Date.now(), + }; + + logger.logAssistantMessage(finalMessage.content); + logger.logSessionEnd(true, false); + + chatStore.setState(flowIdKey, (prev) => ({ + ...prev, + isLoading: false, + messages: [...prev.messages, finalMessage], + })); + } catch (error) { + // Ignore abort errors + if (error instanceof Error && error.name === 'AbortError') { + logger.logSessionEnd(false, true); + chatStore.setState(flowIdKey, (prev) => ({ ...prev, isLoading: false, streamingContent: '' })); + return; + } + logger.logError(error, 'sendMessage'); + logger.logSessionEnd(false, false); + const errorMessage = error instanceof Error ? error.message : 'An error occurred'; + chatStore.setState(flowIdKey, (prev) => ({ + ...prev, + error: errorMessage, + isLoading: false, + streamingContent: '', + })); + } finally { + if (chatStore.getAbortController(flowIdKey) === abortController) { + chatStore.setAbortController(flowIdKey, null); + } + } + }, + [ + apiKey, + flowId, + transport, + nodeCollection, + edgeCollection, + variableCollection, + aiCollection, + jsCollection, + conditionCollection, + forCollection, + forEachCollection, + nodeHttpCollection, + httpCollection, + httpSearchParamCollection, + httpHeaderCollection, + httpBodyRawCollection, + httpAssertCollection, + executionCollection, + fileCollection, + flowCollection, + workspaceId, + ], + ); + + const clearMessages = useCallback(() => { + chatStore.getAbortController(flowIdKey)?.abort(); + chatStore.setAbortController(flowIdKey, null); + chatStore.setState(flowIdKey, { + messages: [], + isLoading: false, + error: null, + streamingContent: '', + }); + }, [flowIdKey]); + + const cancel = useCallback(() => { + chatStore.getAbortController(flowIdKey)?.abort(); + chatStore.setAbortController(flowIdKey, null); + chatStore.setState(flowIdKey, (prev) => ({ ...prev, isLoading: false, streamingContent: '' })); + }, [flowIdKey]); + + return { + cancel, + clearMessages, + error: state.error, + isLoading: state.isLoading, + messages: state.messages, + sendMessage, + streamingContent: state.streamingContent, + }; +}; + +const messageToOpenAI = (message: Message): OpenAIMessage => { + if (message.role === 'tool' && message.toolCallId) { + return { + content: message.content, + role: 'tool', + tool_call_id: message.toolCallId, + }; + } + + if (message.role === 'assistant' && message.toolCalls) { + return { + content: message.content, + role: 'assistant', + tool_calls: message.toolCalls.map((tc) => ({ + function: { + arguments: JSON.stringify(tc.arguments), + name: tc.name, + }, + id: tc.id, + type: 'function' as const, + })), + }; + } + + return { + content: message.content, + role: message.role as 'assistant' | 'system' | 'user', + }; +}; diff --git a/packages/client/src/features/agent/use-openrouter-key.ts b/packages/client/src/features/agent/use-openrouter-key.ts new file mode 100644 index 000000000..36f16df67 --- /dev/null +++ b/packages/client/src/features/agent/use-openrouter-key.ts @@ -0,0 +1,32 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +const STORAGE_KEY = 'openrouter-api-key'; + +const listeners = new Set<() => void>(); + +const subscribe = (cb: () => void) => { + listeners.add(cb); + return () => void listeners.delete(cb); +}; + +const getSnapshot = () => localStorage.getItem(STORAGE_KEY) ?? ''; + +const notify = () => { + for (const cb of listeners) cb(); +}; + +export const useOpenRouterKey = () => { + const apiKey = useSyncExternalStore(subscribe, getSnapshot, () => ''); + + const setApiKey = useCallback((key: string) => { + const trimmed = key.trim(); + if (trimmed) { + localStorage.setItem(STORAGE_KEY, trimmed); + } else { + localStorage.removeItem(STORAGE_KEY); + } + notify(); + }, []); + + return { apiKey, setApiKey }; +}; diff --git a/packages/client/src/pages/flow/agent-panel.tsx b/packages/client/src/pages/flow/agent-panel.tsx new file mode 100644 index 000000000..721622211 --- /dev/null +++ b/packages/client/src/pages/flow/agent-panel.tsx @@ -0,0 +1,744 @@ +import { eq, useLiveQuery } from '@tanstack/react-db'; +import * as XF from '@xyflow/react'; +import { Ulid } from 'id128'; +import { FormEvent, KeyboardEvent, use, useEffect, useMemo, useRef, useState } from 'react'; +import { FiArrowUp, FiChevronUp, FiEdit, FiX } from 'react-icons/fi'; +import Markdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { NodeCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/flow'; +import { Button } from '@the-dev-tools/ui/button'; +import { tw } from '@the-dev-tools/ui/tailwind-literal'; +import { type Message, type ToolCall, useAgentChat } from '~/features/agent'; +import { useOpenRouterKey } from '~/features/agent/use-openrouter-key'; +import { useApiCollection } from '~/shared/api'; +import { FlowContext } from './context'; +import { nodeClientCollection } from './node'; + +// --------------------------------------------------------------------------- +// Tool call display helpers +// --------------------------------------------------------------------------- + +const TOOL_OVERRIDES: Record = { + FlowRunRequest: ['Running', 'Ran', 'Flow'], + FlowStopRequest: ['Stopping', 'Stopped', 'Flow'], +}; + +const VERB_PAIRS: Record = { + Configure: ['Configuring', 'Configured'], + Connect: ['Connecting', 'Connected'], + Create: ['Creating', 'Created'], + Delete: ['Deleting', 'Deleted'], + Disconnect: ['Disconnecting', 'Disconnected'], + Get: ['Retrieving', 'Retrieved'], + Inspect: ['Inspecting', 'Inspected'], + Update: ['Updating', 'Updated'], +}; + +const formatToolCall = (name: string, active: boolean): [verb: string, label: string] => { + const ov = TOOL_OVERRIDES[name]; + if (ov) return [active ? ov[0] : ov[1], ov[2]]; + + const words = name + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .split(' '); + const pair = VERB_PAIRS[words[0] ?? '']; + const verb = pair ? (active ? pair[0] : pair[1]) : active ? 'Running' : 'Ran'; + const rest = (pair ? words.slice(1) : words) + .join(' ') + .replace(/\bHttp\b/g, 'HTTP') + .replace(/\bJs\b/g, 'JS') + .replace(/\s*Request$/g, '') + .trim(); + return [verb, rest || name]; +}; + +const getToolBrief = (args: Record): null | string => { + if (typeof args.name === 'string' && args.name) return args.name; + if (typeof args.url === 'string' && args.url) return args.url; + if (typeof args.key === 'string' && args.key) return args.key; + return null; +}; + +export const AgentPanel = () => { + const { flowId, setAgentPanelOpen } = use(FlowContext); + const { apiKey, setApiKey } = useOpenRouterKey(); + const selectedNodeIds = XF.useStore( + (s) => s.nodes.filter((n) => n.selected).map((n) => n.id), + (a, b) => a.length === b.length && a.every((id, i) => id === b[i]), + ); + const { cancel, clearMessages, error, isLoading, messages, sendMessage, streamingContent } = useAgentChat({ + apiKey, + flowId, + selectedNodeIds, + }); + + const [input, setInput] = useState(''); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + + const completedToolCallIds = useMemo(() => { + const ids = new Set(); + for (const message of messages) { + if (message.role === 'tool' && message.toolCallId) { + ids.add(message.toolCallId); + } + } + return ids; + }, [messages]); + + const activeToolMessageId = useMemo(() => { + if (!isLoading) return null; + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if (message.role !== 'assistant' || !message.toolCalls?.length) continue; + + const hasPendingToolCalls = message.toolCalls.some((tc) => !completedToolCallIds.has(tc.id)); + if (hasPendingToolCalls) return message.id; + } + + return null; + }, [completedToolCallIds, isLoading, messages]); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, isLoading, streamingContent]); + + const autoResize = () => { + const el = textareaRef.current; + if (!el) return; + el.style.height = '0'; + el.style.height = `${el.scrollHeight}px`; + }; + + const handleSubmit = (e?: FormEvent) => { + e?.preventDefault(); + if (!input.trim() || isLoading) return; + void sendMessage(input.trim()); + setInput(''); + // Reset textarea height after clearing + requestAnimationFrame(() => { + if (textareaRef.current) { + textareaRef.current.style.height = ''; + } + }); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }; + + return ( +
+ {/* Header */} +
+
+ Agent +
+ + + + +
+ + {apiKey ? ( + <> + {/* Messages */} +
+ {messages.length === 0 ? ( +
+

Ask me to create or modify workflow nodes.

+

+ e.g. "Create a JavaScript node that returns hello world" +

+
+ ) : ( +
+ {messages.map((message) => ( + + ))} + {isLoading && (streamingContent ? : )} +
+
+ )} + + {error &&
{error}
} +
+ + {/* Input */} +
+ {selectedNodeIds.length > 0 && } +
+