diff --git a/AGENTS.md b/AGENTS.md index 4e2053c8..315149f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,6 +147,15 @@ - 公开 API 与领域对象命名要表达业务意图,避免含糊词。 - 把不需要的直接删除, 无需考虑兼容性 +## 前端设计默认规则 +- 前端相关请求(页面、组件、控制台、playground、样式重构、视觉 polish)默认遵循 `aevatar-frontend-design` 规范;若运行环境存在同名 skill,优先使用。 +- 先确定一个明确审美方向,再开始编码;禁止把多个弱风格混在一起,禁止生成无记忆点的通用 SaaS 外观。 +- 禁止默认回落到通用 AI 审美:避免把 `Inter/Arial/Roboto/system-ui` 作为首选字体,避免紫白渐变、模板化卡片网格、无差异面板堆叠。 +- 优先抽取 design tokens / CSS variables / theme tokens,统一颜色、字体、间距、圆角、阴影与动效,不接受大面积零散硬编码。 +- 在现有信息架构和交互模型内提升层次、比例、对比、质感与动效;除非用户明确要求大改,否则不要破坏既有导航和工作流。 +- 结果必须可用:响应式、键盘可达、基本可访问性达标,真实内容密度下仍可读。 + + ## 测试与质量门禁 - 测试栈:xUnit、FluentAssertions、`coverlet.collector`。 - 测试文件命名:`*Tests.cs`,单文件聚焦一个行为域。 diff --git a/CLAUDE.md b/CLAUDE.md index c486ab44..0522e7a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,6 +162,15 @@ - 先抽象后实现;优先接口注入;避免跨层直接调用。 - 公开 API 与领域对象命名表达业务意图,避免含糊词。 +## 前端设计默认规则 +- 前端相关请求(页面、组件、控制台、playground、样式重构、视觉 polish)默认遵循 `aevatar-frontend-design` 规范;若运行环境存在同名 skill,优先使用。 +- 先确定一个明确审美方向,再开始编码;禁止把多个弱风格混在一起,禁止生成无记忆点的通用 SaaS 外观。 +- 禁止默认回落到通用 AI 审美:避免把 `Inter/Arial/Roboto/system-ui` 作为首选字体,避免紫白渐变、模板化卡片网格、无差异面板堆叠。 +- 优先抽取 design tokens / CSS variables / theme tokens,统一颜色、字体、间距、圆角、阴影与动效,不接受大面积零散硬编码。 +- 在现有信息架构和交互模型内提升层次、比例、对比、质感与动效;除非用户明确要求大改,否则不要破坏既有导航和工作流。 +- 结果必须可用:响应式、键盘可达、基本可访问性达标,真实内容密度下仍可读。 + + ## 测试与质量门禁 - 测试栈:xUnit、FluentAssertions、`coverlet.collector`。 - 测试文件命名:`*Tests.cs`,单文件聚焦一个行为域。 diff --git a/apps/aevatar-console-web/.env.example b/apps/aevatar-console-web/.env.example index bd2e660c..fe83d1d6 100644 --- a/apps/aevatar-console-web/.env.example +++ b/apps/aevatar-console-web/.env.example @@ -23,6 +23,10 @@ NYXID_CLIENT_ID= NYXID_REDIRECT_URI=http://127.0.0.1:5173/auth/callback NYXID_SCOPE="openid profile email roles groups" +# Ornn skills platform +# Optional. Defaults to the public Ornn instance when omitted. +ORNN_BASE_URL=https://ornn.chrono-ai.fun + # Local dev stack ports used by scripts/dev-stack.sh # FRONTEND_PORT affects the dev server bind port. # API/CONFIG ports affect backend startup only; update the proxy targets above too. diff --git a/apps/aevatar-console-web/.env.production.example b/apps/aevatar-console-web/.env.production.example index 319cf51d..dd05f4cf 100644 --- a/apps/aevatar-console-web/.env.production.example +++ b/apps/aevatar-console-web/.env.production.example @@ -10,5 +10,8 @@ NYXID_CLIENT_ID=replace-with-public-client-id NYXID_REDIRECT_URI=https://console.example.com/auth/callback NYXID_SCOPE="openid profile email roles groups" +# Ornn skills platform for the browser client +ORNN_BASE_URL=https://ornn.example.com + # Planned but not wired in the current frontend build: # AEVATAR_CONSOLE_PUBLIC_PATH=/console/ diff --git a/apps/aevatar-console-web/.gitignore b/apps/aevatar-console-web/.gitignore index 4309e1e6..31433f72 100644 --- a/apps/aevatar-console-web/.gitignore +++ b/apps/aevatar-console-web/.gitignore @@ -35,6 +35,7 @@ functions/* .umi .umi-production .umi-test +src/.umi-* .turbopack # screenshot diff --git a/apps/aevatar-console-web/README.md b/apps/aevatar-console-web/README.md index a287d68b..f7ba576f 100644 --- a/apps/aevatar-console-web/README.md +++ b/apps/aevatar-console-web/README.md @@ -40,12 +40,14 @@ NYXID_BASE_URL=http://127.0.0.1:3001 NYXID_CLIENT_ID=your-public-client-id NYXID_REDIRECT_URI=http://127.0.0.1:5173/auth/callback NYXID_SCOPE="openid profile email" +ORNN_BASE_URL=https://ornn.chrono-ai.fun # Optional when deploying under a sub-path such as /console/ AEVATAR_CONSOLE_PUBLIC_PATH=/ ``` `NYXID_BASE_URL` and `NYXID_CLIENT_ID` are required. The console no longer ships a baked-in NyxID tenant or client id. `NYXID_REDIRECT_URI` must exactly match the public client registration in NyxID. +`ORNN_BASE_URL` controls the Ornn skills endpoint used by Studio Settings. If you omit it, the frontend falls back to the public Ornn instance. If you change `.env.local`, restart `pnpm dev` so Umi reloads the injected env values. ## Available scripts @@ -83,6 +85,7 @@ env Cli__App__NyxId__Enabled=true Cli__App__ScopeId=aevatar \ cd apps/aevatar-console-web AEVATAR_API_TARGET=http://127.0.0.1:5080 \ AEVATAR_STUDIO_API_TARGET=http://127.0.0.1:6690 \ +ORNN_BASE_URL=https://ornn.chrono-ai.fun \ pnpm dev ``` diff --git a/apps/aevatar-console-web/config/config.ts b/apps/aevatar-console-web/config/config.ts index fc68edbe..2a4a9949 100644 --- a/apps/aevatar-console-web/config/config.ts +++ b/apps/aevatar-console-web/config/config.ts @@ -155,6 +155,7 @@ const config: ReturnType = defineConfig({ process.env.NYXID_REDIRECT_URI, ), 'process.env.NYXID_SCOPE': JSON.stringify(process.env.NYXID_SCOPE), + 'process.env.ORNN_BASE_URL': JSON.stringify(process.env.ORNN_BASE_URL), 'process.env.AEVATAR_CONSOLE_PUBLIC_PATH': JSON.stringify( process.env.AEVATAR_CONSOLE_PUBLIC_PATH, ), diff --git a/apps/aevatar-console-web/config/routes.ts b/apps/aevatar-console-web/config/routes.ts index 9f2fef38..b8382d61 100644 --- a/apps/aevatar-console-web/config/routes.ts +++ b/apps/aevatar-console-web/config/routes.ts @@ -34,6 +34,12 @@ export default [ menuGroupKey: "build", menuBadgeKey: "build.assets", }, + { + path: "/scopes/files", + name: "Files", + component: "./scopes/files", + menuGroupKey: "build", + }, { path: "/studio", name: "Studio", diff --git a/apps/aevatar-console-web/docs/2026-03-25-runs-page-restructure-wireframe.md b/apps/aevatar-console-web/docs/2026-03-25-runs-page-restructure-wireframe.md deleted file mode 100644 index 3c906481..00000000 --- a/apps/aevatar-console-web/docs/2026-03-25-runs-page-restructure-wireframe.md +++ /dev/null @@ -1,329 +0,0 @@ -# Runs Page 重构草图 - -## 1. 目标 - -`Runs` 页当前同时承担: - -- run 启动入口 -- run 实时观测 -- workflow 概览 -- actor 快照 -- human interaction 处理 -- recent/preset 辅助入口 - -问题不是功能不够,而是这些信息被放在同一层级展示,导致: - -- 首屏信息密度过高 -- 卡片高度不一致 -- 主次不清,用户视线在多个面板之间来回跳 -- `Console` 反而被挤到页面下半部,失去主舞台地位 - -重构目标: - -1. 让 `Run trace` 成为唯一主视图。 -2. 把 run 启动、运行摘要、待处理交互收敛为左右两侧辅助面板。 -3. 把大块 `ProDescriptions` 从首屏主区域移走,改成扫读式 summary。 -4. 统一卡片语义与高度规则,减少“每块都像一个独立页面”的感觉。 - -## 2. 页面结构 - -### 2.1 桌面版草图 - -```text -+-----------------------------------------------------------------------------------------------------------+ -| Runtime Run Console | -| Start run | Open workflows | Open actor | Open settings | -+-----------------------------------------------------------------------------------------------------------+ -| STATUS STRIP | -| [Running] [RunId: xxx] [Elapsed: 02:14] [Workflow: human_input_manual_triage] [WS] [Pending Interaction] | -+------------------------------+------------------------------------------------------+----------------------+ -| LAUNCH RAIL | RUN TRACE | INSPECTOR | -| 360px | flexible | 320px | -| | | | -| [Compose | Recent | Presets] | [Timeline | Messages | Events] | [Run Summary] | -| | | status | -| Prompt | Step group: classify_incident | actorId | -| Workflow | 10:21:02 step.request waiting for manual input | commandId | -| Transport | 10:21:05 assistant "Need severity..." | active steps | -| Existing actorId | | | -| | Step group: human_approval | [Interaction] | -| Start / Abort | 10:21:11 approval.required | approval/input form | -| | | | -| Selected workflow mini card | Step group: finalize | [Workflow Snapshot] | -| | 10:21:20 run.finished | group/source | -| Recent / Presets | | primitives | -| | sticky filter + live scroll + selected row detail | | -| | | [Actor Snapshot] | -| | | updatedAt | -| | | lastOutput preview | -+------------------------------+------------------------------------------------------+----------------------+ -``` - -### 2.2 移动版草图 - -```text -+----------------------------------+ -| Header + Status strip | -+----------------------------------+ -| Primary action | -| Start run / Abort | -+----------------------------------+ -| Trace | -| [Timeline | Messages | Events] | -| full height main area | -+----------------------------------+ -| Bottom sheet tabs | -| Compose | Summary | Interaction | -+----------------------------------+ -``` - -移动端原则: - -- `Trace` 永远优先 -- `Launch rail` 和 `Inspector` 进入底部抽屉或分段页签 -- 不保留三栏 - -## 3. 信息分层 - -### 3.1 第一层:始终可见 - -- run 状态 -- runId -- elapsed -- workflow -- transport -- pending interaction - -这一层用 `status strip / metric pills`,不再单独占用 `Metric HUD` 卡片。 - -### 3.2 第二层:主任务区 - -- timeline -- messages -- events - -这是用户在 run 中最常看的区域,应该占据页面中心和主要高度。 - -### 3.3 第三层:上下文辅助 - -- workflow profile -- actor snapshot -- latest message preview -- recent runs -- presets - -这些都不应该和 trace 抢主舞台。 - -### 3.4 第四层:操作性内容 - -- resume -- signal -- approval - -这类内容只在存在 pending interaction 时高亮,否则收起。 - -## 4. 卡片规则 - -统一收敛为三类卡片。 - -### 4.1 Metric Pill - -- 高度:`64-72` -- 内容:`label + value + status dot` -- 数量:最多 `6` -- 不出现大段描述 - -用于: - -- status -- messages -- events -- active steps -- transport -- pending interaction - -### 4.2 Summary Card - -- 高度:`160-220` -- 只放 `3-5` 个关键字段 -- 最多一段两行摘要 -- tag 最多显示 `3` 个,剩余 `+N` - -用于: - -- workflow snapshot -- run summary -- actor snapshot - -### 4.3 Detail Panel - -- 高度:`fill` -- 允许滚动 -- 承载 timeline / messages / events / forms - -用于: - -- trace 主面板 -- compose 表单 -- interaction 表单 - -## 5. 模块拆分草图 - -建议把当前 `RunsPage` 拆成下面几个稳定子模块。 - -```text -RunsPage -├── RunsStatusStrip -├── RunsLaunchRail -│ ├── RunsComposeForm -│ ├── RunsRecentList -│ └── RunsPresetList -├── RunsTracePane -│ ├── RunsTimelineView -│ ├── RunsMessagesView -│ └── RunsEventsView -└── RunsInspectorPane - ├── RunsSummaryCard - ├── RunsInteractionCard - ├── RunsWorkflowCard - └── RunsActorSnapshotCard -``` - -## 6. 现有数据到新布局的映射 - -### 6.1 Status Strip - -直接复用现有状态数据: - -- `session.status` -- `session.runId` -- `commandId` -- `elapsedLabel` -- `workflowName` -- `activeTransport` -- `hasPendingInteraction` -- `session.messages.length` -- `session.events.length` -- `session.activeSteps.size` - -### 6.2 Launch Rail - -直接复用现有左侧内容: - -- `Compose` -- `Recent` -- `Presets` -- `selectedWorkflowRecord` - -但把“workflow profile”从大 `ProDescriptions` 改成一个简洁 mini card。 - -### 6.3 Trace Pane - -复用现有: - -- `eventRows` -- `session.messages` -- `runFocus` -- `latestStepRequest` -- `waitingSignal` - -建议在 `Timeline` 里按 `stepId` 分组,降低流式信息的碎片感。 - -### 6.4 Inspector - -复用现有: - -- `runSummaryRecord` -- `humanInputRecord` -- `waitingSignalRecord` -- `selectedWorkflowDetails` -- `actorSnapshotQuery.data` - -但只显示精简字段,完整信息用 drawer。 - -## 7. 视觉细节 - -### 7.1 节奏 - -- 页面主 gap 统一 `12` -- 卡片内 gap 统一 `12` -- summary 卡片正文上下 padding 统一 `16` -- 同一区域不混用 `12 / 16 / 20 / 24` - -### 7.2 文案 - -- 卡片标题尽量 1 到 2 个词 -- 描述句最多两行 -- 避免在首屏出现大段 `extra` 说明 - -### 7.3 标签 - -- `Workflow / Transport / Pending / Primitive` 这种强语义保留 tag -- `RunId / ActorId / CommandId` 改为 copyable code row,不用 tag -- 同类 tag 颜色固定,不同区域不要重复换色 - -### 7.4 高度 - -- 页面主容器继续 full-height -- 取消“底部 30vh Console” -- 改为“中间主 trace 填满剩余高度” -- 左右栏跟随主区域等高 - -## 8. 第一版可落地方案 - -不改后端、不改数据模型的前提下,可以先做一版低风险重构。 - -### Phase 1 - -- 删掉独立 `Metric HUD` 卡片,改为 `Status Strip` -- 把底部 `Console` 提升为中间主区域 -- 把 `Live overview + Workflow profile` 改成右侧 `Inspector` -- 维持 `Compose / Recent / Presets` 左侧 rail 不变 - -### Phase 2 - -- 为 `Timeline` 增加 `step group` -- 为 `Messages / Events` 增加选中态与 detail drawer -- 为 `Inspector` 增加折叠区块 - -### Phase 3 - -- 根据 trace 类型增加视觉层次 -- `assistant`、`step.request`、`approval.required`、`run.finished` 用不同密度的 row 模板 - -## 9. 组件实现建议 - -优先新增组件,而不是继续把逻辑堆回 `runs/index.tsx`: - -- `src/pages/runs/components/RunsStatusStrip.tsx` -- `src/pages/runs/components/RunsLaunchRail.tsx` -- `src/pages/runs/components/RunsTracePane.tsx` -- `src/pages/runs/components/RunsInspectorPane.tsx` -- `src/pages/runs/components/RunsSummaryCard.tsx` -- `src/pages/runs/components/RunsInteractionCard.tsx` -- `src/pages/runs/components/RunsWorkflowCard.tsx` -- `src/pages/runs/components/RunsActorSnapshotCard.tsx` - -样式建议新增到: - -- `src/pages/runs/runsWorkbenchLayout.ts` - -避免继续把布局 token 和字段列定义混在 `runWorkbenchConfig.tsx`。 - -## 10. 评审结论 - -这版草图的核心不是“做得更炫”,而是把 `Runs` 页从: - -- 多个同权卡片并列的信息工作台 - -改成: - -- 以 `Run trace` 为中心,左右辅助的运行控制台 - -这更符合 Aevatar 当前产品语义: - -- 左边启动 -- 中间观察执行 -- 右边处理上下文和人工交互 - -而不是让用户在首屏同时阅读五六块大卡片。 diff --git a/apps/aevatar-console-web/jest.config.ts b/apps/aevatar-console-web/jest.config.ts index d624a2d4..a7f77891 100644 --- a/apps/aevatar-console-web/jest.config.ts +++ b/apps/aevatar-console-web/jest.config.ts @@ -1,77 +1,142 @@ import path from 'node:path'; import { createConfig } from '@umijs/max/test'; -const baseConfig = createConfig({ - target: 'browser', -}); - const rootDir = __dirname; const resolveFromRoot = (...segments: string[]): string => path.join(rootDir, ...segments); -const moduleNameMapper = { - ...(baseConfig.moduleNameMapper || {}), - '^@ant-design/icons$': resolveFromRoot( - 'tests', - 'mocks', - 'antDesignIcons.js', - ), - '^@monaco-editor/react$': resolveFromRoot( - 'tests', - 'mocks', - 'monacoEditor.tsx', - ), - '^@ant-design/pro-components$': resolveFromRoot( - 'tests', - 'mocks', - 'proComponents.tsx', - ), - '^antd/es/(.*)$': resolveFromRoot('node_modules', 'antd', 'lib', '$1'), - '^@ant-design/icons/es/(.*)$': resolveFromRoot( - 'node_modules', - '@ant-design', - 'icons', - 'lib', - '$1', - ), - '^@rc-component/([^/]+)/es/(.*)$': resolveFromRoot( - 'node_modules', - '@rc-component', - '$1', - 'lib', - '$2', - ), - '^rc-([^/]+)/es/(.*)$': resolveFromRoot( - 'node_modules', - 'rc-$1', - 'lib', - '$2', - ), - '^@/(.*)$': resolveFromRoot('src', '$1'), - '^@$': resolveFromRoot('src'), - '^@@/(.*)$': resolveFromRoot('src', '.umi', '$1'), - '^@@$': resolveFromRoot('src', '.umi'), - '^@@test/(.*)$': resolveFromRoot('src', '.umi-test', '$1'), - '^@@test$': resolveFromRoot('src', '.umi-test'), -}; +function buildModuleNameMapper(baseModuleNameMapper?: Record) { + return { + ...(baseModuleNameMapper || {}), + '^@ant-design/icons$': resolveFromRoot( + 'tests', + 'mocks', + 'antDesignIcons.js', + ), + '^@monaco-editor/react$': resolveFromRoot( + 'tests', + 'mocks', + 'monacoEditor.tsx', + ), + '^@ant-design/pro-components$': resolveFromRoot( + 'tests', + 'mocks', + 'proComponents.tsx', + ), + '^antd/es/(.*)$': resolveFromRoot('node_modules', 'antd', 'lib', '$1'), + '^@ant-design/icons/es/(.*)$': resolveFromRoot( + 'node_modules', + '@ant-design', + 'icons', + 'lib', + '$1', + ), + '^@rc-component/([^/]+)/es/(.*)$': resolveFromRoot( + 'node_modules', + '@rc-component', + '$1', + 'lib', + '$2', + ), + '^rc-([^/]+)/es/(.*)$': resolveFromRoot( + 'node_modules', + 'rc-$1', + 'lib', + '$2', + ), + '^@/(.*)$': resolveFromRoot('src', '$1'), + '^@$': resolveFromRoot('src'), + '^@@/(.*)$': resolveFromRoot('src', '.umi', '$1'), + '^@@$': resolveFromRoot('src', '.umi'), + '^@@test/(.*)$': resolveFromRoot('src', '.umi-test', '$1'), + '^@@test$': resolveFromRoot('src', '.umi-test'), + }; +} + +function createProjectConfig(target: 'browser' | 'node') { + const { + testTimeout: _ignoredTestTimeout, + watchman: _ignoredWatchman, + ...baseConfig + } = createConfig({ + target, + }); + + return { + ...baseConfig, + moduleNameMapper: buildModuleNameMapper( + baseConfig.moduleNameMapper as Record | undefined, + ), + openHandlesTimeout: 5000, + rootDir, + roots: ['/src', '/tests'], + transformIgnorePatterns: ['/node_modules/(?!.*(?:lodash-es)/)'], + }; +} + +const browserProjectConfig = createProjectConfig('browser'); +const nodeProjectConfig = createProjectConfig('node'); + +const nodeTestFiles = [ + '/src/modules/studio/scripts/floatingLayout.test.ts', + '/src/pages/MissionControl/runtimeAdapter.test.ts', + '/src/pages/actors/actorPresentation.test.ts', + '/src/pages/governance/components/governanceQuery.test.ts', + '/src/pages/runs/runEventPresentation.test.ts', + '/src/pages/scopes/components/resolvedScope.test.ts', + '/src/pages/scopes/components/scopeQuery.test.ts', + '/src/pages/services/components/serviceQuery.test.ts', + '/src/pages/workflows/workflowPresentation.test.ts', + '/src/shared/agui/customEventData.test.ts', + '/src/shared/agui/sseFrameNormalizer.test.ts', + '/src/shared/config/proxyConfig.test.ts', + '/src/shared/datetime/dateTime.test.ts', + '/src/shared/playground/stepSummary.test.ts', + '/src/shared/studio/document.test.ts', + '/src/shared/studio/navigation.test.ts', + '/src/shared/ui/aevatarWorkbench.test.ts', + '/src/shared/workflows/catalogVisibility.test.ts', +] as const; + +const browserIgnoredTestPatterns = nodeTestFiles.map((testPath) => + testPath + .replace('/', '') + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), +); const config: Record = { - ...baseConfig, - moduleNameMapper, - openHandlesTimeout: 5000, - roots: ['/src', '/tests'], - setupFiles: [...(baseConfig.setupFiles || []), './tests/setupTests.jsx'], - setupFilesAfterEnv: [ - ...(baseConfig.setupFilesAfterEnv || []), - './tests/setupAfterEnv.ts', + testTimeout: 30000, + projects: [ + { + ...nodeProjectConfig, + displayName: 'node', + testMatch: [...nodeTestFiles], + }, + { + ...browserProjectConfig, + displayName: 'jsdom', + setupFiles: [ + ...((browserProjectConfig.setupFiles as string[] | undefined) || []), + './tests/setupTests.jsx', + ], + setupFilesAfterEnv: [ + ...((browserProjectConfig.setupFilesAfterEnv as string[] | undefined) || []), + './tests/setupAfterEnv.ts', + ], + testEnvironmentOptions: { + ...(((browserProjectConfig.testEnvironmentOptions as Record< + string, + unknown + >) || {})), + url: 'http://localhost:8000', + }, + testPathIgnorePatterns: [ + ...((browserProjectConfig.testPathIgnorePatterns as string[] | undefined) || []), + ...browserIgnoredTestPatterns, + ], + }, ], - testEnvironmentOptions: { - ...((baseConfig.testEnvironmentOptions as Record) || {}), - url: 'http://localhost:8000', - }, - transformIgnorePatterns: ['/node_modules/(?!.*(?:lodash-es)/)'], - watchman: false, }; export default config; diff --git a/apps/aevatar-console-web/package.json b/apps/aevatar-console-web/package.json index cd00b50b..153e698c 100644 --- a/apps/aevatar-console-web/package.json +++ b/apps/aevatar-console-web/package.json @@ -16,6 +16,8 @@ "start": "PORT=\"${PORT:-${AEVATAR_CONSOLE_FRONTEND_PORT:-5173}}\" UMI_ENV=dev max dev", "start:dev": "PORT=\"${PORT:-${AEVATAR_CONSOLE_FRONTEND_PORT:-5173}}\" UMI_ENV=dev MOCK=none max dev", "test": "jest", + "test:unit": "jest --selectProjects node", + "test:ui": "jest --selectProjects jsdom", "test:coverage": "npm run jest -- --coverage", "tsc": "tsc --noEmit" }, diff --git a/apps/aevatar-console-web/src/global.less b/apps/aevatar-console-web/src/global.less index 0d0244bd..0678dafe 100644 --- a/apps/aevatar-console-web/src/global.less +++ b/apps/aevatar-console-web/src/global.less @@ -101,6 +101,256 @@ body { -moz-osx-font-smoothing: grayscale; } +.scope-chat-llm-bar { + align-items: center; + display: inline-flex; + gap: 10px; + min-width: 0; + position: relative; +} + +.scope-chat-llm-trigger { + align-items: center; + background: + linear-gradient(180deg, rgba(var(--accent-rgb), 0.07) 0%, rgba(var(--accent-rgb), 0.12) 100%); + border: 1px solid rgba(var(--accent-rgb), 0.12); + border-radius: 14px; + box-shadow: + 0 6px 14px rgba(15, 23, 42, 0.03), + 0 1px 0 rgba(255, 255, 255, 0.7) inset; + color: #475569; + cursor: pointer; + display: inline-flex; + font-size: 14px; + font-weight: 600; + gap: 10px; + height: 38px; + max-width: 240px; + min-width: 0; + padding: 0 14px; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.scope-chat-llm-trigger:hover:not(:disabled) { + border-color: rgba(var(--accent-rgb), 0.2); + box-shadow: + 0 8px 18px rgba(15, 23, 42, 0.05), + 0 1px 0 rgba(255, 255, 255, 0.7) inset; +} + +.scope-chat-llm-trigger:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.scope-chat-llm-trigger-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scope-chat-llm-chevron { + color: #94a3b8; +} + +.scope-chat-llm-inline-route { + color: #94a3b8; + font-size: 12px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scope-chat-llm-panel { + background: #ffffff; + border: 1px solid rgba(var(--accent-rgb), 0.12); + border-radius: 18px; + box-shadow: + 0 18px 42px rgba(15, 23, 42, 0.1), + 0 1px 0 rgba(255, 255, 255, 0.82) inset; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.scope-chat-llm-reset { + background: rgba(var(--accent-rgb), 0.06); + border: 1px solid rgba(var(--accent-rgb), 0.12); + border-radius: 999px; + color: var(--accent-text); + cursor: pointer; + font-size: 11px; + font-weight: 600; + height: 28px; + padding: 0 10px; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease; +} + +.scope-chat-llm-reset:hover:not(:disabled) { + background: rgba(var(--accent-rgb), 0.1); + border-color: rgba(var(--accent-rgb), 0.2); +} + +.scope-chat-llm-panel-header { + align-items: center; + display: flex; + justify-content: space-between; + padding: 12px 14px 8px; +} + +.scope-chat-llm-panel-title { + color: #64748b; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.scope-chat-llm-search { + align-items: center; + border-bottom: 1px solid rgba(var(--accent-rgb), 0.08); + border-top: 1px solid rgba(var(--accent-rgb), 0.08); + display: flex; + gap: 10px; + padding: 12px 14px; +} + +.scope-chat-llm-search-icon { + color: #94a3b8; + flex-shrink: 0; +} + +.scope-chat-llm-search-input { + background: transparent; + border: none; + color: #1f2937; + font-size: 14px; + font-weight: 500; + outline: none; + padding: 0; + width: 100%; +} + +.scope-chat-llm-search-input::placeholder { + color: #94a3b8; +} + +.scope-chat-llm-route-row { + align-items: center; + display: flex; + gap: 12px; + padding: 12px 14px 10px; +} + +.scope-chat-llm-route-label { + color: #94a3b8; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.scope-chat-llm-route-select { + appearance: none; + background: rgba(var(--accent-rgb), 0.05); + border: 1px solid rgba(var(--accent-rgb), 0.12); + border-radius: 12px; + color: #334155; + flex: 1 1 auto; + font-size: 13px; + font-weight: 600; + height: 36px; + min-width: 0; + outline: none; + padding: 0 12px; +} + +.scope-chat-llm-options { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + padding: 0 8px 8px; +} + +.scope-chat-llm-group { + padding: 6px 0 2px; +} + +.scope-chat-llm-group-label { + color: #64748b; + font-size: 12px; + font-weight: 700; + padding: 0 10px 8px; +} + +.scope-chat-llm-option { + align-items: center; + background: transparent; + border: 1px solid transparent; + border-radius: 12px; + color: #1f2937; + cursor: pointer; + display: flex; + justify-content: space-between; + min-height: 44px; + padding: 0 12px; + text-align: left; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + color 0.2s ease; + width: 100%; +} + +.scope-chat-llm-option:hover { + background: rgba(var(--accent-rgb), 0.08); +} + +.scope-chat-llm-option.is-active { + background: rgba(var(--accent-rgb), 0.1); + border-color: rgba(var(--accent-rgb), 0.14); +} + +.scope-chat-llm-option--manual { + margin-bottom: 6px; +} + +.scope-chat-llm-option-badge { + background: rgba(var(--accent-rgb), 0.08); + border-radius: 999px; + color: var(--accent-text); + font-size: 11px; + font-weight: 700; + margin-left: 12px; + padding: 4px 8px; +} + +.scope-chat-llm-option-main { + align-items: center; + display: inline-flex; + gap: 10px; + min-width: 0; +} + +.scope-chat-llm-option-main span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scope-chat-llm-empty { + color: #94a3b8; + font-size: 13px; + padding: 12px 10px 14px; +} + .workflow-library-table { .ant-pro-card { box-shadow: none; @@ -348,6 +598,156 @@ ol { color: #201f1d; } +.settings-sidebar { + display: flex; + flex-direction: column; + min-height: 0; + padding: 28px 20px; + border-right: 1px solid #ece8e2; + background: linear-gradient( + 180deg, + rgba(250, 248, 244, 0.94) 0%, + rgba(245, 242, 237, 0.94) 100% + ); +} + +.studio-shell[data-color-mode='dark'] .settings-sidebar { + border-right-color: #223047; + background: linear-gradient( + 180deg, + rgba(10, 17, 30, 0.98) 0%, + rgba(12, 20, 36, 0.98) 100% + ); +} + +.settings-nav-button { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 20px; + border: 1px solid transparent; + transition: all 0.18s ease; + text-align: left; +} + +.settings-nav-button:hover { + background: rgba(255, 255, 255, 0.72); + border-color: #e6e0d7; +} + +.studio-shell[data-color-mode='dark'] .settings-nav-button:hover { + background: rgba(22, 34, 54, 0.9); + border-color: #2a3a52; +} + +.settings-nav-button.active { + border-color: var(--accent-border); + background: linear-gradient( + 180deg, + var(--accent-soft-start) 0%, + var(--accent-soft-end) 100% + ); + box-shadow: 0 16px 34px var(--accent-shadow); +} + +.settings-nav-icon { + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 14px; + background: white; + color: var(--accent-text); + box-shadow: 0 10px 24px rgba(17, 24, 39, 0.06); + flex-shrink: 0; +} + +.studio-shell[data-color-mode='dark'] .settings-nav-icon { + border-color: #33465f; + background: #111b2d; + color: #dbeafe; +} + +.settings-section-card { + border: 1px solid #ede8df; + border-radius: 28px; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.96) 0%, + rgba(250, 248, 244, 0.92) 100% + ); + box-shadow: 0 20px 44px rgba(17, 24, 39, 0.06); + padding: 24px; +} + +.studio-shell[data-color-mode='dark'] .settings-section-card { + border-color: #2a3a52; + background: linear-gradient( + 180deg, + rgba(15, 23, 42, 0.96) 0%, + rgba(17, 27, 45, 0.96) 100% + ); + box-shadow: 0 18px 38px rgba(2, 6, 23, 0.24); +} + +.settings-status-card { + border: 1px solid #e6e1d8; + border-radius: 22px; + background: #fff; + padding: 16px 18px; +} + +.studio-shell[data-color-mode='dark'] .settings-status-card { + border-color: #2a3a52; + background: rgba(15, 23, 42, 0.92); +} + +.settings-status-card.success { + border-color: rgba(34, 197, 94, 0.18); + background: rgba(240, 253, 244, 0.92); +} + +.settings-status-card.error { + border-color: rgba(239, 68, 68, 0.18); + background: rgba(254, 242, 242, 0.92); +} + +.settings-status-card.testing { + border-color: rgba(var(--accent-rgb), 0.18); + background: rgba(239, 246, 255, 0.92); +} + +.settings-status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 26px; + padding: 0 10px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.settings-status-pill.success { + background: rgba(34, 197, 94, 0.12); + color: #15803d; +} + +.settings-status-pill.error { + background: rgba(239, 68, 68, 0.12); + color: #dc2626; +} + +.settings-status-pill.testing { + background: rgba(var(--accent-rgb), 0.12); + color: var(--accent-text); +} + .field-label { display: inline-block; font-size: 11px; diff --git a/apps/aevatar-console-web/src/pages/chat/chatAdvancedConsole.tsx b/apps/aevatar-console-web/src/pages/chat/chatAdvancedConsole.tsx new file mode 100644 index 00000000..e3bad738 --- /dev/null +++ b/apps/aevatar-console-web/src/pages/chat/chatAdvancedConsole.tsx @@ -0,0 +1,2685 @@ +import { AGUIEventType, CustomEventName } from "@aevatar-react-sdk/types"; +import { Alert, Empty, Space, Typography } from "antd"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { parseBackendSSEStream } from "@/shared/agui/sseFrameNormalizer"; +import { authFetch } from "@/shared/auth/fetch"; +import { runtimeActorsApi } from "@/shared/api/runtimeActorsApi"; +import { runtimeRunsApi } from "@/shared/api/runtimeRunsApi"; +import { scopesApi } from "@/shared/api/scopesApi"; +import { servicesApi } from "@/shared/api/servicesApi"; +import { scopeRuntimeApi } from "@/shared/api/scopeRuntimeApi"; +import { formatDateTime } from "@/shared/datetime/dateTime"; +import type { ServiceCatalogSnapshot } from "@/shared/models/services"; +import type { + WorkflowActorGraphEnrichedSnapshot, + WorkflowActorSnapshot, +} from "@/shared/models/runtime/actors"; +import type { ScopeServiceRunAuditSnapshot } from "@/shared/models/runtime/scopeServices"; +import { history } from "@/shared/navigation/history"; +import { + buildRuntimeExplorerHref, + buildRuntimeRunsHref, +} from "@/shared/navigation/runtimeRoutes"; +import { saveObservedRunSessionPayload } from "@/shared/runs/draftRunSession"; +import { + buildScopeConsoleServiceOptions, + extractRuntimeInvokeReceipt, + scopeServiceAppId, + scopeServiceNamespace, +} from "@/shared/runs/scopeConsole"; +import { studioApi } from "@/shared/studio/api"; +import { AevatarContextDrawer } from "@/shared/ui/aevatarPageShells"; +import { + applyRuntimeEvent, + createRuntimeEventAccumulator, + isRawObserved, +} from "./chatEventAdapter"; +import { DebugPanel } from "./chatPresentation"; +import type { RuntimeEvent } from "./chatTypes"; +import { + buildTimelineRows, + filterTimelineRows, +} from "../actors/actorPresentation"; +import { + buildTimelineBlockingSummary, + describeActorCompletionStatus, +} from "./runtimeInspector"; + +type ConsoleTab = "query" | "execute" | "timeline" | "raw"; +type QueryTarget = "binding" | "services" | "workflows" | "actor"; + +type ConsoleFlow = { + badge?: string; + description: string; + group: "developer" | "operate" | "understand"; + id: ConsoleTab; + label: string; + priority: "primary" | "secondary"; +}; + +type ChatAdvancedConsoleProps = { + defaultServiceId?: string; + onClose: () => void; + onEnsureNyxIdBound?: () => Promise; + onTimelineActionResult?: (input: { + action: "resume" | "approve" | "reject" | "signal"; + actorId: string; + commandId?: string; + content: string; + error?: string; + kind: "human_input" | "human_approval" | "wait_signal"; + runId: string; + serviceId: string; + signalName?: string; + stepId: string; + success: boolean; + }) => void; + open: boolean; + scopeId: string; + services: readonly ServiceCatalogSnapshot[]; + sessionActorId?: string; +}; + +type ExecuteLaunchContext = { + endpointId: string; + endpointKind: string; + payloadBase64: string; + payloadTypeUrl: string; + prompt: string; + serviceId: string; +}; + +const monoFontFamily = + "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace"; + +const queryTargets: { description: string; id: QueryTarget; label: string }[] = [ + { + description: "Current default binding for this scope.", + id: "binding", + label: "Scope Binding", + }, + { + description: "All published services currently visible to this scope.", + id: "services", + label: "Services", + }, + { + description: "Workflow assets currently deployed into this scope.", + id: "workflows", + label: "Workflows", + }, + { + description: "Inspect a specific actor by its runtime ID.", + id: "actor", + label: "Actor Snapshot", + }, +]; + +const consoleFlows: readonly ConsoleFlow[] = [ + { + badge: "Recommended first", + description: + "Check the scope binding, published services, deployed workflows, or inspect an actor directly.", + group: "understand", + id: "query", + label: "Query", + priority: "primary", + }, + { + description: + "Inspect actor state, timeline evidence, graph topology, and any blocking gate that needs operator action.", + group: "understand", + id: "timeline", + label: "Timeline", + priority: "secondary", + }, + { + badge: "Common next step", + description: + "Launch a service endpoint, capture the run receipt, and continue into Runs or Explorer when needed.", + group: "operate", + id: "execute", + label: "Execute", + priority: "primary", + }, + { + badge: "Expert", + description: + "Send direct API requests only when you need low-level integration or protocol debugging.", + group: "developer", + id: "raw", + label: "Raw API", + priority: "secondary", + }, +]; + +const drawerSectionStyle: React.CSSProperties = { + background: "#ffffff", + border: "1px solid #e7e5e4", + borderRadius: 16, + display: "flex", + flexDirection: "column", + gap: 12, + padding: 16, +}; + +const fieldLabelStyle: React.CSSProperties = { + color: "#6b7280", + fontSize: 12, + fontWeight: 600, +}; + +const monoBlockStyle: React.CSSProperties = { + background: "#fafaf8", + border: "1px solid #e7e5e4", + borderRadius: 12, + fontFamily: monoFontFamily, + fontSize: 12, + margin: 0, + maxHeight: 320, + overflow: "auto", + padding: 14, + whiteSpace: "pre-wrap", +}; + +const inputStyle: React.CSSProperties = { + background: "#ffffff", + border: "1px solid #d6d3d1", + borderRadius: 10, + color: "#111827", + fontSize: 13, + minHeight: 40, + outline: "none", + padding: "10px 12px", + width: "100%", +}; + +const textareaStyle: React.CSSProperties = { + ...inputStyle, + fontFamily: monoFontFamily, + minHeight: 120, + resize: "vertical", +}; + +const selectStyle: React.CSSProperties = { + ...inputStyle, + fontFamily: monoFontFamily, +}; + +const actionButtonStyle = ( + tone: "primary" | "secondary", + disabled = false +): React.CSSProperties => ({ + background: tone === "primary" ? "#111827" : "#ffffff", + border: `1px solid ${tone === "primary" ? "#111827" : "#d6d3d1"}`, + borderRadius: 10, + color: tone === "primary" ? "#ffffff" : "#4b5563", + cursor: disabled ? "not-allowed" : "pointer", + fontSize: 13, + fontWeight: 600, + opacity: disabled ? 0.45 : 1, + padding: "9px 14px", +}); + +function timelineStatusTone( + status: "processing" | "success" | "error" | "default" +): { background: string; color: string } { + switch (status) { + case "processing": + return { + background: "#eff6ff", + color: "#1d4ed8", + }; + case "success": + return { + background: "#ecfdf5", + color: "#047857", + }; + case "error": + return { + background: "#fef2f2", + color: "#dc2626", + }; + default: + return { + background: "#f5f5f4", + color: "#57534e", + }; + } +} + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function createResultPanel( + label: string, + value: string, + onCopy?: () => void +): React.ReactElement { + return ( +
+
+ {label} + {onCopy ? ( + + ) : null} +
+
{value}
+
+ ); +} + +function renderAuditPreviewCard( + title: string, + description: string, + stamp?: string | null, + keySuffix?: string +): React.ReactElement { + return ( +
+ {title} + + {description || "No detail"} + + {stamp ? ( + + {formatDateTime(stamp)} + + ) : null} +
+ ); +} + +function createObservedExecutionEvents(context: { + actorId?: string; + commandId?: string; + correlationId?: string; + runId?: string; +}): RuntimeEvent[] { + const events: RuntimeEvent[] = []; + + if (context.runId?.trim()) { + events.push({ + runId: context.runId.trim(), + threadId: + context.correlationId?.trim() || + context.commandId?.trim() || + context.runId.trim(), + timestamp: Date.now(), + type: AGUIEventType.RUN_STARTED, + } as RuntimeEvent); + } + + if (context.actorId?.trim() || context.commandId?.trim()) { + events.push({ + name: CustomEventName.RunContext, + timestamp: Date.now(), + type: AGUIEventType.CUSTOM, + value: { + actorId: context.actorId?.trim() || undefined, + commandId: context.commandId?.trim() || undefined, + }, + } as RuntimeEvent); + } + + return events; +} + +export function ChatAdvancedConsole({ + defaultServiceId, + onClose, + onEnsureNyxIdBound, + onTimelineActionResult, + open, + scopeId, + services, + sessionActorId, +}: ChatAdvancedConsoleProps): React.ReactElement { + const executeAbortRef = useRef(null); + + const consoleServices = useMemo( + () => + buildScopeConsoleServiceOptions(services, defaultServiceId, { + sortBy: "displayName", + }), + [defaultServiceId, services] + ); + const [activeTab, setActiveTab] = useState("query"); + const [queryTarget, setQueryTarget] = useState("binding"); + const [queryActorId, setQueryActorId] = useState(""); + const [queryLoading, setQueryLoading] = useState(false); + const [queryResult, setQueryResult] = useState(null); + const [timelineActorInput, setTimelineActorInput] = useState(""); + const [timelineLoading, setTimelineLoading] = useState(false); + const [timelineError, setTimelineError] = useState(""); + const [timelineSnapshot, setTimelineSnapshot] = + useState(null); + const [timelineGraph, setTimelineGraph] = + useState(null); + const [timelineSearch, setTimelineSearch] = useState(""); + const [timelineOnlyErrors, setTimelineOnlyErrors] = useState(false); + const [timelineSelectedStage, setTimelineSelectedStage] = useState(""); + const [timelineItems, setTimelineItems] = useState< + ReturnType + >([]); + const [timelineRefreshTick, setTimelineRefreshTick] = useState(0); + const [timelineSelectedKey, setTimelineSelectedKey] = useState( + null + ); + const [timelineActionInput, setTimelineActionInput] = useState(""); + const [timelineActionLoading, setTimelineActionLoading] = useState(false); + const [timelineActionError, setTimelineActionError] = useState(""); + const [timelineActionNotice, setTimelineActionNotice] = useState(""); + + const [executeServiceId, setExecuteServiceId] = useState(defaultServiceId || ""); + const [executeEndpointId, setExecuteEndpointId] = useState("chat"); + const [executePrompt, setExecutePrompt] = useState(""); + const [executePayloadTypeUrl, setExecutePayloadTypeUrl] = useState(""); + const [executePayloadBase64, setExecutePayloadBase64] = useState(""); + const [executeEvents, setExecuteEvents] = useState([]); + const [executeAssistantText, setExecuteAssistantText] = useState(""); + const [executeResponseText, setExecuteResponseText] = useState(""); + const [executeActorId, setExecuteActorId] = useState(""); + const [executeCommandId, setExecuteCommandId] = useState(""); + const [executeCorrelationId, setExecuteCorrelationId] = useState(""); + const [executeRunId, setExecuteRunId] = useState(""); + const [executeAuditSnapshot, setExecuteAuditSnapshot] = + useState(null); + const [executeAuditLoading, setExecuteAuditLoading] = useState(false); + const [executeAuditError, setExecuteAuditError] = useState(""); + const [executeLaunchContext, setExecuteLaunchContext] = + useState(null); + const [executeStatus, setExecuteStatus] = useState< + "idle" | "running" | "success" | "error" + >("idle"); + const [executeError, setExecuteError] = useState(""); + + const [rawMethod, setRawMethod] = useState("GET"); + const [rawPath, setRawPath] = useState(""); + const [rawBody, setRawBody] = useState(""); + const [rawLoading, setRawLoading] = useState(false); + const [rawResult, setRawResult] = useState<{ + body: string; + status: number; + statusText: string; + } | null>(null); + + const activeExecuteService = + consoleServices.find((service) => service.serviceId === executeServiceId) ?? + consoleServices[0] ?? + null; + const activeExecuteEndpoint = + activeExecuteService?.endpoints.find( + (endpoint) => endpoint.endpointId === executeEndpointId + ) ?? + activeExecuteService?.endpoints[0] ?? + null; + const effectiveTimelineServiceId = + executeLaunchContext?.serviceId || defaultServiceId || executeServiceId || ""; + const effectiveTimelineActorId = ( + timelineActorInput.trim() || + executeActorId.trim() || + sessionActorId?.trim() || + queryActorId.trim() + ).trim(); + const timelineRows = useMemo( + () => + filterTimelineRows(timelineItems, { + errorsOnly: timelineOnlyErrors, + eventTypes: [], + query: timelineSearch, + stages: timelineSelectedStage ? [timelineSelectedStage] : [], + stepTypes: [], + }), + [timelineItems, timelineOnlyErrors, timelineSearch, timelineSelectedStage] + ); + const timelineStageOptions = useMemo( + () => + [...new Set(timelineItems.map((item) => item.stage).filter(Boolean))].sort( + (left, right) => left.localeCompare(right) + ), + [timelineItems] + ); + const selectedTimelineRow = useMemo(() => { + if (!timelineRows.length) { + return null; + } + + return ( + timelineRows.find((item) => item.key === timelineSelectedKey) || + timelineRows[0] + ); + }, [timelineRows, timelineSelectedKey]); + const timelineBlockingSummary = useMemo( + () => buildTimelineBlockingSummary(timelineItems), + [timelineItems] + ); + const consoleFlowGroups = useMemo( + () => [ + { + description: "Inspect the current scope and understand runtime state.", + flows: consoleFlows.filter((flow) => flow.group === "understand"), + id: "understand", + label: "Understand", + }, + { + description: "Run work, inspect the receipt, and act on runtime gates.", + flows: consoleFlows.filter((flow) => flow.group === "operate"), + id: "operate", + label: "Operate", + }, + { + description: "Drop to direct API calls when you need low-level debugging.", + flows: consoleFlows.filter((flow) => flow.group === "developer"), + id: "developer", + label: "Developer", + }, + ], + [] + ); + const activeConsoleFlow = useMemo( + () => consoleFlows.find((flow) => flow.id === activeTab) || null, + [activeTab] + ); + + const rawShortcuts = useMemo( + () => [ + { + label: "Binding", + method: "GET", + path: `/scopes/${scopeId}/binding`, + }, + { + label: "Services", + method: "GET", + path: `/services?tenantId=${scopeId}&appId=${scopeServiceAppId}&namespace=${scopeServiceNamespace}&take=20`, + }, + { + label: "Workflows", + method: "GET", + path: `/scopes/${scopeId}/workflows`, + }, + activeExecuteService + ? { + label: "Runs", + method: "GET", + path: `/scopes/${scopeId}/services/${activeExecuteService.serviceId}/runs?take=10`, + } + : null, + { + label: "Auth Session", + method: "GET", + path: "/auth/me", + }, + ].filter(Boolean) as Array<{ label: string; method: string; path: string }>, + [activeExecuteService, scopeId] + ); + + useEffect(() => { + if (!open) { + return; + } + + if (!queryActorId && sessionActorId) { + setQueryActorId(sessionActorId); + } + }, [open, queryActorId, sessionActorId]); + + useEffect(() => { + if (!consoleServices.length) { + setExecuteServiceId(""); + return; + } + + const preferredServiceId = + (defaultServiceId && + consoleServices.some((service) => service.serviceId === defaultServiceId) + ? defaultServiceId + : "") || + consoleServices[0].serviceId; + + if ( + !executeServiceId || + !consoleServices.some((service) => service.serviceId === executeServiceId) + ) { + setExecuteServiceId(preferredServiceId); + } + }, [consoleServices, defaultServiceId, executeServiceId]); + + useEffect(() => { + const defaultPath = scopeId ? `/scopes/${scopeId}/binding` : "/auth/me"; + setRawPath((current) => (current.trim() ? current : defaultPath)); + }, [scopeId]); + + useEffect(() => { + if (!activeExecuteService) { + setExecuteEndpointId(""); + return; + } + + if ( + !executeEndpointId || + !activeExecuteService.endpoints.some( + (endpoint) => endpoint.endpointId === executeEndpointId + ) + ) { + setExecuteEndpointId(activeExecuteService.endpoints[0]?.endpointId || ""); + } + }, [activeExecuteService, executeEndpointId]); + + useEffect(() => { + setExecutePayloadTypeUrl(activeExecuteEndpoint?.requestTypeUrl || ""); + }, [activeExecuteEndpoint?.endpointId, activeExecuteEndpoint?.requestTypeUrl]); + + useEffect(() => { + if (!open || activeTab !== "timeline") { + return; + } + + if (!effectiveTimelineActorId) { + setTimelineError(""); + setTimelineSnapshot(null); + setTimelineGraph(null); + setTimelineItems([]); + setTimelineSelectedKey(null); + return; + } + + let cancelled = false; + setTimelineLoading(true); + setTimelineError(""); + + void Promise.all([ + runtimeActorsApi.getActorSnapshot(effectiveTimelineActorId), + runtimeActorsApi.getActorTimeline(effectiveTimelineActorId, { take: 40 }), + runtimeActorsApi.getActorGraphEnriched(effectiveTimelineActorId, { + depth: 2, + take: 40, + }), + ]) + .then(([snapshot, timeline, graph]) => { + if (cancelled) { + return; + } + + setTimelineSnapshot(snapshot); + setTimelineGraph(graph); + setTimelineItems(buildTimelineRows(timeline)); + }) + .catch((error) => { + if (cancelled) { + return; + } + + setTimelineSnapshot(null); + setTimelineGraph(null); + setTimelineItems([]); + setTimelineError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + if (!cancelled) { + setTimelineLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [activeTab, effectiveTimelineActorId, open, timelineRefreshTick]); + + useEffect(() => { + if (!timelineRows.length) { + setTimelineSelectedKey(null); + return; + } + + setTimelineSelectedKey((current) => + current && timelineRows.some((item) => item.key === current) + ? current + : timelineRows[0].key + ); + }, [timelineRows]); + + useEffect(() => { + setTimelineActionError(""); + setTimelineActionInput(""); + setTimelineActionNotice(""); + }, [timelineBlockingSummary?.kind, timelineBlockingSummary?.stepId]); + + useEffect( + () => () => { + executeAbortRef.current?.abort(); + }, + [] + ); + + const handleCopy = useCallback((value: string) => { + void navigator.clipboard?.writeText(value); + }, []); + + const handleQuerySubmit = useCallback(async () => { + if (!scopeId) { + return; + } + + setQueryLoading(true); + setQueryResult(null); + try { + let result: unknown; + switch (queryTarget) { + case "binding": + result = await studioApi.getScopeBinding(scopeId); + break; + case "services": + result = await servicesApi.listServices({ + appId: scopeServiceAppId, + namespace: scopeServiceNamespace, + take: 100, + tenantId: scopeId, + }); + break; + case "workflows": + result = await scopesApi.listWorkflows(scopeId); + break; + case "actor": + if (!queryActorId.trim()) { + setQueryResult(safeJson({ error: "Actor ID is required." })); + setQueryLoading(false); + return; + } + result = await runtimeActorsApi.getActorSnapshot(queryActorId.trim()); + break; + } + + setQueryResult(safeJson(result)); + } catch (error) { + setQueryResult( + safeJson({ + error: error instanceof Error ? error.message : String(error), + }) + ); + } finally { + setQueryLoading(false); + } + }, [queryActorId, queryTarget, scopeId]); + + const handleExecuteSubmit = useCallback(async () => { + if (!scopeId || !activeExecuteService || !activeExecuteEndpoint) { + return; + } + + executeAbortRef.current?.abort(); + const controller = new AbortController(); + executeAbortRef.current = controller; + + setExecuteAssistantText(""); + setExecuteActorId(""); + setExecuteAuditError(""); + setExecuteAuditLoading(false); + setExecuteAuditSnapshot(null); + setExecuteCommandId(""); + setExecuteCorrelationId(""); + setExecuteError(""); + setExecuteEvents([]); + const launchContext: ExecuteLaunchContext = { + endpointId: activeExecuteEndpoint.endpointId, + endpointKind: activeExecuteEndpoint.kind, + payloadBase64: executePayloadBase64.trim(), + payloadTypeUrl: executePayloadTypeUrl.trim(), + prompt: executePrompt.trim(), + serviceId: activeExecuteService.serviceId, + }; + setExecuteLaunchContext(launchContext); + setExecuteResponseText(""); + setExecuteRunId(""); + setExecuteStatus("running"); + + try { + if (activeExecuteService.kind === "nyxid-chat") { + await onEnsureNyxIdBound?.(); + } + + const isStreamingEndpoint = + activeExecuteEndpoint.kind === "chat" || + activeExecuteEndpoint.endpointId.trim() === "chat"; + + if (isStreamingEndpoint) { + const accumulator = createRuntimeEventAccumulator(); + const response = await runtimeRunsApi.streamEndpoint( + scopeId, + { + endpointId: activeExecuteEndpoint.endpointId, + prompt: executePrompt, + }, + controller.signal, + { + serviceId: activeExecuteService.serviceId, + } + ); + + for await (const event of parseBackendSSEStream(response, { + signal: controller.signal, + })) { + applyRuntimeEvent(accumulator, event); + setExecuteEvents([...accumulator.events]); + setExecuteAssistantText(accumulator.assistantText); + setExecuteActorId(accumulator.actorId); + setExecuteCommandId(accumulator.commandId); + setExecuteRunId(accumulator.runId); + setExecuteError(accumulator.errorText); + } + + setExecuteStatus(accumulator.errorText ? "error" : "success"); + return; + } + + const response = await runtimeRunsApi.invokeEndpoint( + scopeId, + { + endpointId: activeExecuteEndpoint.endpointId, + payloadBase64: executePayloadBase64.trim() || undefined, + payloadTypeUrl: executePayloadTypeUrl.trim() || undefined, + prompt: executePrompt, + }, + { + serviceId: activeExecuteService.serviceId, + } + ); + const { + actorId: responseActorId, + commandId: responseCommandId, + correlationId: responseCorrelationId, + runId: responseRunId, + } = extractRuntimeInvokeReceipt(response); + + setExecuteActorId(responseActorId); + setExecuteCommandId(responseCommandId); + setExecuteCorrelationId(responseCorrelationId); + setExecuteRunId(responseRunId); + setExecuteResponseText(safeJson(response)); + setExecuteStatus("success"); + } catch (error) { + if (controller.signal.aborted) { + setExecuteError("Execution stopped by operator."); + } else { + setExecuteError(error instanceof Error ? error.message : String(error)); + } + setExecuteStatus("error"); + } finally { + if (executeAbortRef.current === controller) { + executeAbortRef.current = null; + } + } + }, [ + activeExecuteEndpoint, + activeExecuteService, + executePayloadBase64, + executePayloadTypeUrl, + executePrompt, + onEnsureNyxIdBound, + scopeId, + ]); + + const handleOpenRuns = useCallback(() => { + if (!scopeId || !executeLaunchContext) { + return; + } + + const observedEvents = + executeEvents.length > 0 + ? executeEvents + : createObservedExecutionEvents({ + actorId: executeActorId, + commandId: executeCommandId, + correlationId: executeCorrelationId, + runId: executeRunId, + }); + const draftKey = + observedEvents.length > 0 + ? saveObservedRunSessionPayload({ + actorId: executeActorId || undefined, + commandId: executeCommandId || undefined, + endpointId: executeLaunchContext.endpointId, + endpointKind: executeLaunchContext.endpointKind as + | "chat" + | "command" + | undefined, + events: observedEvents, + payloadBase64: + executeLaunchContext.endpointKind !== "chat" + ? executeLaunchContext.payloadBase64 || undefined + : undefined, + payloadTypeUrl: + executeLaunchContext.endpointKind !== "chat" + ? executeLaunchContext.payloadTypeUrl || undefined + : undefined, + prompt: executeLaunchContext.prompt, + runId: executeRunId || undefined, + scopeId, + serviceOverrideId: executeLaunchContext.serviceId, + }) + : ""; + + history.push( + buildRuntimeRunsHref({ + actorId: executeActorId || undefined, + draftKey: draftKey || undefined, + endpointId: executeLaunchContext.endpointId, + endpointKind: executeLaunchContext.endpointKind, + payloadBase64: + executeLaunchContext.endpointKind !== "chat" + ? executeLaunchContext.payloadBase64 || undefined + : undefined, + payloadTypeUrl: + executeLaunchContext.endpointKind !== "chat" + ? executeLaunchContext.payloadTypeUrl || undefined + : undefined, + prompt: executeLaunchContext.prompt || undefined, + scopeId, + serviceId: executeLaunchContext.serviceId, + }) + ); + }, [ + executeActorId, + executeCommandId, + executeCorrelationId, + executeEvents, + executeLaunchContext, + executeRunId, + scopeId, + ]); + + const handleOpenExplorer = useCallback(() => { + if (!scopeId) { + return; + } + + history.push( + buildRuntimeExplorerHref({ + actorId: effectiveTimelineActorId || undefined, + runId: executeRunId || undefined, + scopeId, + serviceId: executeLaunchContext?.serviceId, + }) + ); + }, [effectiveTimelineActorId, executeLaunchContext?.serviceId, executeRunId, scopeId]); + + const handleLoadAudit = useCallback(async () => { + if (!scopeId || !executeLaunchContext?.serviceId || !executeRunId) { + return; + } + + setExecuteAuditLoading(true); + setExecuteAuditError(""); + try { + const snapshot = await scopeRuntimeApi.getServiceRunAudit( + scopeId, + executeLaunchContext.serviceId, + executeRunId, + { + actorId: effectiveTimelineActorId || undefined, + } + ); + setExecuteAuditSnapshot(snapshot); + } catch (error) { + setExecuteAuditSnapshot(null); + setExecuteAuditError(error instanceof Error ? error.message : String(error)); + } finally { + setExecuteAuditLoading(false); + } + }, [ + effectiveTimelineActorId, + executeLaunchContext?.serviceId, + executeRunId, + scopeId, + ]); + + const executeAuditTimeline = executeAuditSnapshot?.audit.timeline ?? []; + const executeAuditSteps = executeAuditSnapshot?.audit.steps ?? []; + const executeAuditReplies = executeAuditSnapshot?.audit.roleReplies ?? []; + const executeAuditSummary = executeAuditSnapshot?.audit.summary; + const relatedAuditStep = useMemo(() => { + const stepId = + selectedTimelineRow?.stepId || timelineBlockingSummary?.stepId || ""; + if (!stepId) { + return null; + } + + return ( + executeAuditSteps.find((step) => step.stepId === stepId) || null + ); + }, [executeAuditSteps, selectedTimelineRow?.stepId, timelineBlockingSummary?.stepId]); + + const handleTimelineAction = useCallback( + async (action: "resume" | "approve" | "reject" | "signal") => { + if ( + !scopeId || + !timelineBlockingSummary || + !effectiveTimelineActorId || + !executeRunId || + !effectiveTimelineServiceId + ) { + return; + } + + setTimelineActionLoading(true); + setTimelineActionError(""); + setTimelineActionNotice(""); + + try { + if (action === "signal") { + const result = await runtimeRunsApi.signal( + scopeId, + { + actorId: effectiveTimelineActorId, + payload: timelineActionInput.trim() || undefined, + runId: executeRunId, + signalName: timelineBlockingSummary.signalName || "continue", + stepId: timelineBlockingSummary.stepId, + }, + { + serviceId: effectiveTimelineServiceId, + } + ); + + const content = `Signal ${ + timelineBlockingSummary.signalName || "continue" + } submitted.`; + setTimelineActionNotice(content); + onTimelineActionResult?.({ + action, + actorId: result.actorId || effectiveTimelineActorId, + commandId: result.commandId, + content, + kind: timelineBlockingSummary.kind, + runId: result.runId || executeRunId, + serviceId: effectiveTimelineServiceId, + signalName: timelineBlockingSummary.signalName, + stepId: timelineBlockingSummary.stepId, + success: true, + }); + } else { + const result = await runtimeRunsApi.resume( + scopeId, + { + actorId: effectiveTimelineActorId, + approved: action !== "reject", + runId: executeRunId, + stepId: timelineBlockingSummary.stepId, + userInput: timelineActionInput.trim() || undefined, + }, + { + serviceId: effectiveTimelineServiceId, + } + ); + + const content = + action === "reject" + ? `Rejection submitted for ${timelineBlockingSummary.stepId}.` + : timelineBlockingSummary.kind === "human_approval" + ? `Approval submitted for ${timelineBlockingSummary.stepId}.` + : `Input submitted for ${timelineBlockingSummary.stepId}.`; + setTimelineActionNotice(content); + onTimelineActionResult?.({ + action, + actorId: result.actorId || effectiveTimelineActorId, + commandId: result.commandId, + content, + kind: timelineBlockingSummary.kind, + runId: result.runId || executeRunId, + serviceId: effectiveTimelineServiceId, + signalName: timelineBlockingSummary.signalName, + stepId: timelineBlockingSummary.stepId, + success: true, + }); + } + + setTimelineActionInput(""); + setTimelineRefreshTick((current) => current + 1); + if (executeAuditSnapshot) { + void handleLoadAudit(); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + setTimelineActionError(errorMessage); + onTimelineActionResult?.({ + action, + actorId: effectiveTimelineActorId, + content: errorMessage, + error: errorMessage, + kind: timelineBlockingSummary.kind, + runId: executeRunId, + serviceId: effectiveTimelineServiceId, + signalName: timelineBlockingSummary.signalName, + stepId: timelineBlockingSummary.stepId, + success: false, + }); + } finally { + setTimelineActionLoading(false); + } + }, + [ + effectiveTimelineActorId, + effectiveTimelineServiceId, + executeAuditSnapshot, + executeRunId, + handleLoadAudit, + onTimelineActionResult, + scopeId, + timelineActionInput, + timelineBlockingSummary, + ] + ); + + const handleRawSubmit = useCallback(async () => { + const normalizedPath = rawPath.trim(); + if (!normalizedPath) { + return; + } + + setRawLoading(true); + setRawResult(null); + + try { + const response = await authFetch( + `/api${normalizedPath.startsWith("/") ? "" : "/"}${normalizedPath}`, + { + body: + rawMethod !== "GET" && rawBody.trim().length > 0 + ? rawBody + : undefined, + headers: + rawMethod !== "GET" && rawBody.trim().length > 0 + ? { + "Content-Type": "application/json", + } + : undefined, + method: rawMethod, + } + ); + + const contentType = response.headers.get("content-type") || ""; + const body = contentType.includes("json") + ? safeJson(await response.json()) + : await response.text(); + + setRawResult({ + body, + status: response.status, + statusText: response.statusText, + }); + } catch (error) { + setRawResult({ + body: error instanceof Error ? error.message : String(error), + status: 0, + statusText: "Network Error", + }); + } finally { + setRawLoading(false); + } + }, [rawBody, rawMethod, rawPath]); + + return ( + + {!scopeId ? ( + + ) : ( +
+
+ Choose a task + + Advanced Console keeps runtime inspection, operator actions, and + developer tooling in one drawer. Start from the task you are + trying to complete. + +
+ Suggested path: start with Query to orient the + scope, move to Execute when you are ready to act, + then use Timeline if the run needs evidence or + operator input. Keep Raw API for protocol-level + debugging. +
+ +
+ {consoleFlowGroups.map((group) => ( +
+
+
+ {group.label} +
+
+ {group.description} +
+
+
+ {group.flows.map((flow) => { + const active = activeTab === flow.id; + return ( + + ); + })} +
+
+ ))} +
+
+ + {activeConsoleFlow ? ( + + ) : null} + + {activeTab === "query" ? ( +
+
+ Query Scope State +
+ {queryTargets.map((target) => ( + + ))} +
+ + {queryTarget === "actor" ? ( +
+ Actor ID + setQueryActorId(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + void handleQuerySubmit(); + } + }} + placeholder="actor://..." + style={{ ...inputStyle, fontFamily: monoFontFamily }} + value={queryActorId} + /> +
+ ) : null} + +
+ +
+
+ + {queryResult + ? createResultPanel("Query Result", queryResult, () => + handleCopy(queryResult) + ) + : null} +
+ ) : null} + + {activeTab === "execute" ? ( +
+
+ Execute Service Endpoint +
+ + + + +