mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-22 09:34:53 +02:00
[Haik]: ckpt, (refactored remaining >250 line files to be less then 250) (made some fixes to the way 9router runs locally, but theres more issues stemming from the fact that local and prod backends run on the same port, fixing this rn)
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
# assistant-ui + Tool UI Migration Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the custom chat UI in `frontend/src/app/pages/AgentChat/` with [assistant-ui](https://www.assistant-ui.com/) (React chat primitives) and [Tool UI](https://www.tool-ui.com/) (tool call rendering components). This replaces ~5,800 lines of custom chat code with maintained, accessible, schema-driven components.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Tailwind + MUI coexist**: Tailwind is added for assistant-ui/Tool UI; MUI stays for non-chat pages (Dashboard, Settings, etc.)
|
||||
- **Clean swap**: No feature flag — old code is replaced directly
|
||||
- **No custom styling**: Use default assistant-ui/Tool UI appearance as-is (no theming to match MUI)
|
||||
- **SkillBuilderChat**: Included in migration
|
||||
- **Sub-agent rendering**: Kept as-is, wired into toolkit as custom tool UIs
|
||||
|
||||
## Architecture: Before → After
|
||||
|
||||
### Before
|
||||
```
|
||||
AgentChat.tsx
|
||||
├── useAgentChat.ts (WS lifecycle, session state)
|
||||
├── useMessageRendering.ts (branch resolution, render items)
|
||||
├── ChatInput.tsx + CommandPicker (custom contentEditable + @/slash picker)
|
||||
├── MessageBubble → UserBubbleContent / AssistantBubbleContent (custom markdown)
|
||||
├── MessageActionBar (copy/edit/regen/branch)
|
||||
├── BranchNavigator (custom branch picker)
|
||||
├── ToolCallBubble + toolCallColors + toolCallUtils (custom terminal rendering)
|
||||
├── ToolGroupBubble (custom accordion)
|
||||
├── ApprovalBar / BatchApprovalBar / QuestionForm (custom HITL)
|
||||
├── GmailCard / McpServiceCards (custom MCP rendering)
|
||||
├── BrowserAgentInlineFeed (custom progress feed)
|
||||
├── DiffViewer (custom diff rendering)
|
||||
└── ThinkingBubble (custom reasoning display)
|
||||
```
|
||||
|
||||
### After
|
||||
```
|
||||
AgentChat.tsx
|
||||
├── AssistantRuntimeProvider + ExternalStoreRuntime (bridges Redux + WS)
|
||||
├── OpenSwarmThread (assistant-ui Thread + Message + ActionBar + BranchPicker)
|
||||
├── OpenSwarmComposer (assistant-ui Composer + ComposerMentionPopover)
|
||||
├── Toolkit registry
|
||||
│ ├── native-tools.tsx → Tool UI Terminal, CodeBlock, CodeDiff
|
||||
│ ├── approval-tools.tsx → Tool UI ApprovalCard, QuestionFlow, OptionList
|
||||
│ ├── mcp-tools.tsx → Tool UI MessageDraft, DataTable, ProgressTracker
|
||||
│ └── custom-tools.tsx → AgentToolBubble, ViewBubble (kept as-is)
|
||||
├── ModelModeSelector (kept, MUI)
|
||||
├── MessageQueue (kept, MUI)
|
||||
└── ChatHeader (kept, MUI)
|
||||
```
|
||||
|
||||
## Phases & Dependency Graph
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ PHASE 1 — Sequential (blocking) │
|
||||
│ Agent 1: Foundation & Packages │
|
||||
└──────────────┬──────────────────────┘
|
||||
│
|
||||
┌─────────┼──────────┬──────────────┬──────────────┐
|
||||
│ │ │ │ │
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
┌─────────┐┌─────────┐┌──────────┐┌──────────┐┌──────────────┐
|
||||
│ Agent 2 ││ Agent 3 ││ Agent 4 ││ Agent 5 ││ Agent 6 │
|
||||
│ Thread ││Composer ││ Tool UI: ││ Tool UI: ││ Tool UI: │
|
||||
│ & ││ & ││ Native ││Approvals ││ MCP Cards │
|
||||
│Messages ││Mentions ││ Tools ││ & ││ & Browser │
|
||||
│ ││ ││ ││Questions ││ Feed │
|
||||
└────┬────┘└────┬────┘└────┬─────┘└────┬─────┘└──────┬───────┘
|
||||
│ │ │ │ │
|
||||
│ PHASE 2 — All 5 agents run in parallel │
|
||||
│ │ │ │ │
|
||||
└─────────┴──────────┴────────────┴─────────────┘
|
||||
│
|
||||
┌───────────▼───────────────┐
|
||||
│ PHASE 3 — Sequential │
|
||||
│ Agent 7: Integration, │
|
||||
│ SkillBuilderChat & │
|
||||
│ Cleanup │
|
||||
└───────────────────────────┘
|
||||
```
|
||||
|
||||
## Agent Summary
|
||||
|
||||
| Agent | Plan File | Phase | What It Does |
|
||||
|-------|-----------|-------|-------------|
|
||||
| 1 | `MIGRATION_AGENT_1.md` | 1 (sequential) | Install Tailwind, shadcn, assistant-ui, Tool UI packages. Create ExternalStoreRuntime adapter, toolkit skeleton, new directory structure. Scaffold `AgentChat.tsx` with provider wrapper. |
|
||||
| 2 | `MIGRATION_AGENT_2.md` | 2 (parallel) | Replace message list with `Thread`. Replace bubbles with `Message` primitives. Replace `MessageActionBar` → `ActionBar`, `BranchNavigator` → `BranchPicker`, `ThinkingBubble` → `Reasoning`. |
|
||||
| 3 | `MIGRATION_AGENT_3.md` | 2 (parallel) | Replace `ChatInput` + `CommandPicker` with `Composer` + `ComposerMentionPopover`. Create `MentionAdapter` for templates/skills/modes/tools/files. |
|
||||
| 4 | `MIGRATION_AGENT_4.md` | 2 (parallel) | Register Tool UI components for native tools: `Terminal` (bash), `CodeBlock` (file read), `CodeDiff` (edit/diff). Replace `ToolCallBubble`, `toolCallColors`, `toolCallUtils`. |
|
||||
| 5 | `MIGRATION_AGENT_5.md` | 2 (parallel) | Register Tool UI components for approvals: `ApprovalCard`, `QuestionFlow`, `OptionList`. Replace `ApprovalBar`, `BatchApprovalBar`, `QuestionForm`, `ToolPreview`. |
|
||||
| 6 | `MIGRATION_AGENT_6.md` | 2 (parallel) | Register Tool UI for MCP services: `MessageDraft` (Gmail), `DataTable` (Calendar/Drive), `ProgressTracker` (browser feed). Wire `AgentToolBubble` and `ViewBubble` as custom toolkit entries. |
|
||||
| 7 | `MIGRATION_AGENT_7.md` | 3 (sequential) | Wire all pieces in `AgentChat.tsx`. Migrate `SkillBuilderChat`. Delete all dead files. Verify build. |
|
||||
|
||||
## File Ownership (Parallel Safety)
|
||||
|
||||
During Phase 2, each agent only touches files it owns. No conflicts.
|
||||
|
||||
| Agent | Creates | Modifies | Deletes |
|
||||
|-------|---------|----------|---------|
|
||||
| 2 | `thread/` directory | Nothing shared | `MessageBubble`, `UserBubbleContent`, `AssistantBubbleContent`, `MessageActionBar`, `BranchNavigator`, `ThinkingBubble`, `MessageImageThumbnails`, `messageBubbleUtils`, `useMessageRendering` |
|
||||
| 3 | `composer/` directory | Nothing shared | `ChatInput`, `useChatSubmit`, `CommandPicker`, `commandPickerTypes`, `useCommandPickerItems`, `CommandPickerIcons`, `SlashCommandPicker`, `AttachmentChips`, `ImageAttachments`, `richEditorUtils`, `RichPromptEditor` |
|
||||
| 4 | `toolkit/native-tools.tsx` | Nothing shared | `ToolCallBubble`, `toolCallColors`, `toolCallUtils`, `ElapsedTimer`, `ToolGroupBubble` |
|
||||
| 5 | `toolkit/approval-tools.tsx` | Nothing shared | `ApprovalBar`, `BatchApprovalBar`, `QuestionForm`, `ToolPreview`, `approvalUtils` |
|
||||
| 6 | `toolkit/mcp-tools.tsx`, `toolkit/custom-tools.tsx` | Nothing shared | `GmailCard`, `McpServiceCards`, `BrowserAgentInlineFeed`, `BrowserFeedEntryRow`, `browserFeedUtils`, `DiffViewer` |
|
||||
| 7 | (integration) | `AgentChat.tsx`, `SkillBuilderChat.tsx` | Remaining dead imports/files |
|
||||
|
||||
## Key Technical Notes
|
||||
|
||||
### ExternalStoreRuntime Adapter
|
||||
|
||||
The runtime adapter (`runtime/useOpenSwarmRuntime.ts`) bridges Redux ↔ assistant-ui:
|
||||
- **Messages**: Read from `session.messages` + `session.streamingMessage` in Redux
|
||||
- **isRunning**: Derived from `session.status === 'running'`
|
||||
- **onNew**: Dispatches `sendMessage` thunk → WebSocket
|
||||
- **onEdit**: Dispatches `editMessage` thunk → REST API
|
||||
- **onCancel**: Dispatches `stopAgent` thunk → REST API
|
||||
- **Branches**: Mapped from `session.branches` + `session.active_branch_id`
|
||||
|
||||
### Message Format Conversion
|
||||
|
||||
Redux `AgentMessage` → assistant-ui format:
|
||||
- `role: 'user'` → `{ role: 'user', content: [{ type: 'text', text }] }`
|
||||
- `role: 'assistant'` → `{ role: 'assistant', content: [{ type: 'text', text }] }`
|
||||
- `role: 'tool_call'` → `{ role: 'assistant', content: [{ type: 'tool-call', toolCallId, toolName, args }] }`
|
||||
- `role: 'tool_result'` → `{ role: 'tool', content: [{ type: 'tool-result', toolCallId, result }] }`
|
||||
|
||||
### Tailwind + MUI Coexistence
|
||||
|
||||
Tailwind is configured with a prefix or scoped to assistant-ui components to avoid conflicts with MUI's global styles. The `important` selector strategy or Tailwind's `prefix` option may be needed.
|
||||
|
||||
### Tool UI Registration Pattern
|
||||
|
||||
All Tool UI components are registered in toolkit files. Each toolkit file exports a partial `Toolkit` object. The `toolkit/index.ts` merges them:
|
||||
|
||||
```typescript
|
||||
import { nativeToolkit } from './native-tools';
|
||||
import { approvalToolkit } from './approval-tools';
|
||||
import { mcpToolkit } from './mcp-tools';
|
||||
import { customToolkit } from './custom-tools';
|
||||
|
||||
export const toolkit: Toolkit = {
|
||||
...nativeToolkit,
|
||||
...approvalToolkit,
|
||||
...mcpToolkit,
|
||||
...customToolkit,
|
||||
};
|
||||
```
|
||||
|
||||
## Lines of Code Impact (Estimated)
|
||||
|
||||
| Category | Lines |
|
||||
|----------|-------|
|
||||
| Deleted (old components) | ~5,800 |
|
||||
| New bridge/adapter code | ~600–800 |
|
||||
| Tool UI components (installed, not authored) | ~2,000 (maintained externally) |
|
||||
| **Net reduction in authored code** | **~5,000** |
|
||||
|
||||
## Estimated Effort
|
||||
|
||||
| Phase | Agents | Estimated Time |
|
||||
|-------|--------|---------------|
|
||||
| Phase 1 | Agent 1 | 1–2 hours |
|
||||
| Phase 2 | Agents 2–6 (parallel) | 2–4 hours each, ~4 hours wall clock |
|
||||
| Phase 3 | Agent 7 | 1–2 hours |
|
||||
| **Total wall clock** | | **~6–8 hours** |
|
||||
@@ -0,0 +1,268 @@
|
||||
# Migration Agent 1: Foundation & Packages
|
||||
|
||||
## Objective
|
||||
|
||||
Install all required packages, configure Tailwind CSS alongside MUI, set up shadcn/ui, create the ExternalStoreRuntime adapter, create the toolkit skeleton, and scaffold the new directory structure. This agent's output is the foundation that all Phase 2 agents build on.
|
||||
|
||||
**This agent must complete before any other agent starts.**
|
||||
|
||||
## Constraints
|
||||
|
||||
- **No custom styling**: Do not theme assistant-ui or Tool UI components to match MUI. Use their default appearance.
|
||||
- **Tailwind + MUI coexist**: Tailwind should not break existing MUI styles. Use Tailwind's `prefix` option (e.g., `tw-`) or scope it carefully.
|
||||
- **Webpack 5**: This project uses Webpack 5 (not Next.js). Tailwind must be configured for Webpack with PostCSS.
|
||||
- **React 18**: The project uses React 18.2. assistant-ui supports React 18 — check their compatibility docs at `/docs/react-compatibility`.
|
||||
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`. Use `assistantUIDocs` and `assistantUIExamples` tools to look up current API docs.
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Install Tailwind CSS for Webpack 5
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install -D tailwindcss @tailwindcss/postcss postcss postcss-loader
|
||||
```
|
||||
|
||||
Create `frontend/postcss.config.js`:
|
||||
```js
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Create `frontend/src/styles/tailwind.css`:
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
```
|
||||
|
||||
Update `webpack.config.js` to add a CSS rule with PostCSS for `.css` files (not `.module.scss` — those stay as-is):
|
||||
```js
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['style-loader', 'css-loader', 'postcss-loader']
|
||||
}
|
||||
```
|
||||
|
||||
Import the tailwind CSS in `frontend/src/index.tsx`:
|
||||
```typescript
|
||||
import './styles/tailwind.css';
|
||||
```
|
||||
|
||||
Verify: `npm run dev` should start without errors, existing MUI pages should look unchanged.
|
||||
|
||||
### 2. Install shadcn/ui
|
||||
|
||||
shadcn/ui typically assumes Next.js, but works with Webpack. Run:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest init
|
||||
```
|
||||
|
||||
When prompted:
|
||||
- Style: Default
|
||||
- Base color: Neutral (or Slate — doesn't matter since we're not custom styling)
|
||||
- CSS variables: Yes
|
||||
- Path alias: `@/` (matches existing tsconfig paths)
|
||||
- Components directory: `src/components/ui`
|
||||
|
||||
This creates `components.json` and a `lib/utils.ts` file. Ensure `cn()` utility works.
|
||||
|
||||
### 3. Install assistant-ui
|
||||
|
||||
```bash
|
||||
npm install @assistant-ui/react @assistant-ui/react-markdown
|
||||
```
|
||||
|
||||
Optionally, for the Lexical rich editor (used by Composer Mention):
|
||||
```bash
|
||||
npm install @assistant-ui/react-lexical lexical @lexical/react
|
||||
```
|
||||
|
||||
Use `assistantUIDocs` MCP tool to check the installation page (`/docs/installation`) for the latest install instructions and any peer dependencies.
|
||||
|
||||
Add assistant-ui's default styles. Check their docs for the exact import — it may be:
|
||||
```typescript
|
||||
import "@assistant-ui/react/styles/index.css";
|
||||
```
|
||||
|
||||
Or they may use Tailwind-based styling via shadcn components. Follow whatever the current docs say.
|
||||
|
||||
### 4. Install ALL Tool UI components upfront
|
||||
|
||||
Install every Tool UI component the migration needs. This prevents parallel agents from running conflicting `npx shadcn` installs.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add @tool-ui/terminal
|
||||
npx shadcn@latest add @tool-ui/code-block
|
||||
npx shadcn@latest add @tool-ui/code-diff
|
||||
npx shadcn@latest add @tool-ui/approval-card
|
||||
npx shadcn@latest add @tool-ui/question-flow
|
||||
npx shadcn@latest add @tool-ui/option-list
|
||||
npx shadcn@latest add @tool-ui/message-draft
|
||||
npx shadcn@latest add @tool-ui/data-table
|
||||
npx shadcn@latest add @tool-ui/item-carousel
|
||||
npx shadcn@latest add @tool-ui/progress-tracker
|
||||
```
|
||||
|
||||
Each installs to `src/components/tool-ui/<component>/` with a component file, schema, and types.
|
||||
|
||||
### 5. Create the ExternalStoreRuntime adapter
|
||||
|
||||
Create `frontend/src/app/pages/AgentChat/runtime/useOpenSwarmRuntime.ts`.
|
||||
|
||||
This hook bridges the Redux store + WebSocket to assistant-ui's runtime. Use `assistantUIDocs` to look up the ExternalStoreRuntime API at path `runtimes/custom/external-store`.
|
||||
|
||||
Key responsibilities:
|
||||
- Read `session.messages` from Redux and convert to assistant-ui's message format
|
||||
- Read `session.streamingMessage` and surface it as the in-progress message
|
||||
- Map `session.status` to `isRunning`
|
||||
- `onNew` → dispatch `sendMessage` thunk (existing in `agentsThunks.ts`)
|
||||
- `onEdit` → dispatch `editMessage` thunk
|
||||
- `onCancel` → dispatch `stopAgent` thunk
|
||||
- Handle `session.branches` for branching support
|
||||
|
||||
**Message format conversion** — the critical mapping:
|
||||
|
||||
Redux `AgentMessage` types:
|
||||
```typescript
|
||||
interface AgentMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'system';
|
||||
content: any;
|
||||
timestamp: string;
|
||||
branch_id: string;
|
||||
parent_id: string | null;
|
||||
// ... other fields
|
||||
}
|
||||
```
|
||||
|
||||
assistant-ui expects messages like:
|
||||
```typescript
|
||||
// User message
|
||||
{ role: 'user', content: [{ type: 'text', text: '...' }] }
|
||||
|
||||
// Assistant message
|
||||
{ role: 'assistant', content: [{ type: 'text', text: '...' }] }
|
||||
|
||||
// Tool call (part of assistant message)
|
||||
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: '...', toolName: '...', args: {} }] }
|
||||
|
||||
// Tool result
|
||||
{ role: 'tool', content: [{ type: 'tool-result', toolCallId: '...', result: {} }] }
|
||||
```
|
||||
|
||||
Write a `convertMessages(messages: AgentMessage[]): ThreadMessage[]` function that handles this. Note that consecutive tool_call + tool_result messages may need to be merged or paired.
|
||||
|
||||
Look up the ExternalStoreRuntime docs carefully — the exact shape of `ThreadMessage` and how streaming is handled (via `status` field on messages) matters.
|
||||
|
||||
### 6. Create the toolkit skeleton
|
||||
|
||||
Create the directory `frontend/src/app/pages/AgentChat/toolkit/`.
|
||||
|
||||
Create `frontend/src/app/pages/AgentChat/toolkit/index.ts`:
|
||||
```typescript
|
||||
import { type Toolkit } from '@assistant-ui/react';
|
||||
import { nativeToolkit } from './native-tools';
|
||||
import { approvalToolkit } from './approval-tools';
|
||||
import { mcpToolkit } from './mcp-tools';
|
||||
import { customToolkit } from './custom-tools';
|
||||
|
||||
export const toolkit: Toolkit = {
|
||||
...nativeToolkit,
|
||||
...approvalToolkit,
|
||||
...mcpToolkit,
|
||||
...customToolkit,
|
||||
};
|
||||
```
|
||||
|
||||
Create placeholder files for each toolkit module:
|
||||
- `toolkit/native-tools.tsx` — exports `nativeToolkit: Partial<Toolkit> = {}`
|
||||
- `toolkit/approval-tools.tsx` — exports `approvalToolkit: Partial<Toolkit> = {}`
|
||||
- `toolkit/mcp-tools.tsx` — exports `mcpToolkit: Partial<Toolkit> = {}`
|
||||
- `toolkit/custom-tools.tsx` — exports `customToolkit: Partial<Toolkit> = {}`
|
||||
|
||||
These are empty stubs. Phase 2 agents fill them in.
|
||||
|
||||
### 7. Create Thread and Composer placeholder directories
|
||||
|
||||
Create:
|
||||
- `frontend/src/app/pages/AgentChat/thread/` — Agent 2 fills this
|
||||
- `frontend/src/app/pages/AgentChat/composer/` — Agent 3 fills this
|
||||
|
||||
Create placeholder files:
|
||||
- `thread/OpenSwarmThread.tsx` — exports a simple `<div>Thread placeholder</div>`
|
||||
- `composer/OpenSwarmComposer.tsx` — exports a simple `<div>Composer placeholder</div>`
|
||||
|
||||
### 8. Scaffold AgentChat.tsx with provider wrapper
|
||||
|
||||
Modify `AgentChat.tsx` to wrap the chat area with `AssistantRuntimeProvider`. Keep the existing structure but prepare for Phase 2 agents to swap in their components.
|
||||
|
||||
The modified AgentChat should:
|
||||
1. Import and use `useOpenSwarmRuntime(sessionId)` to get the runtime
|
||||
2. Wrap the chat area with `<AssistantRuntimeProvider runtime={runtime}>`
|
||||
3. Register the toolkit via `useAui` or `Tools` (check assistant-ui docs for the current API)
|
||||
4. Keep existing imports for `ChatHeader`, `MessageQueue`, `ModelModeSelector` (these stay)
|
||||
5. Keep the `useAgentChat` hook (it manages WS lifecycle, send handlers, etc.)
|
||||
6. For now, keep the existing message rendering loop AND the placeholders — the app should still work with the old rendering. Agent 7 does the final swap.
|
||||
|
||||
**Important**: The app must still work after this agent completes. Do not remove any existing components yet — just add the runtime wrapper and placeholders alongside them.
|
||||
|
||||
### 9. Verify everything works
|
||||
|
||||
- `npm run dev` starts without errors
|
||||
- The existing chat page renders and works as before
|
||||
- Tailwind utility classes work (test by adding a `tw-text-red-500` class somewhere temporarily)
|
||||
- Tool UI component files exist in `src/components/tool-ui/`
|
||||
- No TypeScript errors from the new imports
|
||||
|
||||
## Files Created
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `frontend/postcss.config.js` | PostCSS config for Tailwind |
|
||||
| `frontend/src/styles/tailwind.css` | Tailwind entry CSS |
|
||||
| `frontend/components.json` | shadcn/ui config |
|
||||
| `frontend/src/lib/utils.ts` | shadcn `cn()` utility |
|
||||
| `frontend/src/app/pages/AgentChat/runtime/useOpenSwarmRuntime.ts` | ExternalStoreRuntime adapter |
|
||||
| `frontend/src/app/pages/AgentChat/toolkit/index.ts` | Merged toolkit registry |
|
||||
| `frontend/src/app/pages/AgentChat/toolkit/native-tools.tsx` | Stub for Agent 4 |
|
||||
| `frontend/src/app/pages/AgentChat/toolkit/approval-tools.tsx` | Stub for Agent 5 |
|
||||
| `frontend/src/app/pages/AgentChat/toolkit/mcp-tools.tsx` | Stub for Agent 6 |
|
||||
| `frontend/src/app/pages/AgentChat/toolkit/custom-tools.tsx` | Stub for Agent 6 |
|
||||
| `frontend/src/app/pages/AgentChat/thread/OpenSwarmThread.tsx` | Placeholder for Agent 2 |
|
||||
| `frontend/src/app/pages/AgentChat/composer/OpenSwarmComposer.tsx` | Placeholder for Agent 3 |
|
||||
| `frontend/src/components/tool-ui/*` | All Tool UI components (installed via shadcn) |
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/package.json` | New dependencies added |
|
||||
| `frontend/webpack.config.js` | CSS rule with postcss-loader added |
|
||||
| `frontend/tsconfig.json` | May need path adjustments for shadcn |
|
||||
| `frontend/src/index.tsx` | Import tailwind CSS |
|
||||
| `frontend/src/app/pages/AgentChat/AgentChat.tsx` | Wrapped with `AssistantRuntimeProvider` |
|
||||
|
||||
## Files Deleted
|
||||
|
||||
None. This agent only adds — it never removes existing functionality.
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `npm run dev` starts without errors
|
||||
- [ ] Existing chat page works exactly as before
|
||||
- [ ] Tailwind classes render correctly
|
||||
- [ ] `src/components/tool-ui/terminal/` exists with component + schema
|
||||
- [ ] `src/components/tool-ui/code-block/` exists with component + schema
|
||||
- [ ] `src/components/tool-ui/code-diff/` exists with component + schema
|
||||
- [ ] `src/components/tool-ui/approval-card/` exists with component + schema
|
||||
- [ ] `src/components/tool-ui/question-flow/` exists with component + schema
|
||||
- [ ] `src/components/tool-ui/option-list/` exists with component + schema
|
||||
- [ ] `src/components/tool-ui/message-draft/` exists with component + schema
|
||||
- [ ] `src/components/tool-ui/data-table/` exists with component + schema
|
||||
- [ ] `src/components/tool-ui/progress-tracker/` exists with component + schema
|
||||
- [ ] `useOpenSwarmRuntime` hook compiles without errors
|
||||
- [ ] Toolkit index imports all stubs without errors
|
||||
- [ ] `AgentChat.tsx` has `AssistantRuntimeProvider` wrapper
|
||||
@@ -0,0 +1,184 @@
|
||||
# Migration Agent 2: Thread & Message Rendering
|
||||
|
||||
## Objective
|
||||
|
||||
Replace the custom message list, scroll viewport, message bubbles, action bar, branch picker, thinking indicator, and image rendering with assistant-ui's `Thread`, `Message`, `ActionBar`, `BranchPicker`, and `Reasoning` primitives.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Agent 1 must be complete.** The ExternalStoreRuntime adapter, toolkit skeleton, and `AssistantRuntimeProvider` wrapper must exist.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **No custom styling**: Use default assistant-ui appearance. Do not add CSS to match MUI theme.
|
||||
- **Only modify files in the `thread/` directory** and the files listed under "Files Deleted." Do NOT modify `AgentChat.tsx`, `toolkit/`, `composer/`, or any file owned by another agent.
|
||||
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`. Use `assistantUIDocs` and `assistantUIExamples` tools to look up current API docs.
|
||||
|
||||
## Key Files to Read First
|
||||
|
||||
Understand what you're replacing:
|
||||
- `frontend/src/app/pages/AgentChat/useMessageRendering.ts` — branch resolution, render items
|
||||
- `frontend/src/app/pages/AgentChat/MessageBubble.tsx` — routes to User/Assistant bubbles
|
||||
- `frontend/src/app/pages/AgentChat/UserBubbleContent.tsx` — user message with context pills, images, editing
|
||||
- `frontend/src/app/pages/AgentChat/AssistantBubbleContent.tsx` — markdown rendering
|
||||
- `frontend/src/app/pages/AgentChat/MessageActionBar.tsx` — copy/edit/regenerate/branch actions
|
||||
- `frontend/src/app/pages/AgentChat/BranchNavigator.tsx` — branch picker arrows
|
||||
- `frontend/src/app/pages/AgentChat/ThinkingBubble.tsx` — thinking/reasoning display
|
||||
- `frontend/src/app/pages/AgentChat/MessageImageThumbnails.tsx` — image display in messages
|
||||
- `frontend/src/app/pages/AgentChat/AttachedContextSection.tsx` — context pills in user messages
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Look up assistant-ui docs
|
||||
|
||||
Use the `assistantUIDocs` MCP tool to read these pages:
|
||||
- `primitives/thread` — Thread primitives (viewport, messages, scroll)
|
||||
- `primitives/message` — Message primitives (root, content, parts)
|
||||
- `primitives/action-bar` — ActionBar primitives (copy, edit, reload, speak)
|
||||
- `primitives/branch-picker` — BranchPicker primitives (prev/next/count)
|
||||
- `ui/thread` — Pre-built Thread component
|
||||
- `ui/markdown` — Markdown component
|
||||
- `ui/reasoning` — Reasoning/thinking UI
|
||||
- `guides/branching` — How branching works
|
||||
|
||||
### 2. Build OpenSwarmThread
|
||||
|
||||
Create `frontend/src/app/pages/AgentChat/thread/OpenSwarmThread.tsx`.
|
||||
|
||||
This is the main thread component that replaces the scroll container + message render loop in `AgentChat.tsx`. It should:
|
||||
|
||||
1. Use `ThreadPrimitive.Root` and `ThreadPrimitive.Viewport` for the scrollable container with auto-scroll
|
||||
2. Use `ThreadPrimitive.Messages` to render the message list
|
||||
3. Provide custom `UserMessage` and `AssistantMessage` components
|
||||
|
||||
```tsx
|
||||
import { ThreadPrimitive } from '@assistant-ui/react';
|
||||
import { UserMessage } from './UserMessage';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
|
||||
export const OpenSwarmThread = () => {
|
||||
return (
|
||||
<ThreadPrimitive.Root>
|
||||
<ThreadPrimitive.Viewport>
|
||||
<ThreadPrimitive.Messages
|
||||
components={{ UserMessage, AssistantMessage }}
|
||||
/>
|
||||
</ThreadPrimitive.Viewport>
|
||||
<ThreadPrimitive.ScrollToBottom />
|
||||
</ThreadPrimitive.Root>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Build UserMessage component
|
||||
|
||||
Create `thread/UserMessage.tsx`.
|
||||
|
||||
This replaces `UserBubbleContent.tsx`. It should:
|
||||
- Use `MessagePrimitive.Root` and `MessagePrimitive.Content`
|
||||
- Render user text content
|
||||
- Render attached context paths (files, directories) as chips/badges
|
||||
- Render attached images using `MessagePrimitive.Attachments` or custom rendering
|
||||
- Support inline editing via `MessagePrimitive.EditComposer` (or however assistant-ui handles edit)
|
||||
- Show the `ActionBar` (copy, edit) on hover
|
||||
|
||||
For context pills and attached skills, read the existing `AttachedContextSection.tsx` and `UserBubbleContent.tsx` to understand the data shape. These are in `message.context_paths` and `message.attached_skills`.
|
||||
|
||||
Note: The ExternalStoreRuntime adapter (from Agent 1) should include these in the converted message format. If they're not accessible via assistant-ui's message API, render them by reading from Redux directly using the message ID.
|
||||
|
||||
### 4. Build AssistantMessage component
|
||||
|
||||
Create `thread/AssistantMessage.tsx`.
|
||||
|
||||
This replaces `AssistantBubbleContent.tsx`. It should:
|
||||
- Use `MessagePrimitive.Root` and `MessagePrimitive.Content`
|
||||
- Render markdown content using assistant-ui's `MarkdownText` or `@assistant-ui/react-markdown`
|
||||
- Render tool calls inline (assistant-ui handles this automatically when tools are registered in the toolkit)
|
||||
- Show the `ActionBar` (copy, regenerate, branch) on hover
|
||||
- Show the `BranchPicker` when the message has sibling branches
|
||||
|
||||
Use assistant-ui's `makeMarkdownText` or the `Markdown` component from `@assistant-ui/react-markdown`. Look up the current API in the docs.
|
||||
|
||||
### 5. Build ActionBar
|
||||
|
||||
Create `thread/MessageActions.tsx` (or inline in UserMessage/AssistantMessage).
|
||||
|
||||
This replaces `MessageActionBar.tsx`. Use assistant-ui's `ActionBarPrimitive`:
|
||||
- `ActionBarPrimitive.Copy` — copy message text
|
||||
- `ActionBarPrimitive.Edit` — edit user message
|
||||
- `ActionBarPrimitive.Reload` — regenerate assistant message (maps to your "Regenerate" button)
|
||||
|
||||
For the "Branch chat" action, this is custom. Use a custom button inside the ActionBar that calls the existing `duplicateSession` thunk. You can use `useMessage` hook to get the current message context.
|
||||
|
||||
### 6. Build BranchPicker
|
||||
|
||||
Create `thread/BranchPicker.tsx` (or inline in AssistantMessage).
|
||||
|
||||
This replaces `BranchNavigator.tsx`. Use assistant-ui's `BranchPickerPrimitive`:
|
||||
- `BranchPickerPrimitive.Previous`
|
||||
- `BranchPickerPrimitive.Number` / `BranchPickerPrimitive.Count`
|
||||
- `BranchPickerPrimitive.Next`
|
||||
|
||||
The ExternalStoreRuntime adapter (Agent 1) should expose branch data. If not, this may need coordination with Agent 1's runtime adapter to ensure branches are surfaced correctly.
|
||||
|
||||
### 7. Handle thinking/reasoning display
|
||||
|
||||
Create `thread/ThinkingIndicator.tsx` (or use assistant-ui's built-in).
|
||||
|
||||
This replaces `ThinkingBubble.tsx`. Check if assistant-ui's `Thread` automatically shows a loading indicator when `isRunning` is true. If so, the default behavior may be sufficient. If a custom thinking bubble is needed, use assistant-ui's reasoning or chain-of-thought UI.
|
||||
|
||||
### 8. Handle streaming messages
|
||||
|
||||
The ExternalStoreRuntime adapter should handle streaming state. When `session.streamingMessage` exists in Redux, the runtime should surface it as a message with `status: 'in_progress'`. assistant-ui will automatically animate streaming text.
|
||||
|
||||
Verify that the streaming cursor / token animation works correctly with the runtime adapter from Agent 1.
|
||||
|
||||
## Files Created
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `thread/OpenSwarmThread.tsx` | Main thread component (replaces scroll container + message loop) |
|
||||
| `thread/UserMessage.tsx` | User message rendering |
|
||||
| `thread/AssistantMessage.tsx` | Assistant message with markdown, tool calls |
|
||||
| `thread/MessageActions.tsx` | Action bar (copy, edit, regenerate, branch) |
|
||||
| `thread/BranchPicker.tsx` | Branch navigation |
|
||||
| `thread/ThinkingIndicator.tsx` | Thinking/loading state (if needed beyond defaults) |
|
||||
|
||||
All files go in `frontend/src/app/pages/AgentChat/thread/`.
|
||||
|
||||
## Files Deleted (by this agent)
|
||||
|
||||
These files are replaced by the new thread components. Delete them:
|
||||
|
||||
| File | Lines | Replaced By |
|
||||
|------|-------|------------|
|
||||
| `MessageBubble.tsx` | 99 | `UserMessage.tsx` + `AssistantMessage.tsx` |
|
||||
| `UserBubbleContent.tsx` | 152 | `UserMessage.tsx` |
|
||||
| `AssistantBubbleContent.tsx` | 128 | `AssistantMessage.tsx` |
|
||||
| `MessageActionBar.tsx` | 153 | `MessageActions.tsx` |
|
||||
| `BranchNavigator.tsx` | 60 | `BranchPicker.tsx` |
|
||||
| `ThinkingBubble.tsx` | 55 | `ThinkingIndicator.tsx` or built-in |
|
||||
| `MessageImageThumbnails.tsx` | 108 | Handled in `UserMessage.tsx` via Attachment primitives |
|
||||
| `messageBubbleUtils.ts` | 54 | No longer needed |
|
||||
| `useMessageRendering.ts` | 196 | Thread handles message list rendering |
|
||||
|
||||
**Total deleted: ~1,005 lines**
|
||||
|
||||
## Files NOT Modified
|
||||
|
||||
- `AgentChat.tsx` — Agent 7 wires in `OpenSwarmThread`
|
||||
- Anything in `composer/`, `toolkit/`, or shared state
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `OpenSwarmThread` renders a scrollable message list
|
||||
- [ ] User messages display text content
|
||||
- [ ] Assistant messages render markdown correctly
|
||||
- [ ] Tool call messages are rendered (even if just as fallback text — toolkit fills in later)
|
||||
- [ ] ActionBar shows copy/edit/regenerate on hover
|
||||
- [ ] BranchPicker shows navigation when branches exist
|
||||
- [ ] Streaming messages animate token-by-token
|
||||
- [ ] Auto-scroll to bottom works on new messages
|
||||
- [ ] Scroll-to-bottom button appears when scrolled up
|
||||
- [ ] No TypeScript errors
|
||||
- [ ] All deleted files are removed
|
||||
@@ -0,0 +1,206 @@
|
||||
# Migration Agent 3: Composer & Mention System
|
||||
|
||||
## Objective
|
||||
|
||||
Replace the custom `ChatInput` (contentEditable + CommandPicker) with assistant-ui's `Composer` primitives and `ComposerMentionPopover` for `@`/`/` trigger-based command picking.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Agent 1 must be complete.** The `AssistantRuntimeProvider`, runtime adapter, and assistant-ui packages must be installed.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **No custom styling**: Use default assistant-ui Composer and Mention appearance.
|
||||
- **Only modify files in the `composer/` directory** and the files listed under "Files Deleted." Do NOT modify `AgentChat.tsx`, `toolkit/`, `thread/`, or any file owned by another agent.
|
||||
- **Keep `ModelModeSelector.tsx`** as-is — it's MUI and stays. The new Composer should render it in the footer area alongside the send button, similar to how it's currently positioned.
|
||||
- **Keep `MessageQueue.tsx`** as-is — it wraps the composer.
|
||||
- **Keep `TemplateInvokeModal.tsx`** as-is — it's triggered when a template is selected.
|
||||
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`. Use `assistantUIDocs` and `assistantUIExamples` tools to look up current API docs.
|
||||
|
||||
## Key Files to Read First
|
||||
|
||||
Understand what you're replacing:
|
||||
- `frontend/src/app/pages/AgentChat/ChatInput.tsx` — main composer component
|
||||
- `frontend/src/app/pages/AgentChat/hooks/useChatSubmit.ts` — send/paste/drop/pick logic
|
||||
- `frontend/src/app/pages/AgentChat/AttachmentChips.tsx` — context path + tool chips
|
||||
- `frontend/src/app/pages/AgentChat/ImageAttachments.tsx` — image thumbnails + lightbox
|
||||
- `frontend/src/app/components/CommandPicker.tsx` — unified `@`/`/` command picker
|
||||
- `frontend/src/app/components/commandPickerTypes.tsx` — picker types
|
||||
- `frontend/src/app/components/useCommandPickerItems.tsx` — items for the picker (templates, skills, modes, tools, files)
|
||||
- `frontend/src/app/components/CommandPickerIcons.tsx` — icons for picker categories
|
||||
- `frontend/src/app/components/SlashCommandPicker.tsx` — legacy picker
|
||||
- `frontend/src/app/components/richEditorUtils.ts` — skill pill serialize/deserialize
|
||||
- `frontend/src/app/components/RichPromptEditor.tsx` — shared rich editor used in mode/template editors
|
||||
|
||||
Also read the assistant-ui docs:
|
||||
- `ui/mention` — ComposerMentionPopover, MentionAdapter, DirectiveText
|
||||
- `primitives/composer` — Composer primitives (Root, Input, Send, Attachments)
|
||||
- `primitives/attachment` — Attachment primitives
|
||||
- `guides/attachments` — Attachment handling
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Look up assistant-ui Mention docs
|
||||
|
||||
Use `assistantUIDocs` MCP tool to read:
|
||||
- `ui/mention` — full Mention API
|
||||
- `primitives/composer` — Composer primitives
|
||||
|
||||
The Mention system provides:
|
||||
- `ComposerMentionPopover.Root` — wraps the composer, detects `@` trigger
|
||||
- `ComposerMentionPopover` — the popover UI with categories and keyboard nav
|
||||
- `LexicalComposerInput` — rich editor with inline mention chips
|
||||
- Custom `MentionAdapter` — you supply the list of mentionable items
|
||||
|
||||
### 2. Create the MentionAdapter
|
||||
|
||||
Create `frontend/src/app/pages/AgentChat/composer/OpenSwarmMentionAdapter.ts`.
|
||||
|
||||
This replaces `useCommandPickerItems.tsx`. The adapter provides the list of mentionable items for both `@` and `/` triggers.
|
||||
|
||||
Read the existing `useCommandPickerItems.tsx` to understand what categories exist:
|
||||
- **Templates** (triggered by `/`) — from `state.templates.items`
|
||||
- **Skills** (triggered by `/`) — from `state.skills.items`
|
||||
- **Modes** (triggered by `/`) — from `state.modes.items`
|
||||
- **File attach** (triggered by `@`) — opens file browser
|
||||
- **Web search** (triggered by `@`) — web search context
|
||||
- **MCP tool groups** (triggered by `@`) — from `state.mcpRegistry`
|
||||
- **View/app outputs** (triggered by `@`) — from dashboard outputs
|
||||
|
||||
The Mention adapter should return these as categories with items. The adapter's `search` function filters items based on the typed query.
|
||||
|
||||
Note: The default trigger for assistant-ui Mention is `@`. To support both `@` and `/`, you may need two `ComposerMentionPopover.Root` instances with different `trigger` props, or a single adapter that handles both (check docs for multi-trigger support).
|
||||
|
||||
### 3. Build OpenSwarmComposer
|
||||
|
||||
Create `frontend/src/app/pages/AgentChat/composer/OpenSwarmComposer.tsx`.
|
||||
|
||||
This replaces `ChatInput.tsx`. Structure:
|
||||
|
||||
```tsx
|
||||
import { ComposerPrimitive } from '@assistant-ui/react';
|
||||
import { ComposerMentionPopover } from '@/components/assistant-ui/composer-mention';
|
||||
import { LexicalComposerInput } from '@assistant-ui/react-lexical';
|
||||
import ModelModeSelector from '../ModelModeSelector';
|
||||
|
||||
export const OpenSwarmComposer = ({ mode, onModeChange, model, onModelChange, ...props }) => {
|
||||
return (
|
||||
<ComposerMentionPopover.Root adapter={openSwarmMentionAdapter}>
|
||||
<ComposerPrimitive.Root>
|
||||
{/* Attachment display area (images, context paths) */}
|
||||
<ComposerPrimitive.Attachments />
|
||||
|
||||
{/* Rich text input with inline mention chips */}
|
||||
<LexicalComposerInput placeholder="Agent, @ for context, / for commands" />
|
||||
|
||||
{/* Mention popover (appears on @ or / trigger) */}
|
||||
<ComposerMentionPopover />
|
||||
|
||||
{/* Footer: mode/model selector + send button */}
|
||||
<ModelModeSelector ... />
|
||||
<ComposerPrimitive.Send />
|
||||
<ComposerPrimitive.Cancel /> {/* Stop button when running */}
|
||||
</ComposerPrimitive.Root>
|
||||
</ComposerMentionPopover.Root>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
Key behaviors to implement:
|
||||
- **Send**: `ComposerPrimitive.Send` calls the runtime's `onNew` which dispatches `sendMessage`
|
||||
- **Stop**: `ComposerPrimitive.Cancel` calls the runtime's `onCancel` which dispatches `stopAgent`
|
||||
- **Image paste/drop**: Use `ComposerPrimitive.Attachments` for drag-drop and paste handling. Check assistant-ui's attachment guide.
|
||||
- **File upload**: When `@file` is selected from the mention popover, trigger the existing file upload flow (POST to `/api/settings/upload-files`)
|
||||
- **Template selection**: When a template with variables is selected from `/`, open `TemplateInvokeModal`. This requires a callback on mention selection.
|
||||
|
||||
### 4. Handle the ChatInputHandle API
|
||||
|
||||
The existing `ChatInput` exposes a `ChatInputHandle` ref with `getConfig()` and `setContent()` methods. These are used by:
|
||||
- `SkillBuilderChat.tsx` — to programmatically set content
|
||||
- `useAgentChat.ts` — to get config before sending
|
||||
|
||||
With assistant-ui, programmatic control goes through the `ComposerRuntime`. Check docs at `api-reference/runtimes/composer-runtime` for `setText()`, `send()`, etc.
|
||||
|
||||
Create a wrapper or hook that provides equivalent functionality:
|
||||
- `getConfig()` → read from composer runtime state
|
||||
- `setContent()` → use `composerRuntime.setText()`
|
||||
|
||||
### 5. Handle attachment rendering in the composer
|
||||
|
||||
The current `ChatInput` renders:
|
||||
- **Image thumbnails** with lightbox and remove buttons (`ImageAttachments.tsx`)
|
||||
- **Context path chips** with copy-to-clipboard and remove (`AttachmentChips.tsx`)
|
||||
- **Forced tool chips** (`AttachmentChips.tsx`)
|
||||
- **UI element selection chips** (`AttachmentChips.tsx`)
|
||||
|
||||
With assistant-ui, use `ComposerPrimitive.Attachments` to render attachments. For custom chip types (context paths, forced tools, UI elements), you may need custom rendering inside the Composer area.
|
||||
|
||||
### 6. Handle the RichPromptEditor
|
||||
|
||||
`RichPromptEditor.tsx` is a shared component used outside the chat (in mode editors, template editors). It has similar `@`/`/` trigger detection and skill pill insertion.
|
||||
|
||||
Two options:
|
||||
- **Option A**: Keep `RichPromptEditor.tsx` as-is for now (it's not in the critical chat path)
|
||||
- **Option B**: Refactor it to also use `ComposerMentionPopover`
|
||||
|
||||
Go with **Option A** — keep it as-is. It's used in Settings/Modes pages, which are out of scope. Just make sure deleting `richEditorUtils.ts` doesn't break it. If it imports from there, extract the needed utilities.
|
||||
|
||||
**Important**: Before deleting `richEditorUtils.ts`, check if `RichPromptEditor.tsx` imports from it. If so, either:
|
||||
- Move the needed functions into `RichPromptEditor.tsx` itself
|
||||
- Keep `richEditorUtils.ts` but only with the functions `RichPromptEditor` needs
|
||||
|
||||
## Files Created
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `composer/OpenSwarmComposer.tsx` | Main composer component |
|
||||
| `composer/OpenSwarmMentionAdapter.ts` | Mention adapter for templates/skills/modes/tools/files |
|
||||
| `composer/useComposerHandle.ts` | Hook providing `getConfig`/`setContent` via ComposerRuntime |
|
||||
|
||||
All files go in `frontend/src/app/pages/AgentChat/composer/`.
|
||||
|
||||
Also install the composer-mention shadcn component if not already present:
|
||||
```bash
|
||||
npx shadcn@latest add composer-mention
|
||||
```
|
||||
|
||||
## Files Deleted (by this agent)
|
||||
|
||||
| File | Lines | Replaced By |
|
||||
|------|-------|------------|
|
||||
| `ChatInput.tsx` | 209 | `OpenSwarmComposer.tsx` |
|
||||
| `hooks/useChatSubmit.ts` | 248 | Runtime `onNew`/`onEdit`/`onCancel` |
|
||||
| `AttachmentChips.tsx` | 118 | Mention chips + Composer Attachments |
|
||||
| `ImageAttachments.tsx` | 85 | Composer Attachment primitives |
|
||||
| `CommandPicker.tsx` | 230 | `ComposerMentionPopover` |
|
||||
| `commandPickerTypes.tsx` | 47 | `OpenSwarmMentionAdapter.ts` types |
|
||||
| `useCommandPickerItems.tsx` | 225 | `OpenSwarmMentionAdapter.ts` |
|
||||
| `CommandPickerIcons.tsx` | 40 | Category icons in adapter |
|
||||
| `SlashCommandPicker.tsx` | 165 | `ComposerMentionPopover` |
|
||||
|
||||
**About `richEditorUtils.ts` (196 lines)**: Check if `RichPromptEditor.tsx` imports from it. If yes, extract only the functions `RichPromptEditor` needs into that file, then delete `richEditorUtils.ts`. If no imports, delete it entirely.
|
||||
|
||||
**Total deleted: ~1,367+ lines**
|
||||
|
||||
## Files NOT Modified
|
||||
|
||||
- `AgentChat.tsx` — Agent 7 wires in `OpenSwarmComposer`
|
||||
- `ModelModeSelector.tsx` — kept as-is, rendered inside the new Composer
|
||||
- `MessageQueue.tsx` — kept as-is, wraps the new Composer
|
||||
- `TemplateInvokeModal.tsx` — kept as-is, triggered by mention selection
|
||||
- Anything in `thread/`, `toolkit/`, or shared state
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `OpenSwarmComposer` renders a text input area
|
||||
- [ ] Typing `@` opens the mention popover with context categories (files, tools, etc.)
|
||||
- [ ] Typing `/` opens the mention popover with command categories (templates, skills, modes)
|
||||
- [ ] Keyboard navigation works (arrows, Enter, Escape)
|
||||
- [ ] Selected mentions appear as inline chips in the editor
|
||||
- [ ] Images can be pasted or dragged into the composer
|
||||
- [ ] Send button calls the runtime's `onNew`
|
||||
- [ ] Stop button appears when the agent is running and calls `onCancel`
|
||||
- [ ] `ModelModeSelector` renders in the composer footer
|
||||
- [ ] No TypeScript errors
|
||||
- [ ] All deleted files are removed
|
||||
- [ ] `RichPromptEditor` still works in Modes/Templates pages (if it existed before)
|
||||
@@ -0,0 +1,262 @@
|
||||
# Migration Agent 4: Tool Toolkit — Terminal, Code, Diffs
|
||||
|
||||
## Objective
|
||||
|
||||
Register Tool UI components (`Terminal`, `CodeBlock`, `CodeDiff`) in the toolkit for rendering native tool calls (bash, file read/write, edit, grep, glob, search). Replace `ToolCallBubble`, `toolCallColors`, `toolCallUtils`, `ElapsedTimer`, and `ToolGroupBubble`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Agent 1 must be complete.** Tool UI components must be installed in `src/components/tool-ui/`, and the toolkit skeleton must exist at `toolkit/native-tools.tsx`.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **No custom styling**: Use default Tool UI Terminal/CodeBlock/CodeDiff appearance.
|
||||
- **Only modify `toolkit/native-tools.tsx`**. Do NOT modify `AgentChat.tsx`, `thread/`, `composer/`, or other toolkit files.
|
||||
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`. Use it for assistant-ui API lookups. For Tool UI docs, refer to the website or read the installed component schemas.
|
||||
|
||||
## Key Files to Read First
|
||||
|
||||
Understand what you're replacing:
|
||||
- `frontend/src/app/pages/AgentChat/ToolCallBubble.tsx` — main tool call rendering (173 lines)
|
||||
- `frontend/src/app/pages/AgentChat/toolCallUtils.ts` — `parseMcpToolName`, `getToolData`, `parseToolResult`, `formatInputDisplay`, etc.
|
||||
- `frontend/src/app/pages/AgentChat/toolCallColors.tsx` — `colorizeInput`, `colorizeOutput`, terminal color constants
|
||||
- `frontend/src/app/pages/AgentChat/ElapsedTimer.tsx` — duration timer component
|
||||
- `frontend/src/app/pages/AgentChat/ToolGroupBubble.tsx` — grouped tool calls accordion
|
||||
|
||||
Also read the installed Tool UI component schemas:
|
||||
- `src/components/tool-ui/terminal/schema.ts` — Terminal props schema
|
||||
- `src/components/tool-ui/code-block/schema.ts` — CodeBlock props schema
|
||||
- `src/components/tool-ui/code-diff/schema.ts` — CodeDiff props schema
|
||||
|
||||
## Background: How Tool Calls Work in This App
|
||||
|
||||
Messages with `role: 'tool_call'` have this content shape:
|
||||
```typescript
|
||||
{
|
||||
tool: string; // tool name (e.g. "Bash", "Read", "Edit", "Grep", "mcp__google-gmail__search")
|
||||
input: any; // tool input args (e.g. { command: "ls -la" })
|
||||
}
|
||||
```
|
||||
|
||||
Messages with `role: 'tool_result'` have:
|
||||
```typescript
|
||||
{
|
||||
text: string; // raw result text
|
||||
elapsed_ms?: number; // optional duration
|
||||
}
|
||||
// OR just a string
|
||||
```
|
||||
|
||||
The `toolCallUtils.ts` file has `parseToolResult()` which parses the result into structured types:
|
||||
- `{ type: 'bash', stdout, stderr, exitCode }` for Bash tool
|
||||
- `{ type: 'text', content, isError }` for Read/Grep/etc.
|
||||
- `{ type: 'mcp', service, action, data }` for MCP tools (handled by Agent 6)
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Read Tool UI component APIs
|
||||
|
||||
Read the schema files for each installed component:
|
||||
- `src/components/tool-ui/terminal/schema.ts`
|
||||
- `src/components/tool-ui/code-block/schema.ts`
|
||||
- `src/components/tool-ui/code-diff/schema.ts`
|
||||
|
||||
Understand the props each component accepts.
|
||||
|
||||
### 2. Understand the toolkit registration pattern
|
||||
|
||||
In assistant-ui, tool calls are rendered by registering renderers in a `Toolkit` object. Each key is a tool name, and the value describes how to render it.
|
||||
|
||||
Use `assistantUIDocs` to look up:
|
||||
- `guides/tool-ui` — Generative UI / tool rendering
|
||||
- `copilots/make-assistant-tool-ui` — `makeAssistantToolUI` API
|
||||
|
||||
The toolkit pattern from Tool UI's quick-start:
|
||||
```tsx
|
||||
const toolkit: Toolkit = {
|
||||
toolName: {
|
||||
type: "backend",
|
||||
render: ({ result, args }) => {
|
||||
// Parse result, return Tool UI component
|
||||
return <Terminal {...parsedProps} />;
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Implement native-tools.tsx
|
||||
|
||||
Fill in `frontend/src/app/pages/AgentChat/toolkit/native-tools.tsx`.
|
||||
|
||||
Register renderers for each native tool type:
|
||||
|
||||
#### Bash → Terminal
|
||||
```tsx
|
||||
import { Terminal } from '@/components/tool-ui/terminal';
|
||||
|
||||
Bash: {
|
||||
type: "backend",
|
||||
render: ({ result, args }) => {
|
||||
const command = args?.command || '';
|
||||
// Parse result: { stdout, stderr, exitCode, elapsed_ms }
|
||||
return (
|
||||
<Terminal
|
||||
id={`bash-${toolCallId}`}
|
||||
command={command}
|
||||
stdout={parsed.stdout}
|
||||
stderr={parsed.stderr}
|
||||
exitCode={parsed.exitCode}
|
||||
durationMs={parsed.elapsed_ms}
|
||||
cwd={args?.working_directory}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Read → CodeBlock
|
||||
```tsx
|
||||
import { CodeBlock } from '@/components/tool-ui/code-block';
|
||||
|
||||
Read: {
|
||||
type: "backend",
|
||||
render: ({ result, args }) => {
|
||||
const filePath = args?.file_path || args?.path || '';
|
||||
const language = guessLanguage(filePath); // from extension
|
||||
return (
|
||||
<CodeBlock
|
||||
id={`read-${toolCallId}`}
|
||||
code={resultText}
|
||||
language={language}
|
||||
filename={filePath}
|
||||
lineNumbers="visible"
|
||||
/>
|
||||
);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Edit / Write → CodeDiff
|
||||
```tsx
|
||||
import { CodeDiff } from '@/components/tool-ui/code-diff';
|
||||
|
||||
Edit: {
|
||||
type: "backend",
|
||||
render: ({ result, args }) => {
|
||||
const filePath = args?.file_path || args?.path || '';
|
||||
return (
|
||||
<CodeDiff
|
||||
id={`edit-${toolCallId}`}
|
||||
oldCode={args?.old_string || ''}
|
||||
newCode={args?.new_string || ''}
|
||||
language={guessLanguage(filePath)}
|
||||
filename={filePath}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Grep / Glob / Search → Terminal
|
||||
These are search commands; display results in Terminal:
|
||||
```tsx
|
||||
Grep: {
|
||||
type: "backend",
|
||||
render: ({ result, args }) => {
|
||||
return (
|
||||
<Terminal
|
||||
id={`grep-${toolCallId}`}
|
||||
command={`grep ${args?.pattern || ''}`}
|
||||
stdout={resultText}
|
||||
exitCode={0}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Handle the "input" display
|
||||
|
||||
Currently `ToolCallBubble` shows the tool input (command, file path, etc.) in a terminal-style header. With Tool UI's `Terminal`, the `command` prop handles this. For `CodeBlock` and `CodeDiff`, the `filename` prop shows the context.
|
||||
|
||||
For tools that don't fit Terminal/CodeBlock/CodeDiff, create a simple fallback renderer that shows the tool name and JSON-formatted args.
|
||||
|
||||
### 5. Handle tool groups (ToolGroupBubble replacement)
|
||||
|
||||
`ToolGroupBubble` wraps multiple tool calls in a collapsible accordion with a generated SVG icon and completed/pending counters.
|
||||
|
||||
Check if assistant-ui has a `ChainOfThought` or `ToolGroup` primitive:
|
||||
- Look up `primitives/chain-of-thought` in assistant-ui docs
|
||||
- Look up `ui/reasoning` for grouping
|
||||
|
||||
If assistant-ui provides grouping, use it. If not, create a simple wrapper component that groups consecutive tool calls into a collapsible section. Put this in `toolkit/native-tools.tsx` as a helper.
|
||||
|
||||
### 6. Write a `guessLanguage` utility
|
||||
|
||||
Create a small utility function that maps file extensions to language names for CodeBlock:
|
||||
```typescript
|
||||
function guessLanguage(filePath: string): string {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript',
|
||||
py: 'python', rs: 'rust', go: 'go', rb: 'ruby', java: 'java',
|
||||
json: 'json', yaml: 'yaml', yml: 'yaml', md: 'markdown',
|
||||
html: 'html', css: 'css', scss: 'scss', sh: 'bash', bash: 'bash',
|
||||
sql: 'sql', xml: 'xml', toml: 'toml', // ... etc
|
||||
};
|
||||
return map[ext || ''] || 'text';
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Handle pending/streaming states
|
||||
|
||||
Currently `ToolCallBubble` shows:
|
||||
- A pulsing cursor when pending
|
||||
- A blinking cursor when streaming
|
||||
- An elapsed timer when pending
|
||||
|
||||
Tool UI's `Terminal` has built-in support for these states through its props. For pending state without result, you can render `Terminal` without stdout/stderr and it shows as in-progress. Check the exact behavior.
|
||||
|
||||
## Files Created / Modified
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `toolkit/native-tools.tsx` | **Fill in** (was stub from Agent 1) | All native tool registrations |
|
||||
|
||||
## Files Deleted (by this agent)
|
||||
|
||||
| File | Lines | Replaced By |
|
||||
|------|-------|------------|
|
||||
| `ToolCallBubble.tsx` | 172 | Toolkit registrations in `native-tools.tsx` |
|
||||
| `toolCallColors.tsx` | 211 | Tool UI Terminal handles ANSI colors natively |
|
||||
| `toolCallUtils.ts` | 249 | Simplified parsing in toolkit renderers |
|
||||
| `ElapsedTimer.tsx` | 41 | Terminal's `durationMs` prop |
|
||||
| `ToolGroupBubble.tsx` | 200 | ChainOfThought or custom grouping |
|
||||
|
||||
**Important**: Before deleting `toolCallUtils.ts`, check if other files import from it:
|
||||
- `McpServiceCards.tsx` imports `ParsedMcpResult`, `formatTimestamp` — these are owned by Agent 6
|
||||
- `GmailCard.tsx` imports `getGmailHeader`, `formatTimestamp`, `stripHtml`
|
||||
- `approvalUtils.tsx` imports `parseMcpToolName`
|
||||
|
||||
If other files import from `toolCallUtils.ts`, **do not delete it yet**. Instead, move the functions that only native-tools need into `native-tools.tsx`, and leave `toolCallUtils.ts` for other agents to consume. Agent 7 (Cleanup) will delete it once all consumers are gone.
|
||||
|
||||
Similarly for `ToolGroupBubble.tsx` — check if `AgentChat.tsx` imports `isToolGroup`/`isToolPair` from it. If so, coordinate with Agent 7.
|
||||
|
||||
**Total deleted: ~873 lines** (depending on shared imports)
|
||||
|
||||
## Files NOT Modified
|
||||
|
||||
- `AgentChat.tsx` — Agent 7 handles integration
|
||||
- `thread/`, `composer/` — owned by Agents 2 and 3
|
||||
- `toolkit/approval-tools.tsx`, `toolkit/mcp-tools.tsx` — owned by Agents 5 and 6
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `toolkit/native-tools.tsx` exports a `nativeToolkit` object
|
||||
- [ ] Bash tool calls render as `Terminal` with command, stdout, stderr, exitCode, duration
|
||||
- [ ] Read tool calls render as `CodeBlock` with syntax highlighting and filename
|
||||
- [ ] Edit tool calls render as `CodeDiff` with old/new code and filename
|
||||
- [ ] Grep/Glob/Search tool calls render as `Terminal`
|
||||
- [ ] Unknown native tools render a sensible fallback
|
||||
- [ ] No TypeScript errors
|
||||
- [ ] Deleted files don't break other imports (check before deleting)
|
||||
@@ -0,0 +1,229 @@
|
||||
# Migration Agent 5: Tool Toolkit — Approvals & Questions
|
||||
|
||||
## Objective
|
||||
|
||||
Register Tool UI components (`ApprovalCard`, `QuestionFlow`, `OptionList`) in the toolkit for rendering HITL approval requests and user questions. Replace `ApprovalBar`, `BatchApprovalBar`, `QuestionForm`, `ToolPreview`, and `approvalUtils`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Agent 1 must be complete.** Tool UI components must be installed in `src/components/tool-ui/`, and the toolkit skeleton must exist at `toolkit/approval-tools.tsx`.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **No custom styling**: Use default Tool UI ApprovalCard/QuestionFlow/OptionList appearance.
|
||||
- **Only modify `toolkit/approval-tools.tsx`** and create any helpers needed within it. Do NOT modify `AgentChat.tsx`, `thread/`, `composer/`, or other toolkit files.
|
||||
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`. Use it for assistant-ui API lookups.
|
||||
|
||||
## Key Files to Read First
|
||||
|
||||
Understand what you're replacing:
|
||||
- `frontend/src/app/pages/AgentChat/ApprovalBar.tsx` — single tool approval (195 lines)
|
||||
- `frontend/src/app/pages/AgentChat/BatchApprovalBar.tsx` — mass approve/deny (179 lines)
|
||||
- `frontend/src/app/pages/AgentChat/QuestionForm.tsx` — AskUserQuestion with options/multi-select/free-text (238 lines)
|
||||
- `frontend/src/app/pages/AgentChat/ToolPreview.tsx` — code preview of bash/read/write/edit/grep args (140 lines)
|
||||
- `frontend/src/app/pages/AgentChat/approvalUtils.tsx` — MCP tool metadata, integration icons (139 lines)
|
||||
|
||||
Also read the installed Tool UI schemas:
|
||||
- `src/components/tool-ui/approval-card/schema.ts`
|
||||
- `src/components/tool-ui/question-flow/schema.ts`
|
||||
- `src/components/tool-ui/option-list/schema.ts`
|
||||
|
||||
## Background: How Approvals Work in This App
|
||||
|
||||
The backend sends `approval_request` events over WebSocket. These are stored in `session.pending_approvals` in Redux:
|
||||
|
||||
```typescript
|
||||
interface ApprovalRequest {
|
||||
id: string;
|
||||
session_id: string;
|
||||
tool_name: string; // e.g. "Bash", "mcp__google-gmail__sendEmail"
|
||||
tool_input: Record<string, any>; // the tool args needing approval
|
||||
created_at: string;
|
||||
}
|
||||
```
|
||||
|
||||
The user approves or denies via `handleApprove(requestId, updatedInput?)` or `handleDeny(requestId, message?)` which dispatches to the backend.
|
||||
|
||||
Special case: `tool_name === 'AskUserQuestion'` renders a `QuestionForm` instead of an approval bar. The `tool_input` contains:
|
||||
```typescript
|
||||
{
|
||||
question: string;
|
||||
options?: Array<{ id, label, description? }>;
|
||||
allow_multiple?: boolean;
|
||||
allow_free_text?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
The user's response is sent back via `onApprove(requestId, { answer: selectedOptions })`.
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Read Tool UI component schemas
|
||||
|
||||
Read the installed schema files to understand each component's props:
|
||||
- `src/components/tool-ui/approval-card/schema.ts` — `ApprovalCard` props
|
||||
- `src/components/tool-ui/question-flow/schema.ts` — `QuestionFlow` props
|
||||
- `src/components/tool-ui/option-list/schema.ts` — `OptionList` props
|
||||
|
||||
### 2. Understand the rendering context
|
||||
|
||||
Approvals are NOT standard tool call results — they're rendered in a separate area below the message list in `AgentChat.tsx` (lines 199-205). They come from `session.pending_approvals`, not from the message stream.
|
||||
|
||||
This means approvals might not fit neatly into the toolkit registration pattern (which is for rendering tool call results inside the message thread). Instead, they may need to be rendered as standalone components in the approval area.
|
||||
|
||||
**Two approaches**:
|
||||
|
||||
**Approach A — Keep approvals as standalone components**: Create wrapper components in `toolkit/approval-tools.tsx` that use Tool UI's `ApprovalCard`, `QuestionFlow`, and `OptionList` internally. These are rendered in the approval area of `AgentChat.tsx` (Agent 7 wires this in).
|
||||
|
||||
**Approach B — Render approvals as tool call parts in the thread**: Map `pending_approvals` to tool messages in the runtime adapter, so they appear inline in the message thread. The toolkit renderers handle them.
|
||||
|
||||
Go with **Approach A** — it's simpler and matches the existing UX where approvals appear as a bar between the messages and the composer. The components just change from custom MUI to Tool UI.
|
||||
|
||||
### 3. Create approval wrapper components
|
||||
|
||||
#### SingleApproval → ApprovalCard
|
||||
|
||||
Map from `ApprovalRequest` to `ApprovalCard` props:
|
||||
|
||||
```tsx
|
||||
import { ApprovalCard } from '@/components/tool-ui/approval-card';
|
||||
|
||||
export const ToolApproval: React.FC<{
|
||||
request: ApprovalRequest;
|
||||
onApprove: (id: string) => void;
|
||||
onDeny: (id: string, message?: string) => void;
|
||||
}> = ({ request, onApprove, onDeny }) => {
|
||||
const parsedTool = parseMcpToolName(request.tool_name);
|
||||
|
||||
return (
|
||||
<ApprovalCard
|
||||
id={request.id}
|
||||
title={parsedTool.isMcp ? parsedTool.displayName : `Run ${request.tool_name}`}
|
||||
description={getApprovalDescription(request)}
|
||||
metadata={buildMetadata(request.tool_input)}
|
||||
variant={isDangerous(request.tool_name) ? 'destructive' : 'default'}
|
||||
confirmLabel="Approve"
|
||||
cancelLabel="Deny"
|
||||
onConfirm={() => onApprove(request.id)}
|
||||
onCancel={() => onDeny(request.id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
The `metadata` prop accepts key-value pairs, which maps well to showing the tool input args:
|
||||
```typescript
|
||||
function buildMetadata(toolInput: Record<string, any>): Array<{ key: string; value: string }> {
|
||||
return Object.entries(toolInput)
|
||||
.filter(([, v]) => v != null)
|
||||
.slice(0, 5)
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: typeof value === 'string' ? value.slice(0, 100) : JSON.stringify(value).slice(0, 100),
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
#### QuestionForm → QuestionFlow + OptionList
|
||||
|
||||
When `tool_name === 'AskUserQuestion'`, render a `QuestionFlow` (if multi-step) or `OptionList` (if single question with options):
|
||||
|
||||
```tsx
|
||||
import { OptionList } from '@/components/tool-ui/option-list';
|
||||
import { QuestionFlow } from '@/components/tool-ui/question-flow';
|
||||
|
||||
export const ToolQuestion: React.FC<{
|
||||
request: ApprovalRequest;
|
||||
onApprove: (id: string, updatedInput?: Record<string, any>) => void;
|
||||
onDeny: (id: string) => void;
|
||||
}> = ({ request, onApprove, onDeny }) => {
|
||||
const { question, options, allow_multiple, allow_free_text } = request.tool_input;
|
||||
|
||||
if (options?.length > 0) {
|
||||
return (
|
||||
<OptionList
|
||||
id={request.id}
|
||||
options={options.map(opt => ({
|
||||
id: opt.id || opt.value || opt.label,
|
||||
label: opt.label || opt.text,
|
||||
description: opt.description,
|
||||
}))}
|
||||
selectionMode={allow_multiple ? 'multi' : 'single'}
|
||||
actions={[
|
||||
{ id: 'confirm', label: 'Submit' },
|
||||
{ id: 'cancel', label: 'Skip', variant: 'secondary' },
|
||||
]}
|
||||
onAction={(actionId, selection) => {
|
||||
if (actionId === 'confirm') {
|
||||
onApprove(request.id, { answer: selection });
|
||||
} else {
|
||||
onDeny(request.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Free text question without options — render a simple card
|
||||
// (or use a text input approach)
|
||||
};
|
||||
```
|
||||
|
||||
#### BatchApprovalBar → composed ApprovalCards
|
||||
|
||||
When `session.pending_approvals.length > 1`, render a batch approval UI. Options:
|
||||
- Render multiple `ApprovalCard` instances stacked
|
||||
- Add a "Approve All" / "Deny All" header above them
|
||||
|
||||
### 4. Port MCP tool metadata
|
||||
|
||||
The existing `approvalUtils.tsx` has `parseMcpToolName()` and `useMcpToolMeta()` that look up MCP tool metadata (integration icons, descriptions) from the Redux store. Port these utility functions into the approval-tools file or a local helper.
|
||||
|
||||
**Important**: `parseMcpToolName` is also used by Agent 6 (for MCP service cards) and Agent 4 (in toolCallUtils). If you extract it, put it in a shared location that other agents can access, or duplicate the logic since it's small.
|
||||
|
||||
### 5. Handle ToolPreview replacement
|
||||
|
||||
`ToolPreview.tsx` renders a preview of tool arguments for native tools (showing the bash command, file path, edit diff, etc.). With Tool UI, this preview is naturally handled by the metadata display in `ApprovalCard`.
|
||||
|
||||
For more detailed previews (like showing the full bash command or code diff), you can nest a Tool UI `Terminal` or `CodeBlock` inside the approval area. But for now, the metadata key-value pairs should be sufficient.
|
||||
|
||||
## Files Created / Modified
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `toolkit/approval-tools.tsx` | **Fill in** (was stub) | Approval renderers: `ToolApproval`, `ToolQuestion`, batch wrapper |
|
||||
|
||||
## Files Deleted (by this agent)
|
||||
|
||||
| File | Lines | Replaced By |
|
||||
|------|-------|------------|
|
||||
| `ApprovalBar.tsx` | 195 | `ToolApproval` using `ApprovalCard` |
|
||||
| `BatchApprovalBar.tsx` | 179 | Batch wrapper using composed `ApprovalCard` |
|
||||
| `QuestionForm.tsx` | 238 | `ToolQuestion` using `OptionList` / `QuestionFlow` |
|
||||
| `ToolPreview.tsx` | 140 | `ApprovalCard` metadata + optional nested Terminal/CodeBlock |
|
||||
| `approvalUtils.tsx` | 139 | Utility functions ported to `approval-tools.tsx` |
|
||||
|
||||
**Important**: Before deleting `approvalUtils.tsx`, check if other files import `parseMcpToolName` from it. If `ToolCallBubble.tsx` or `McpServiceCards.tsx` imports it, leave the file until Agent 7 cleanup, or extract the shared function.
|
||||
|
||||
**Total deleted: ~891 lines**
|
||||
|
||||
## Files NOT Modified
|
||||
|
||||
- `AgentChat.tsx` — Agent 7 wires the new approval components into the approval area
|
||||
- `thread/`, `composer/` — owned by Agents 2 and 3
|
||||
- Other toolkit files — owned by Agents 4 and 6
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `toolkit/approval-tools.tsx` exports `approvalToolkit` and approval wrapper components
|
||||
- [ ] `ToolApproval` renders an `ApprovalCard` with title, description, metadata
|
||||
- [ ] Native tool approvals show tool name and key input args
|
||||
- [ ] MCP tool approvals show the integration name and action
|
||||
- [ ] Destructive variant is used for dangerous tools (e.g. Bash with `rm`, `delete`)
|
||||
- [ ] `ToolQuestion` renders `OptionList` for questions with options
|
||||
- [ ] Multi-select works for `allow_multiple: true` questions
|
||||
- [ ] Free-text questions render an input field
|
||||
- [ ] Approve/Deny callbacks work correctly
|
||||
- [ ] Batch approval renders multiple cards or a batch wrapper
|
||||
- [ ] No TypeScript errors
|
||||
- [ ] Deleted files don't break other imports
|
||||
@@ -0,0 +1,347 @@
|
||||
# Migration Agent 6: Tool Toolkit — MCP Services, Browser Feed & Custom Tools
|
||||
|
||||
## Objective
|
||||
|
||||
Register Tool UI components for MCP service results (`MessageDraft` for Gmail, `DataTable`/`ItemCarousel` for Calendar/Drive, `ProgressTracker` for browser feed). Wire existing `AgentToolBubble` (sub-agent rendering) and `ViewBubble` (iframe previews) into the toolkit as custom entries. Replace `GmailCard`, `McpServiceCards`, `BrowserAgentInlineFeed`, `BrowserFeedEntryRow`, `browserFeedUtils`, and `DiffViewer`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Agent 1 must be complete.** Tool UI components must be installed in `src/components/tool-ui/`, and the toolkit skeleton must exist at `toolkit/mcp-tools.tsx` and `toolkit/custom-tools.tsx`.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **No custom styling**: Use default Tool UI appearance for MessageDraft, DataTable, ProgressTracker.
|
||||
- **Only modify `toolkit/mcp-tools.tsx` and `toolkit/custom-tools.tsx`**. Do NOT modify `AgentChat.tsx`, `thread/`, `composer/`, or other toolkit files.
|
||||
- **Keep `AgentToolBubble.tsx` and `ViewBubble.tsx` as-is** — just wire them into the custom toolkit as renderers. Do not rewrite them.
|
||||
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`.
|
||||
|
||||
## Key Files to Read First
|
||||
|
||||
Understand what you're replacing:
|
||||
- `frontend/src/app/pages/AgentChat/McpServiceCards.tsx` — dispatches to Gmail/Calendar/Drive/Generic cards (158 lines)
|
||||
- `frontend/src/app/pages/AgentChat/GmailCard.tsx` — email list/detail rendering (136 lines)
|
||||
- `frontend/src/app/pages/AgentChat/BrowserAgentInlineFeed.tsx` — browser automation feed (164 lines)
|
||||
- `frontend/src/app/pages/AgentChat/BrowserFeedEntryRow.tsx` — feed entry rendering (120 lines)
|
||||
- `frontend/src/app/pages/AgentChat/browserFeedUtils.ts` — browser feed utilities (129 lines)
|
||||
- `frontend/src/app/pages/AgentChat/DiffViewer.tsx` — git diff panel (140 lines)
|
||||
|
||||
Understand what you're keeping and wiring:
|
||||
- `frontend/src/app/pages/AgentChat/AgentToolBubble.tsx` — InvokeAgent/CreateAgent (193 lines) — KEEP
|
||||
- `frontend/src/app/pages/AgentChat/ViewBubble.tsx` — iframe app preview (194 lines) — KEEP
|
||||
- `frontend/src/app/pages/AgentChat/ViewBubbleParts.tsx` — iframe parts (136 lines) — KEEP
|
||||
|
||||
Also read the installed Tool UI schemas:
|
||||
- `src/components/tool-ui/message-draft/schema.ts`
|
||||
- `src/components/tool-ui/data-table/schema.ts`
|
||||
- `src/components/tool-ui/progress-tracker/schema.ts`
|
||||
- `src/components/tool-ui/item-carousel/schema.ts` (if installed)
|
||||
|
||||
## Background: How MCP Tool Results Work
|
||||
|
||||
MCP tools have names like `mcp__google-gmail__search`, `mcp__google-calendar__listEvents`, `mcp__google-drive__listFiles`. The existing `parseMcpToolName()` function (in `toolCallUtils.ts`) parses these into:
|
||||
```typescript
|
||||
{
|
||||
isMcp: true,
|
||||
serverSlug: 'google-gmail',
|
||||
service: 'gmail',
|
||||
actionName: 'search',
|
||||
displayName: 'Search',
|
||||
}
|
||||
```
|
||||
|
||||
The `McpResultCard` component dispatches based on `service`:
|
||||
- `gmail` → `GmailCard`
|
||||
- `calendar` → `CalendarCard`
|
||||
- `drive` / `sheets` → `DriveCard`
|
||||
- anything else → `GenericMcpCard`
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Read Tool UI component schemas
|
||||
|
||||
Read the installed schemas:
|
||||
- `src/components/tool-ui/message-draft/schema.ts` — email/message rendering
|
||||
- `src/components/tool-ui/data-table/schema.ts` — table rendering
|
||||
- `src/components/tool-ui/progress-tracker/schema.ts` — step-by-step progress
|
||||
|
||||
### 2. Implement mcp-tools.tsx — Gmail → MessageDraft
|
||||
|
||||
The `GmailCard` renders:
|
||||
- **Email list**: Multiple email cards with subject, from, date, snippet
|
||||
- **Single email**: Subject header, from/to/date fields, labels, body (markdown), attachments
|
||||
|
||||
Map to Tool UI's `MessageDraft`:
|
||||
```tsx
|
||||
import { MessageDraft } from '@/components/tool-ui/message-draft';
|
||||
|
||||
// For single email results
|
||||
function renderGmailResult(data: any, action: string) {
|
||||
const email = extractEmailFields(data);
|
||||
return (
|
||||
<MessageDraft
|
||||
id={`gmail-${data.id || 'result'}`}
|
||||
from={email.from}
|
||||
to={email.to}
|
||||
subject={email.subject}
|
||||
body={email.bodyPreview || email.snippet}
|
||||
// MessageDraft may have additional props for sent state, etc.
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For **email list results** (search/list), use `DataTable`:
|
||||
```tsx
|
||||
import { DataTable } from '@/components/tool-ui/data-table';
|
||||
|
||||
function renderGmailList(messages: any[]) {
|
||||
return (
|
||||
<DataTable
|
||||
id="gmail-list"
|
||||
columns={[
|
||||
{ key: 'from', label: 'From', priority: 'primary' },
|
||||
{ key: 'subject', label: 'Subject' },
|
||||
{ key: 'date', label: 'Date', format: { kind: 'date', dateFormat: 'relative' } },
|
||||
{ key: 'snippet', label: 'Preview', truncate: true },
|
||||
]}
|
||||
data={messages.map((msg, i) => ({
|
||||
id: String(i),
|
||||
...extractEmailFields(msg),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Implement mcp-tools.tsx — Calendar → DataTable
|
||||
|
||||
The `CalendarCard` renders:
|
||||
- **Event list**: Cards with summary + date
|
||||
- **Single event**: Summary, start, end, location, description
|
||||
|
||||
Map event lists to `DataTable`:
|
||||
```tsx
|
||||
function renderCalendarList(items: any[]) {
|
||||
return (
|
||||
<DataTable
|
||||
id="calendar-list"
|
||||
columns={[
|
||||
{ key: 'summary', label: 'Event', priority: 'primary' },
|
||||
{ key: 'start', label: 'Start', format: { kind: 'date', dateFormat: 'short' } },
|
||||
{ key: 'end', label: 'End', format: { kind: 'date', dateFormat: 'short' } },
|
||||
{ key: 'location', label: 'Location' },
|
||||
]}
|
||||
data={items.map((item, i) => ({
|
||||
id: String(i),
|
||||
summary: item.summary || '(no title)',
|
||||
start: item.start?.dateTime || item.start?.date || '',
|
||||
end: item.end?.dateTime || item.end?.date || '',
|
||||
location: item.location || '',
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Implement mcp-tools.tsx — Drive → DataTable
|
||||
|
||||
The `DriveCard` renders file lists with name and mimeType. Map to `DataTable`:
|
||||
```tsx
|
||||
function renderDriveFiles(files: any[]) {
|
||||
return (
|
||||
<DataTable
|
||||
id="drive-files"
|
||||
columns={[
|
||||
{ key: 'name', label: 'File', priority: 'primary' },
|
||||
{ key: 'mimeType', label: 'Type' },
|
||||
]}
|
||||
data={files.map((f, i) => ({
|
||||
id: String(i),
|
||||
name: f.name || f.id,
|
||||
mimeType: f.mimeType?.split('/').pop() || '',
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Implement mcp-tools.tsx — Generic MCP fallback
|
||||
|
||||
For MCP tools without a specific handler, render a simple key-value display. Use `DataTable` with two columns (key, value) or create a minimal fallback component.
|
||||
|
||||
### 6. Register MCP tool renderers in the toolkit
|
||||
|
||||
The challenge: MCP tool names are dynamic (`mcp__<server>__<action>`). You can't register every possible tool name in the toolkit.
|
||||
|
||||
**Solution**: Use a catch-all pattern. Check if assistant-ui supports a `ToolFallback` component or a wildcard toolkit entry. Look up `ui/tool-fallback` in the docs.
|
||||
|
||||
If the toolkit supports a fallback/default renderer:
|
||||
```tsx
|
||||
export const mcpToolkit = {
|
||||
// Specific MCP tools can be registered by name if desired
|
||||
// Generic fallback handles all MCP tools
|
||||
__fallback__: {
|
||||
type: "backend",
|
||||
render: ({ result, args, toolName }) => {
|
||||
const mcpInfo = parseMcpToolName(toolName);
|
||||
if (!mcpInfo.isMcp) return null; // Let other handlers deal with it
|
||||
|
||||
switch (mcpInfo.service) {
|
||||
case 'gmail': return renderGmailResult(result, mcpInfo.actionName);
|
||||
case 'calendar': return renderCalendarResult(result, mcpInfo.actionName);
|
||||
case 'drive': return renderDriveResult(result);
|
||||
default: return renderGenericMcp(result);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
If assistant-ui doesn't support a fallback, register a `ToolFallback` component that checks if the tool name starts with `mcp__` and routes accordingly.
|
||||
|
||||
### 7. Implement mcp-tools.tsx — Browser Feed → ProgressTracker
|
||||
|
||||
The `BrowserAgentInlineFeed` renders a compact activity log of browser automation steps (navigate, click, type, screenshot) with status indicators.
|
||||
|
||||
Map to `ProgressTracker`:
|
||||
```tsx
|
||||
import { ProgressTracker } from '@/components/tool-ui/progress-tracker';
|
||||
|
||||
function renderBrowserFeed(entries: BrowserFeedEntry[]) {
|
||||
return (
|
||||
<ProgressTracker
|
||||
id="browser-feed"
|
||||
steps={entries.map(entry => ({
|
||||
id: entry.id,
|
||||
label: entry.action, // "navigate", "click", "type"
|
||||
description: entry.detail, // URL, selector, text
|
||||
status: entry.status === 'done' ? 'completed'
|
||||
: entry.status === 'error' ? 'failed'
|
||||
: entry.status === 'running' ? 'in-progress'
|
||||
: 'pending',
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The browser feed data comes from Redux (via `useBrowserActivity` or similar). The renderer needs to read this data. Since it's rendered inside a tool call bubble (for `BrowserAgent` tool), the data should be accessible from the tool result or via Redux selector.
|
||||
|
||||
### 8. Implement mcp-tools.tsx — DiffViewer → CodeDiff
|
||||
|
||||
The `DiffViewer.tsx` fetches a git worktree diff from the API and renders colorized diff output. Replace with Tool UI's `CodeDiff`:
|
||||
|
||||
```tsx
|
||||
import { CodeDiff } from '@/components/tool-ui/code-diff';
|
||||
|
||||
// DiffViewer fetches diff text from /api/agents/sessions/{id}/worktree-diff
|
||||
// This is rendered as a side panel, not a tool call result.
|
||||
// It may need to stay as a standalone component, just using CodeDiff internally.
|
||||
```
|
||||
|
||||
**Note**: DiffViewer is rendered in `ChatHeader`, not as a tool call. It may not fit the toolkit pattern. Two options:
|
||||
- Replace the rendering logic inside DiffViewer to use `CodeDiff` component but keep the wrapper
|
||||
- Or just replace the internals
|
||||
|
||||
Go with replacing the internals: keep a thin wrapper that fetches the diff and passes it to `CodeDiff`.
|
||||
|
||||
### 9. Implement custom-tools.tsx — Wire AgentToolBubble and ViewBubble
|
||||
|
||||
These are kept as-is but registered in the toolkit so assistant-ui knows how to render them:
|
||||
|
||||
```tsx
|
||||
import { InvokeAgentBubble, CreateAgentBubble } from '../AgentToolBubble';
|
||||
import ViewBubble from '../ViewBubble';
|
||||
|
||||
export const customToolkit = {
|
||||
InvokeAgent: {
|
||||
type: "backend",
|
||||
render: ({ result, args, toolCallId }) => (
|
||||
<InvokeAgentBubble
|
||||
call={{ id: toolCallId, content: { tool: 'InvokeAgent', input: args }, ... }}
|
||||
result={result ? { content: result } : null}
|
||||
isPending={!result}
|
||||
isStreaming={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
CreateAgent: {
|
||||
type: "backend",
|
||||
render: ({ result, args, toolCallId }) => (
|
||||
<CreateAgentBubble
|
||||
call={{ id: toolCallId, content: { tool: 'CreateAgent', input: args }, ... }}
|
||||
result={result ? { content: result } : null}
|
||||
isPending={!result}
|
||||
isStreaming={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
RenderOutput: {
|
||||
type: "backend",
|
||||
render: ({ result, args, toolCallId }) => (
|
||||
<ViewBubble
|
||||
call={{ id: toolCallId, content: { tool: 'RenderOutput', input: args }, ... }}
|
||||
result={result ? { content: result } : null}
|
||||
isPending={!result}
|
||||
isStreaming={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Adapt the props to match what `AgentToolBubble` and `ViewBubble` expect. Read those files to understand their prop interfaces.
|
||||
|
||||
### 10. Port `parseMcpToolName` and `extractEmailFields`
|
||||
|
||||
These utility functions are needed by the MCP toolkit. Port them into `mcp-tools.tsx` or a local helper file. Keep them minimal — only port what you need.
|
||||
|
||||
## Files Created / Modified
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `toolkit/mcp-tools.tsx` | **Fill in** (was stub) | Gmail, Calendar, Drive, Generic MCP, Browser feed, DiffViewer renderers |
|
||||
| `toolkit/custom-tools.tsx` | **Fill in** (was stub) | AgentToolBubble, ViewBubble wrappers |
|
||||
|
||||
## Files Deleted (by this agent)
|
||||
|
||||
| File | Lines | Replaced By |
|
||||
|------|-------|------------|
|
||||
| `GmailCard.tsx` | 136 | MessageDraft + DataTable in `mcp-tools.tsx` |
|
||||
| `McpServiceCards.tsx` | 158 | Routing logic in `mcp-tools.tsx` |
|
||||
| `BrowserAgentInlineFeed.tsx` | 164 | ProgressTracker in `mcp-tools.tsx` |
|
||||
| `BrowserFeedEntryRow.tsx` | 120 | ProgressTracker step rendering |
|
||||
| `browserFeedUtils.ts` | 129 | Simplified in `mcp-tools.tsx` |
|
||||
| `DiffViewer.tsx` | 140 | CodeDiff (keep thin wrapper if needed for API fetch) |
|
||||
|
||||
**Important**: `DiffViewer` is rendered in `ChatHeader.tsx`, not as a tool call. Before deleting, check how it's used. If it's a side panel that fetches from an API, you may want to keep a thin wrapper that uses `CodeDiff` internally rather than fully deleting it.
|
||||
|
||||
**Total deleted: ~847 lines**
|
||||
|
||||
## Files Kept (wired as custom toolkit entries)
|
||||
|
||||
| File | Lines | Action |
|
||||
|------|-------|--------|
|
||||
| `AgentToolBubble.tsx` | 193 | Kept, registered in `custom-tools.tsx` |
|
||||
| `ViewBubble.tsx` | 194 | Kept, registered in `custom-tools.tsx` |
|
||||
| `ViewBubbleParts.tsx` | 136 | Kept (dependency of ViewBubble) |
|
||||
|
||||
## Files NOT Modified
|
||||
|
||||
- `AgentChat.tsx` — Agent 7 handles integration
|
||||
- `thread/`, `composer/` — owned by Agents 2 and 3
|
||||
- Other toolkit files — owned by Agents 4 and 5
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `toolkit/mcp-tools.tsx` exports `mcpToolkit` with MCP tool renderers
|
||||
- [ ] Gmail email results render as `MessageDraft` (single) or `DataTable` (list)
|
||||
- [ ] Calendar events render as `DataTable`
|
||||
- [ ] Drive files render as `DataTable`
|
||||
- [ ] Unknown MCP tools render a generic key-value fallback
|
||||
- [ ] Browser agent activity renders as `ProgressTracker` steps
|
||||
- [ ] `toolkit/custom-tools.tsx` exports `customToolkit` with `InvokeAgent`, `CreateAgent`, `RenderOutput`
|
||||
- [ ] `AgentToolBubble` and `ViewBubble` render correctly through the toolkit
|
||||
- [ ] DiffViewer rendering uses `CodeDiff` internally
|
||||
- [ ] No TypeScript errors
|
||||
- [ ] Deleted files don't break other imports
|
||||
@@ -0,0 +1,257 @@
|
||||
# Migration Agent 7: Integration, SkillBuilderChat & Cleanup
|
||||
|
||||
## Objective
|
||||
|
||||
Wire all Phase 2 outputs together in `AgentChat.tsx`. Migrate `SkillBuilderChat.tsx`. Delete all remaining dead code. Verify the build passes and the chat is functional end-to-end.
|
||||
|
||||
**This agent runs last, after ALL Phase 2 agents (2–6) are complete.**
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Agents 1–6 must ALL be complete.**
|
||||
- Agent 1: Runtime adapter, toolkit skeleton, providers exist
|
||||
- Agent 2: `thread/OpenSwarmThread.tsx` exists and works
|
||||
- Agent 3: `composer/OpenSwarmComposer.tsx` exists and works
|
||||
- Agent 4: `toolkit/native-tools.tsx` is filled in
|
||||
- Agent 5: `toolkit/approval-tools.tsx` is filled in
|
||||
- Agent 6: `toolkit/mcp-tools.tsx` and `toolkit/custom-tools.tsx` are filled in
|
||||
|
||||
## Constraints
|
||||
|
||||
- **No custom styling**: Do not add CSS to make assistant-ui/Tool UI match MUI.
|
||||
- **The assistant-ui MCP docs server is available** in `.cursor/mcp.json`.
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Rewrite AgentChat.tsx
|
||||
|
||||
This is the main integration task. Replace the current `AgentChat.tsx` (250 lines) with the new structure.
|
||||
|
||||
The current structure:
|
||||
```
|
||||
AgentChat
|
||||
├── ChatHeader (keep)
|
||||
├── Scroll container with message render loop (REPLACE → OpenSwarmThread)
|
||||
│ ├── renderItems.map (messages, tool groups, tool pairs) (REPLACE)
|
||||
│ ├── Streaming message (REPLACE — handled by runtime)
|
||||
│ ├── ThinkingBubble (REPLACE — handled by Thread)
|
||||
│ ├── Resume bubble (KEEP)
|
||||
│ └── Scroll-to-bottom button (REPLACE — Thread handles this)
|
||||
├── ApprovalBar / BatchApprovalBar (REPLACE → approval-tools components)
|
||||
├── Glow CTA (keep)
|
||||
└── MessageQueue > ChatInput (REPLACE ChatInput → OpenSwarmComposer)
|
||||
```
|
||||
|
||||
The new structure:
|
||||
```tsx
|
||||
import { AssistantRuntimeProvider } from '@assistant-ui/react';
|
||||
import { useOpenSwarmRuntime } from './runtime/useOpenSwarmRuntime';
|
||||
import { toolkit } from './toolkit';
|
||||
import { OpenSwarmThread } from './thread/OpenSwarmThread';
|
||||
import { OpenSwarmComposer } from './composer/OpenSwarmComposer';
|
||||
import { ToolApproval, ToolQuestion, BatchApproval } from './toolkit/approval-tools';
|
||||
import ChatHeader from './ChatHeader';
|
||||
import MessageQueue from './MessageQueue';
|
||||
|
||||
const AgentChat = ({ sessionId, ... }) => {
|
||||
const runtime = useOpenSwarmRuntime(sessionId);
|
||||
const { session, handleApprove, handleDeny, handleStop, ... } = useAgentChat({ sessionId });
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Box sx={{ display: 'flex', height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
|
||||
{!embedded && <ChatHeader session={session} ... />}
|
||||
|
||||
{/* Thread replaces the scroll container + message loop */}
|
||||
<OpenSwarmThread />
|
||||
|
||||
{/* Resume bubble (keep existing) */}
|
||||
{showResumeBubble && session.status === 'stopped' && (
|
||||
<ResumeButton onClick={handleResume} />
|
||||
)}
|
||||
|
||||
{/* Approvals area — now using Tool UI components */}
|
||||
{session.pending_approvals.length > 1 ? (
|
||||
<BatchApproval requests={session.pending_approvals} onApprove={handleApprove} onDeny={handleDeny} />
|
||||
) : (
|
||||
session.pending_approvals.map((req) => (
|
||||
req.tool_name === 'AskUserQuestion'
|
||||
? <ToolQuestion key={req.id} request={req} onApprove={handleApprove} onDeny={handleDeny} />
|
||||
: <ToolApproval key={req.id} request={req} onApprove={handleApprove} onDeny={handleDeny} />
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Composer replaces ChatInput */}
|
||||
{isGlowing ? (
|
||||
<GlowCTA onClick={onDismissGlow} />
|
||||
) : (
|
||||
<MessageQueue ...>
|
||||
<OpenSwarmComposer
|
||||
mode={mode}
|
||||
onModeChange={handleModeChange}
|
||||
model={model}
|
||||
onModelChange={handleModelChange}
|
||||
isRunning={agentBusy}
|
||||
onStop={handleStop}
|
||||
contextEstimate={contextEstimate}
|
||||
sessionId={id}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</MessageQueue>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Register the toolkit
|
||||
|
||||
Check how assistant-ui registers the toolkit. It may be via:
|
||||
- `Tools` component or `useAui` hook (check current docs)
|
||||
- Props on `AssistantRuntimeProvider`
|
||||
- A separate context provider
|
||||
|
||||
Use `assistantUIDocs` to look up:
|
||||
- `copilots/model-context` — how to provide tools/toolkit
|
||||
- `guides/tool-ui` — how toolkit is registered
|
||||
|
||||
Make sure ALL toolkit entries (native + approvals + MCP + custom) are registered so tool calls render correctly.
|
||||
|
||||
### 3. Clean up useAgentChat hook
|
||||
|
||||
The `useAgentChat` hook (`hooks/useAgentChat.ts`) currently manages:
|
||||
- Session state and WebSocket lifecycle (KEEP)
|
||||
- Scroll container ref + scroll handlers (REMOVE — Thread handles scroll)
|
||||
- `handleSend` (KEEP — still used by runtime adapter or Composer)
|
||||
- Approval handlers (KEEP)
|
||||
- Stop/resume handlers (KEEP)
|
||||
|
||||
Remove the scroll-related code:
|
||||
- `scrollContainerRef` — Thread manages its own scroll
|
||||
- `showScrollButton` — Thread has built-in scroll-to-bottom
|
||||
- `handleScroll`, `scrollToBottom` — Thread handles these
|
||||
|
||||
Also remove `chatInputRef` if the Composer no longer uses it (replaced by ComposerRuntime API).
|
||||
|
||||
### 4. Migrate SkillBuilderChat
|
||||
|
||||
Read `frontend/src/app/pages/Skills/SkillBuilderChat.tsx` (403 lines).
|
||||
|
||||
It embeds `AgentChat` in `embedded` mode:
|
||||
```tsx
|
||||
<AgentChat
|
||||
sessionId={draftSessionId}
|
||||
embedded
|
||||
autoFocus
|
||||
initialContextPaths={...}
|
||||
/>
|
||||
```
|
||||
|
||||
Since `AgentChat` is being rewritten with assistant-ui, `SkillBuilderChat` should work as-is **if** the `embedded` prop behavior is preserved. Verify:
|
||||
- `embedded` hides the `ChatHeader`
|
||||
- The Composer still renders in embedded mode
|
||||
- The Thread fills the available space
|
||||
|
||||
If `SkillBuilderChat` used `ChatInputHandle` ref to programmatically set content:
|
||||
- Check if Agent 3 created a `useComposerHandle` hook
|
||||
- Update `SkillBuilderChat` to use the new API (ComposerRuntime's `setText()`)
|
||||
|
||||
### 5. Delete all remaining dead files
|
||||
|
||||
After integration, these files should no longer be imported anywhere:
|
||||
|
||||
**Already deleted by Phase 2 agents** (verify they're gone):
|
||||
- Agent 2: `MessageBubble`, `UserBubbleContent`, `AssistantBubbleContent`, `MessageActionBar`, `BranchNavigator`, `ThinkingBubble`, `MessageImageThumbnails`, `messageBubbleUtils`, `useMessageRendering`
|
||||
- Agent 3: `ChatInput`, `useChatSubmit`, `CommandPicker`, `commandPickerTypes`, `useCommandPickerItems`, `CommandPickerIcons`, `SlashCommandPicker`, `AttachmentChips`, `ImageAttachments`
|
||||
- Agent 4: `ToolCallBubble`, `toolCallColors`, `ElapsedTimer`, `ToolGroupBubble`
|
||||
- Agent 5: `ApprovalBar`, `BatchApprovalBar`, `QuestionForm`, `ToolPreview`, `approvalUtils`
|
||||
- Agent 6: `GmailCard`, `McpServiceCards`, `BrowserAgentInlineFeed`, `BrowserFeedEntryRow`, `browserFeedUtils`, `DiffViewer`
|
||||
|
||||
**Files that may have been left due to shared imports** — delete now if no longer needed:
|
||||
- `toolCallUtils.ts` — functions may have been ported to toolkit files
|
||||
- `richEditorUtils.ts` — functions may have been moved to RichPromptEditor
|
||||
- `AttachedContextSection.tsx` — check if Thread's UserMessage uses it
|
||||
|
||||
Run a grep/search for any remaining imports of deleted files. Fix any broken imports.
|
||||
|
||||
### 6. Remove unused npm packages
|
||||
|
||||
After the migration, some packages may no longer be needed:
|
||||
- `react-markdown` — if assistant-ui's Markdown component replaces all usage. **Check**: `RichPromptEditor` or other non-chat pages may still use it.
|
||||
- `remark-gfm` — same as above
|
||||
|
||||
Only remove if truly unused across the entire codebase.
|
||||
|
||||
### 7. Clean up agentsSlice streaming reducers
|
||||
|
||||
The `streamStart`, `streamDelta`, `streamEnd` reducers in the agents Redux slice may be simplified since the ExternalStoreRuntime adapter handles streaming display state. However, keep them if:
|
||||
- The WebSocketManager still dispatches them
|
||||
- The runtime adapter reads `session.streamingMessage` from Redux
|
||||
|
||||
Don't remove them if removing would break the data flow. Just leave them — they're small.
|
||||
|
||||
### 8. Verify the build
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
Fix any TypeScript errors, missing imports, or broken references.
|
||||
|
||||
### 9. Manual smoke test
|
||||
|
||||
Start the dev server and verify:
|
||||
- [ ] Chat page loads
|
||||
- [ ] Existing sessions show their message history
|
||||
- [ ] Can type in the Composer and send a message
|
||||
- [ ] `@` trigger opens the mention popover with categories
|
||||
- [ ] `/` trigger opens command categories (templates, skills, modes)
|
||||
- [ ] Streaming responses animate token-by-token
|
||||
- [ ] Tool calls render with Terminal/CodeBlock/CodeDiff components
|
||||
- [ ] MCP tool results render (Gmail, Calendar, Drive)
|
||||
- [ ] Approval requests show ApprovalCard
|
||||
- [ ] AskUserQuestion shows OptionList/QuestionFlow
|
||||
- [ ] Branch navigation works
|
||||
- [ ] Copy/edit/regenerate actions work
|
||||
- [ ] Scroll-to-bottom works
|
||||
- [ ] SkillBuilderChat works in embedded mode
|
||||
- [ ] Dashboard page still works (no regressions)
|
||||
- [ ] Settings/Modes/Templates pages still work
|
||||
|
||||
## Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `AgentChat.tsx` | Rewritten to use assistant-ui Thread + Composer + toolkit |
|
||||
| `hooks/useAgentChat.ts` | Removed scroll-related code |
|
||||
| `SkillBuilderChat.tsx` | Updated to work with new AgentChat (if needed) |
|
||||
|
||||
## Files Deleted (remaining dead code)
|
||||
|
||||
| File | Reason |
|
||||
|------|--------|
|
||||
| `toolCallUtils.ts` | Functions ported to toolkit files |
|
||||
| `richEditorUtils.ts` | Functions moved to RichPromptEditor (if still needed) |
|
||||
| `AttachedContextSection.tsx` | Context rendering moved to UserMessage in Thread |
|
||||
| Any other orphaned files | Verify with import search |
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [ ] `npm run build` succeeds with no errors
|
||||
- [ ] `npm run dev` starts the dev server
|
||||
- [ ] Chat page renders with assistant-ui Thread
|
||||
- [ ] Messages display correctly (user + assistant)
|
||||
- [ ] Tool calls render with Tool UI components
|
||||
- [ ] Approvals render with ApprovalCard
|
||||
- [ ] Questions render with OptionList/QuestionFlow
|
||||
- [ ] Composer works with Mention popover
|
||||
- [ ] Streaming works
|
||||
- [ ] Branching works
|
||||
- [ ] SkillBuilderChat works
|
||||
- [ ] No console errors
|
||||
- [ ] No broken imports (grep for imports of deleted files)
|
||||
- [ ] Non-chat pages (Dashboard, Settings, Modes, Templates, Tools, Views) still work
|
||||
@@ -11,9 +11,10 @@ import os
|
||||
import sys
|
||||
|
||||
from backend.apps.agents.models import AgentSession
|
||||
from backend.apps.agents.prompt_builder import (
|
||||
resolve_mode, compose_system_prompt, build_connected_tools_context,
|
||||
build_outputs_context, build_browser_context, get_pre_selected_browser_ids,
|
||||
from backend.apps.agents.prompt_builder import resolve_mode, compose_system_prompt
|
||||
from backend.apps.agents.prompt_context import (
|
||||
build_connected_tools_context, build_outputs_context,
|
||||
build_browser_context, get_pre_selected_browser_ids,
|
||||
)
|
||||
from backend.apps.agents.mcp_builder import (
|
||||
FULL_TOOLS, build_mcp_servers, get_all_tool_names,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tool schema definitions for the browser agent delegation MCP server."""
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "CreateBrowserAgent",
|
||||
"description": (
|
||||
"Create a new browser card and run a task on it. A dedicated browser agent "
|
||||
"will autonomously perform the task (navigating, clicking, typing, etc.) "
|
||||
"and return a summary of actions taken plus a final screenshot. "
|
||||
"Use this when you need a fresh browser for a new task."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The task for the browser agent to perform. Be specific and "
|
||||
"detailed about what you want accomplished."
|
||||
),
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional starting URL. The new browser will navigate here "
|
||||
"before beginning the task."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["task"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserAgent",
|
||||
"description": (
|
||||
"Delegate a browser task to a dedicated browser agent on an existing "
|
||||
"browser card. The browser agent will autonomously perform the task "
|
||||
"(navigating, clicking, typing, etc.) and return a summary of actions "
|
||||
"taken plus a final screenshot."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the existing browser card to use.",
|
||||
},
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The task for the browser agent to perform. Be specific and "
|
||||
"detailed about what you want accomplished."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "task"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserAgents",
|
||||
"description": (
|
||||
"Delegate multiple browser tasks to run in parallel, each on an existing "
|
||||
"browser card. All tasks execute concurrently and results are returned "
|
||||
"together. Use this when you need to perform tasks on multiple web pages "
|
||||
"simultaneously."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"description": "Array of browser tasks to run in parallel.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the existing browser card to use.",
|
||||
},
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "The task for this browser agent.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "task"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["tasks"],
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -20,6 +20,8 @@ try:
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
|
||||
from browser_agent_mcp_schemas import TOOLS # noqa: E402 (sibling script import)
|
||||
|
||||
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/agents/browser-agent/run"
|
||||
MODEL = os.environ.get("OPENSWARM_AGENT_MODEL", "sonnet")
|
||||
@@ -27,97 +29,6 @@ DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
|
||||
PRE_SELECTED_BROWSER_IDS = os.environ.get("OPENSWARM_PRE_SELECTED_BROWSER_IDS", "")
|
||||
PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "CreateBrowserAgent",
|
||||
"description": (
|
||||
"Create a new browser card and run a task on it. A dedicated browser agent "
|
||||
"will autonomously perform the task (navigating, clicking, typing, etc.) "
|
||||
"and return a summary of actions taken plus a final screenshot. "
|
||||
"Use this when you need a fresh browser for a new task."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The task for the browser agent to perform. Be specific and "
|
||||
"detailed about what you want accomplished."
|
||||
),
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional starting URL. The new browser will navigate here "
|
||||
"before beginning the task."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["task"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserAgent",
|
||||
"description": (
|
||||
"Delegate a browser task to a dedicated browser agent on an existing "
|
||||
"browser card. The browser agent will autonomously perform the task "
|
||||
"(navigating, clicking, typing, etc.) and return a summary of actions "
|
||||
"taken plus a final screenshot."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the existing browser card to use.",
|
||||
},
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"The task for the browser agent to perform. Be specific and "
|
||||
"detailed about what you want accomplished."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "task"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserAgents",
|
||||
"description": (
|
||||
"Delegate multiple browser tasks to run in parallel, each on an existing "
|
||||
"browser card. All tasks execute concurrently and results are returned "
|
||||
"together. Use this when you need to perform tasks on multiple web pages "
|
||||
"simultaneously."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"description": "Array of browser tasks to run in parallel.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the existing browser card to use.",
|
||||
},
|
||||
"task": {
|
||||
"type": "string",
|
||||
"description": "The task for this browser agent.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "task"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["tasks"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def send_response(id_, result=None, error=None):
|
||||
msg = {"jsonrpc": "2.0", "id": id_}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Tool schema definitions for the browser MCP server."""
|
||||
|
||||
TAB_ID_PROP = {
|
||||
"type": "string",
|
||||
"description": "Optional tab ID within the browser card. If omitted, targets the active tab.",
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "BrowserScreenshot",
|
||||
"description": (
|
||||
"Capture a screenshot of the browser page. Returns the screenshot as a "
|
||||
"base64-encoded PNG image. Use this to see what is currently displayed."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID to capture. Use the ID from the selected browser card context.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
},
|
||||
"required": ["browser_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserGetText",
|
||||
"description": (
|
||||
"Get the visible text content of the browser page. Returns the page's "
|
||||
"innerText (up to 15000 characters)."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
},
|
||||
"required": ["browser_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserNavigate",
|
||||
"description": "Navigate the browser to a URL.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The URL to navigate to.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserClick",
|
||||
"description": (
|
||||
"Click an element in the browser page identified by a CSS selector."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector of the element to click.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "selector"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserType",
|
||||
"description": (
|
||||
"Type text into an input element in the browser page. Clears the "
|
||||
"existing value first, then types the new text."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector of the input element.",
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "The text to type.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "selector", "text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserEvaluate",
|
||||
"description": (
|
||||
"Evaluate a JavaScript expression in the browser page and return the result. "
|
||||
"The expression is run via executeJavaScript on the webview."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "JavaScript expression to evaluate.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "expression"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserGetElements",
|
||||
"description": (
|
||||
"Get a list of interactive elements on the page with their CSS selectors. "
|
||||
"Returns clickable elements, inputs, links, and buttons with selector paths "
|
||||
"you can use with BrowserClick and BrowserType. Call this BEFORE attempting "
|
||||
"to click or type so you know which selectors are valid."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional CSS selector to scope the search "
|
||||
"(e.g. 'form', '#main'). Defaults to 'body'."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["browser_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserScroll",
|
||||
"description": (
|
||||
"Scroll the page up or down. Automatically finds the correct scrollable "
|
||||
"container (works on SPAs like Notion, Gmail, etc. that use nested scroll "
|
||||
"containers instead of window-level scrolling). Returns scroll position info "
|
||||
"including whether top/bottom has been reached."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"direction": {
|
||||
"type": "string",
|
||||
"enum": ["up", "down"],
|
||||
"description": "Scroll direction. Defaults to 'down'.",
|
||||
},
|
||||
"amount": {
|
||||
"type": "number",
|
||||
"description": "Pixels to scroll. Defaults to 500.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserWait",
|
||||
"description": (
|
||||
"Wait for a specified duration. Useful after navigation or actions that "
|
||||
"trigger page loads, animations, or async content rendering. "
|
||||
"Min 100ms, max 10000ms."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"milliseconds": {
|
||||
"type": "number",
|
||||
"description": "Duration to wait in milliseconds. Defaults to 1000.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id"],
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -21,220 +21,11 @@ try:
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
|
||||
from browser_mcp_schemas import TOOLS # noqa: E402 (sibling script import)
|
||||
|
||||
BACKEND_PORT = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
BACKEND_URL = f"http://127.0.0.1:{BACKEND_PORT}/api/agents/browser/command"
|
||||
|
||||
TAB_ID_PROP = {
|
||||
"type": "string",
|
||||
"description": "Optional tab ID within the browser card. If omitted, targets the active tab.",
|
||||
}
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "BrowserScreenshot",
|
||||
"description": (
|
||||
"Capture a screenshot of the browser page. Returns the screenshot as a "
|
||||
"base64-encoded PNG image. Use this to see what is currently displayed."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID to capture. Use the ID from the selected browser card context.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
},
|
||||
"required": ["browser_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserGetText",
|
||||
"description": (
|
||||
"Get the visible text content of the browser page. Returns the page's "
|
||||
"innerText (up to 15000 characters)."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
},
|
||||
"required": ["browser_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserNavigate",
|
||||
"description": "Navigate the browser to a URL.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "The URL to navigate to.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserClick",
|
||||
"description": (
|
||||
"Click an element in the browser page identified by a CSS selector."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector of the element to click.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "selector"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserType",
|
||||
"description": (
|
||||
"Type text into an input element in the browser page. Clears the "
|
||||
"existing value first, then types the new text."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": "CSS selector of the input element.",
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "The text to type.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "selector", "text"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserEvaluate",
|
||||
"description": (
|
||||
"Evaluate a JavaScript expression in the browser page and return the result. "
|
||||
"The expression is run via executeJavaScript on the webview."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "JavaScript expression to evaluate.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id", "expression"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserGetElements",
|
||||
"description": (
|
||||
"Get a list of interactive elements on the page with their CSS selectors. "
|
||||
"Returns clickable elements, inputs, links, and buttons with selector paths "
|
||||
"you can use with BrowserClick and BrowserType. Call this BEFORE attempting "
|
||||
"to click or type so you know which selectors are valid."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"selector": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional CSS selector to scope the search "
|
||||
"(e.g. 'form', '#main'). Defaults to 'body'."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["browser_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserScroll",
|
||||
"description": (
|
||||
"Scroll the page up or down. Automatically finds the correct scrollable "
|
||||
"container (works on SPAs like Notion, Gmail, etc. that use nested scroll "
|
||||
"containers instead of window-level scrolling). Returns scroll position info "
|
||||
"including whether top/bottom has been reached."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"direction": {
|
||||
"type": "string",
|
||||
"enum": ["up", "down"],
|
||||
"description": "Scroll direction. Defaults to 'down'.",
|
||||
},
|
||||
"amount": {
|
||||
"type": "number",
|
||||
"description": "Pixels to scroll. Defaults to 500.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "BrowserWait",
|
||||
"description": (
|
||||
"Wait for a specified duration. Useful after navigation or actions that "
|
||||
"trigger page loads, animations, or async content rendering. "
|
||||
"Min 100ms, max 10000ms."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"browser_id": {
|
||||
"type": "string",
|
||||
"description": "The browser card ID.",
|
||||
},
|
||||
"tab_id": TAB_ID_PROP,
|
||||
"milliseconds": {
|
||||
"type": "number",
|
||||
"description": "Duration to wait in milliseconds. Defaults to 1000.",
|
||||
},
|
||||
},
|
||||
"required": ["browser_id"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def send_response(id_, result=None, error=None):
|
||||
msg = {"jsonrpc": "2.0", "id": id_}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Prompt-building helpers extracted from AgentManager.
|
||||
"""Prompt composition helpers extracted from AgentManager.
|
||||
|
||||
All functions are stateless — they accept data as parameters instead of
|
||||
relying on ``self``.
|
||||
@@ -6,15 +6,12 @@ relying on ``self``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from backend.apps.agents.models import AgentSession
|
||||
from backend.apps.modes.modes import load_mode
|
||||
from backend.apps.outputs.outputs import _load_all as load_all_outputs
|
||||
from backend.apps.common.mcp_utils import sanitize_server_name as _sanitize_server_name
|
||||
from backend.apps.agents.prompt_context import resolve_context_paths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,202 +41,6 @@ def compose_system_prompt(
|
||||
return "\n\n".join(parts) if parts else None
|
||||
|
||||
|
||||
def build_connected_tools_context(
|
||||
allowed_tools: list[str],
|
||||
load_all_tools_fn,
|
||||
get_all_tool_names_fn,
|
||||
is_fully_denied_fn,
|
||||
get_denied_tool_names_fn,
|
||||
) -> str | None:
|
||||
all_tools = load_all_tools_fn()
|
||||
mcp_tools = [t for t in all_tools if t.mcp_config and t.enabled and t.auth_status in ("configured", "connected")]
|
||||
|
||||
sections: list[str] = []
|
||||
for tool in mcp_tools:
|
||||
tool_ref = f"mcp:{tool.name}"
|
||||
if tool_ref not in allowed_tools and allowed_tools != get_all_tool_names_fn():
|
||||
continue
|
||||
if is_fully_denied_fn(tool):
|
||||
continue
|
||||
|
||||
server_name = _sanitize_server_name(tool.name)
|
||||
denied = get_denied_tool_names_fn(tool)
|
||||
tool_descs = {
|
||||
k: v for k, v in tool.tool_permissions.get("_tool_descriptions", {}).items()
|
||||
if k not in denied
|
||||
}
|
||||
if not tool_descs:
|
||||
continue
|
||||
|
||||
lines = [f"MCP Server: {server_name}"]
|
||||
lines.append(f" Status: {tool.auth_status}")
|
||||
if tool.connected_account_email:
|
||||
lines.append(f" Connected account: {tool.connected_account_email}")
|
||||
lines.append(
|
||||
f" IMPORTANT: When calling tools from this server that require an email "
|
||||
f"parameter (e.g. user_google_email, user_email), always use "
|
||||
f"\"{tool.connected_account_email}\" automatically — do NOT ask the user."
|
||||
)
|
||||
tool_names = list(tool_descs.keys())
|
||||
if tool_names:
|
||||
lines.append(f" Available tools ({len(tool_names)}): {', '.join(tool_names)}")
|
||||
sections.append("\n".join(lines))
|
||||
|
||||
not_connected = [
|
||||
t for t in all_tools
|
||||
if t.mcp_config and t.enabled
|
||||
and t.auth_type in ("oauth2", "env_vars")
|
||||
and t.auth_status != "connected"
|
||||
]
|
||||
if not_connected:
|
||||
nc_lines = ["Tools installed but not yet connected (user needs to authorize in Settings → Tools):"]
|
||||
for t in not_connected:
|
||||
nc_lines.append(f" - {t.name}")
|
||||
sections.append("\n".join(nc_lines))
|
||||
|
||||
if not sections:
|
||||
return None
|
||||
return (
|
||||
"<connected_mcp_tools>\n"
|
||||
"The following MCP tool servers are connected and available. "
|
||||
"Use them directly when relevant to the user's request.\n\n"
|
||||
+ "\n\n".join(sections)
|
||||
+ "\n</connected_mcp_tools>"
|
||||
)
|
||||
|
||||
|
||||
def build_outputs_context() -> str | None:
|
||||
all_outputs = load_all_outputs()
|
||||
if not all_outputs:
|
||||
return None
|
||||
sections: list[str] = []
|
||||
for out in all_outputs:
|
||||
lines = [f"- **{out.name}** (id: `{out.id}`)"]
|
||||
if out.description:
|
||||
lines.append(f" Description: {out.description}")
|
||||
schema_str = _json.dumps(out.input_schema, indent=2)
|
||||
lines.append(f" Input schema:\n```json\n{schema_str}\n```")
|
||||
sections.append("\n".join(lines))
|
||||
return (
|
||||
"<available_views>\n"
|
||||
"The following reusable View artifacts are available. "
|
||||
"Use the RenderOutput tool to invoke one by providing its output_id "
|
||||
"and the required input_data matching its schema.\n\n"
|
||||
+ "\n\n".join(sections)
|
||||
+ "\n</available_views>"
|
||||
)
|
||||
|
||||
|
||||
def build_browser_context(
|
||||
dashboard_id: str | None,
|
||||
selected_browser_ids: list[str] | None = None,
|
||||
) -> str | None:
|
||||
if not dashboard_id:
|
||||
return None
|
||||
try:
|
||||
from backend.apps.dashboards.dashboards import _load as load_dashboard
|
||||
dashboard = load_dashboard(dashboard_id)
|
||||
except Exception:
|
||||
return None
|
||||
raw = dashboard.model_dump(mode="json")
|
||||
browser_cards = raw.get("layout", {}).get("browser_cards", {})
|
||||
|
||||
lines = [
|
||||
"<browser_agent_instructions>",
|
||||
"You have access to browser automation through the CreateBrowserAgent, BrowserAgent, and BrowserAgents tools.",
|
||||
"",
|
||||
"- **CreateBrowserAgent(task, url?)**: Create a new browser card and run a task on it. "
|
||||
"Use this when you need a fresh browser. Optionally provide a starting URL.",
|
||||
"- **BrowserAgent(browser_id, task)**: Delegate a task to an existing browser card. "
|
||||
"The browser agent will autonomously navigate, click, type, and interact with the page, then return a summary and screenshot.",
|
||||
"- **BrowserAgents(tasks)**: Run multiple browser tasks in parallel on existing browser cards. "
|
||||
"Each task requires a browser_id.",
|
||||
"",
|
||||
"You do NOT have direct access to low-level browser tools (click, type, screenshot, etc.). "
|
||||
"Instead, describe what you want accomplished and the browser agent will handle the details.",
|
||||
]
|
||||
|
||||
if browser_cards and selected_browser_ids:
|
||||
visible_cards = [
|
||||
card for card in browser_cards.values()
|
||||
if card.get("browser_id", "") in selected_browser_ids
|
||||
]
|
||||
if visible_cards:
|
||||
lines.append("")
|
||||
lines.append("The user selected these browser cards for you to work with:")
|
||||
for card in visible_cards:
|
||||
bid = card.get("browser_id", "")
|
||||
tabs = card.get("tabs", [])
|
||||
active_tab_id = card.get("activeTabId", "")
|
||||
active_tab = next((t for t in tabs if t.get("id") == active_tab_id), None)
|
||||
url = (active_tab or {}).get("url", card.get("url", ""))
|
||||
title = (active_tab or {}).get("title", "")
|
||||
lines.append(f"- browser_id: \"{bid}\"")
|
||||
if title:
|
||||
lines.append(f" Title: {title}")
|
||||
if url:
|
||||
lines.append(f" URL: {url}")
|
||||
|
||||
lines.append("</browser_agent_instructions>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_pre_selected_browser_ids(dashboard_id: str | None) -> list[str]:
|
||||
if not dashboard_id:
|
||||
return []
|
||||
try:
|
||||
from backend.apps.dashboards.dashboards import _load as load_dashboard
|
||||
dashboard = load_dashboard(dashboard_id)
|
||||
except Exception:
|
||||
return []
|
||||
raw = dashboard.model_dump(mode="json")
|
||||
browser_cards = raw.get("layout", {}).get("browser_cards", {})
|
||||
return [card.get("browser_id", "") for card in browser_cards.values() if card.get("browser_id")]
|
||||
|
||||
|
||||
def resolve_context_paths(context_paths: list | None) -> str:
|
||||
if not context_paths:
|
||||
return ""
|
||||
sections: list[str] = []
|
||||
for cp in context_paths:
|
||||
path = cp.get("path", "")
|
||||
cp_type = cp.get("type", "file")
|
||||
if not path or not os.path.exists(path):
|
||||
sections.append(f"[Context: {path} — not found]")
|
||||
continue
|
||||
if cp_type == "file" and os.path.isfile(path):
|
||||
try:
|
||||
with open(path, "r", errors="replace") as f:
|
||||
content = f.read(512_000)
|
||||
sections.append(f"<context_file path=\"{path}\">\n{content}\n</context_file>")
|
||||
except Exception as e:
|
||||
sections.append(f"[Context: {path} — error reading: {e}]")
|
||||
elif cp_type == "directory" and os.path.isdir(path):
|
||||
tree_lines = build_dir_tree(path, max_depth=4)
|
||||
sections.append(f"<context_directory path=\"{path}\">\n{chr(10).join(tree_lines)}\n</context_directory>")
|
||||
else:
|
||||
sections.append(f"[Context: {path} — type mismatch]")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def build_dir_tree(root: str, max_depth: int = 4, prefix: str = "") -> list[str]:
|
||||
lines: list[str] = []
|
||||
try:
|
||||
entries = sorted(os.listdir(root))
|
||||
except PermissionError:
|
||||
return [f"{prefix}[permission denied]"]
|
||||
dirs = [e for e in entries if not e.startswith(".") and os.path.isdir(os.path.join(root, e))]
|
||||
files = [e for e in entries if not e.startswith(".") and os.path.isfile(os.path.join(root, e))]
|
||||
for f in files:
|
||||
lines.append(f"{prefix}{f}")
|
||||
for d in dirs:
|
||||
lines.append(f"{prefix}{d}/")
|
||||
if max_depth > 1:
|
||||
sub = build_dir_tree(os.path.join(root, d), max_depth - 1, prefix + " ")
|
||||
lines.extend(sub)
|
||||
return lines
|
||||
|
||||
|
||||
def resolve_forced_tools(
|
||||
forced_tools: list[str] | None,
|
||||
load_all_tools_fn,
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Context-building helpers for agent prompts.
|
||||
|
||||
Assembles tool context, output schemas, browser instructions,
|
||||
file/directory context, and directory trees.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from backend.apps.outputs.outputs import _load_all as load_all_outputs
|
||||
from backend.apps.common.mcp_utils import sanitize_server_name as _sanitize_server_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_connected_tools_context(
|
||||
allowed_tools: list[str],
|
||||
load_all_tools_fn,
|
||||
get_all_tool_names_fn,
|
||||
is_fully_denied_fn,
|
||||
get_denied_tool_names_fn,
|
||||
) -> str | None:
|
||||
all_tools = load_all_tools_fn()
|
||||
mcp_tools = [t for t in all_tools if t.mcp_config and t.enabled and t.auth_status in ("configured", "connected")]
|
||||
|
||||
sections: list[str] = []
|
||||
for tool in mcp_tools:
|
||||
tool_ref = f"mcp:{tool.name}"
|
||||
if tool_ref not in allowed_tools and allowed_tools != get_all_tool_names_fn():
|
||||
continue
|
||||
if is_fully_denied_fn(tool):
|
||||
continue
|
||||
|
||||
server_name = _sanitize_server_name(tool.name)
|
||||
denied = get_denied_tool_names_fn(tool)
|
||||
tool_descs = {
|
||||
k: v for k, v in tool.tool_permissions.get("_tool_descriptions", {}).items()
|
||||
if k not in denied
|
||||
}
|
||||
if not tool_descs:
|
||||
continue
|
||||
|
||||
lines = [f"MCP Server: {server_name}"]
|
||||
lines.append(f" Status: {tool.auth_status}")
|
||||
if tool.connected_account_email:
|
||||
lines.append(f" Connected account: {tool.connected_account_email}")
|
||||
lines.append(
|
||||
f" IMPORTANT: When calling tools from this server that require an email "
|
||||
f"parameter (e.g. user_google_email, user_email), always use "
|
||||
f"\"{tool.connected_account_email}\" automatically — do NOT ask the user."
|
||||
)
|
||||
tool_names = list(tool_descs.keys())
|
||||
if tool_names:
|
||||
lines.append(f" Available tools ({len(tool_names)}): {', '.join(tool_names)}")
|
||||
sections.append("\n".join(lines))
|
||||
|
||||
not_connected = [
|
||||
t for t in all_tools
|
||||
if t.mcp_config and t.enabled
|
||||
and t.auth_type in ("oauth2", "env_vars")
|
||||
and t.auth_status != "connected"
|
||||
]
|
||||
if not_connected:
|
||||
nc_lines = ["Tools installed but not yet connected (user needs to authorize in Settings → Tools):"]
|
||||
for t in not_connected:
|
||||
nc_lines.append(f" - {t.name}")
|
||||
sections.append("\n".join(nc_lines))
|
||||
|
||||
if not sections:
|
||||
return None
|
||||
return (
|
||||
"<connected_mcp_tools>\n"
|
||||
"The following MCP tool servers are connected and available. "
|
||||
"Use them directly when relevant to the user's request.\n\n"
|
||||
+ "\n\n".join(sections)
|
||||
+ "\n</connected_mcp_tools>"
|
||||
)
|
||||
|
||||
|
||||
def build_outputs_context() -> str | None:
|
||||
all_outputs = load_all_outputs()
|
||||
if not all_outputs:
|
||||
return None
|
||||
sections: list[str] = []
|
||||
for out in all_outputs:
|
||||
lines = [f"- **{out.name}** (id: `{out.id}`)"]
|
||||
if out.description:
|
||||
lines.append(f" Description: {out.description}")
|
||||
schema_str = _json.dumps(out.input_schema, indent=2)
|
||||
lines.append(f" Input schema:\n```json\n{schema_str}\n```")
|
||||
sections.append("\n".join(lines))
|
||||
return (
|
||||
"<available_views>\n"
|
||||
"The following reusable View artifacts are available. "
|
||||
"Use the RenderOutput tool to invoke one by providing its output_id "
|
||||
"and the required input_data matching its schema.\n\n"
|
||||
+ "\n\n".join(sections)
|
||||
+ "\n</available_views>"
|
||||
)
|
||||
|
||||
|
||||
def build_browser_context(
|
||||
dashboard_id: str | None,
|
||||
selected_browser_ids: list[str] | None = None,
|
||||
) -> str | None:
|
||||
if not dashboard_id:
|
||||
return None
|
||||
try:
|
||||
from backend.apps.dashboards.dashboards import _load as load_dashboard
|
||||
dashboard = load_dashboard(dashboard_id)
|
||||
except Exception:
|
||||
return None
|
||||
raw = dashboard.model_dump(mode="json")
|
||||
browser_cards = raw.get("layout", {}).get("browser_cards", {})
|
||||
|
||||
lines = [
|
||||
"<browser_agent_instructions>",
|
||||
"You have access to browser automation through the CreateBrowserAgent, BrowserAgent, and BrowserAgents tools.",
|
||||
"",
|
||||
"- **CreateBrowserAgent(task, url?)**: Create a new browser card and run a task on it. "
|
||||
"Use this when you need a fresh browser. Optionally provide a starting URL.",
|
||||
"- **BrowserAgent(browser_id, task)**: Delegate a task to an existing browser card. "
|
||||
"The browser agent will autonomously navigate, click, type, and interact with the page, then return a summary and screenshot.",
|
||||
"- **BrowserAgents(tasks)**: Run multiple browser tasks in parallel on existing browser cards. "
|
||||
"Each task requires a browser_id.",
|
||||
"",
|
||||
"You do NOT have direct access to low-level browser tools (click, type, screenshot, etc.). "
|
||||
"Instead, describe what you want accomplished and the browser agent will handle the details.",
|
||||
]
|
||||
|
||||
if browser_cards and selected_browser_ids:
|
||||
visible_cards = [
|
||||
card for card in browser_cards.values()
|
||||
if card.get("browser_id", "") in selected_browser_ids
|
||||
]
|
||||
if visible_cards:
|
||||
lines.append("")
|
||||
lines.append("The user selected these browser cards for you to work with:")
|
||||
for card in visible_cards:
|
||||
bid = card.get("browser_id", "")
|
||||
tabs = card.get("tabs", [])
|
||||
active_tab_id = card.get("activeTabId", "")
|
||||
active_tab = next((t for t in tabs if t.get("id") == active_tab_id), None)
|
||||
url = (active_tab or {}).get("url", card.get("url", ""))
|
||||
title = (active_tab or {}).get("title", "")
|
||||
lines.append(f"- browser_id: \"{bid}\"")
|
||||
if title:
|
||||
lines.append(f" Title: {title}")
|
||||
if url:
|
||||
lines.append(f" URL: {url}")
|
||||
|
||||
lines.append("</browser_agent_instructions>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_pre_selected_browser_ids(dashboard_id: str | None) -> list[str]:
|
||||
if not dashboard_id:
|
||||
return []
|
||||
try:
|
||||
from backend.apps.dashboards.dashboards import _load as load_dashboard
|
||||
dashboard = load_dashboard(dashboard_id)
|
||||
except Exception:
|
||||
return []
|
||||
raw = dashboard.model_dump(mode="json")
|
||||
browser_cards = raw.get("layout", {}).get("browser_cards", {})
|
||||
return [card.get("browser_id", "") for card in browser_cards.values() if card.get("browser_id")]
|
||||
|
||||
|
||||
def resolve_context_paths(context_paths: list | None) -> str:
|
||||
if not context_paths:
|
||||
return ""
|
||||
sections: list[str] = []
|
||||
for cp in context_paths:
|
||||
path = cp.get("path", "")
|
||||
cp_type = cp.get("type", "file")
|
||||
if not path or not os.path.exists(path):
|
||||
sections.append(f"[Context: {path} — not found]")
|
||||
continue
|
||||
if cp_type == "file" and os.path.isfile(path):
|
||||
try:
|
||||
with open(path, "r", errors="replace") as f:
|
||||
content = f.read(512_000)
|
||||
sections.append(f"<context_file path=\"{path}\">\n{content}\n</context_file>")
|
||||
except Exception as e:
|
||||
sections.append(f"[Context: {path} — error reading: {e}]")
|
||||
elif cp_type == "directory" and os.path.isdir(path):
|
||||
tree_lines = build_dir_tree(path, max_depth=4)
|
||||
sections.append(f"<context_directory path=\"{path}\">\n{chr(10).join(tree_lines)}\n</context_directory>")
|
||||
else:
|
||||
sections.append(f"[Context: {path} — type mismatch]")
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def build_dir_tree(root: str, max_depth: int = 4, prefix: str = "") -> list[str]:
|
||||
lines: list[str] = []
|
||||
try:
|
||||
entries = sorted(os.listdir(root))
|
||||
except PermissionError:
|
||||
return [f"{prefix}[permission denied]"]
|
||||
dirs = [e for e in entries if not e.startswith(".") and os.path.isdir(os.path.join(root, e))]
|
||||
files = [e for e in entries if not e.startswith(".") and os.path.isfile(os.path.join(root, e))]
|
||||
for f in files:
|
||||
lines.append(f"{prefix}{f}")
|
||||
for d in dirs:
|
||||
lines.append(f"{prefix}{d}/")
|
||||
if max_depth > 1:
|
||||
sub = build_dir_tree(os.path.join(root, d), max_depth - 1, prefix + " ")
|
||||
lines.extend(sub)
|
||||
return lines
|
||||
@@ -1,17 +1,14 @@
|
||||
"""Analytics SubApp: PostHog for product analytics + local usage summary from session data."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
from collections import Counter
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.config.paths import SESSIONS_DIR
|
||||
from backend.apps.analytics.collector import init as init_collector, shutdown as shutdown_collector, record, identify
|
||||
from backend.apps.analytics.usage_summary import load_all_sessions, compute_session_stats, enrich_with_nine_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,7 +27,6 @@ async def _heartbeat_loop():
|
||||
"active_session_count": len(agent_manager.sessions),
|
||||
}
|
||||
|
||||
# Include 9Router cost snapshot if available
|
||||
try:
|
||||
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
|
||||
if _9r_running():
|
||||
@@ -40,7 +36,6 @@ async def _heartbeat_loop():
|
||||
props["nine_router_total_prompt_tokens"] = stats.get("totalPromptTokens", 0)
|
||||
props["nine_router_total_completion_tokens"] = stats.get("totalCompletionTokens", 0)
|
||||
props["nine_router_total_requests"] = stats.get("totalRequests", 0)
|
||||
# Per-model breakdown as flat properties for PostHog
|
||||
for model_name, model_data in (stats.get("byModel") or {}).items():
|
||||
safe_name = model_name.replace(".", "_").replace("-", "_")[:40]
|
||||
props[f"cost_model_{safe_name}"] = model_data.get("cost", 0)
|
||||
@@ -50,7 +45,6 @@ async def _heartbeat_loop():
|
||||
|
||||
record("app.heartbeat", props)
|
||||
|
||||
# Also fire a dedicated cost snapshot for cleaner dashboards
|
||||
if "nine_router_total_cost" in props:
|
||||
record("cost.snapshot", {
|
||||
"total_cost_usd": props["nine_router_total_cost"],
|
||||
@@ -73,7 +67,6 @@ async def analytics_lifespan():
|
||||
from backend.apps.settings.settings import load_settings, _save_settings
|
||||
settings = load_settings()
|
||||
|
||||
# Track first open
|
||||
is_first_open = settings.first_opened_at is None
|
||||
if is_first_open:
|
||||
settings.first_opened_at = datetime.now().isoformat()
|
||||
@@ -117,19 +110,16 @@ async def analytics_lifespan():
|
||||
except Exception as e:
|
||||
logger.debug(f"Analytics startup event failed (non-critical): {e}")
|
||||
|
||||
# Auto-start 9Router for subscription access
|
||||
try:
|
||||
from backend.apps.nine_router import ensure_running as ensure_9router
|
||||
await ensure_9router()
|
||||
except Exception as e:
|
||||
logger.debug(f"9Router auto-start skipped: {e}")
|
||||
logger.warning(f"9Router auto-start failed: {e}")
|
||||
|
||||
# Start heartbeat
|
||||
_heartbeat_task = asyncio.create_task(_heartbeat_loop())
|
||||
|
||||
yield
|
||||
|
||||
# Stop heartbeat
|
||||
if _heartbeat_task:
|
||||
_heartbeat_task.cancel()
|
||||
try:
|
||||
@@ -138,7 +128,6 @@ async def analytics_lifespan():
|
||||
pass
|
||||
_heartbeat_task = None
|
||||
|
||||
# Stop 9Router
|
||||
try:
|
||||
from backend.apps.nine_router import stop as stop_9router
|
||||
stop_9router()
|
||||
@@ -152,137 +141,19 @@ async def analytics_lifespan():
|
||||
analytics = SubApp("analytics", analytics_lifespan)
|
||||
|
||||
|
||||
def _load_all_sessions() -> list[dict]:
|
||||
"""Load all persisted session JSON files."""
|
||||
results = []
|
||||
if not os.path.exists(SESSIONS_DIR):
|
||||
return results
|
||||
for fname in os.listdir(SESSIONS_DIR):
|
||||
if fname.endswith(".json"):
|
||||
try:
|
||||
with open(os.path.join(SESSIONS_DIR, fname)) as f:
|
||||
results.append(json.load(f))
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
|
||||
@analytics.router.get("/usage-summary")
|
||||
async def usage_summary():
|
||||
"""Compute usage stats from persisted sessions for the Settings page."""
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
|
||||
|
||||
# Combine persisted + active sessions
|
||||
sessions = _load_all_sessions()
|
||||
sessions = load_all_sessions()
|
||||
for s in agent_manager.get_all_sessions():
|
||||
sessions.append(s.model_dump(mode="json"))
|
||||
|
||||
total_sessions = len(sessions)
|
||||
total_cost = sum(s.get("cost_usd", 0) for s in sessions)
|
||||
total_messages = 0
|
||||
total_tool_calls = 0
|
||||
total_duration = 0.0
|
||||
model_counts: Counter = Counter()
|
||||
provider_counts: Counter = Counter()
|
||||
tool_counts: Counter = Counter()
|
||||
status_counts: Counter = Counter()
|
||||
|
||||
for s in sessions:
|
||||
messages = s.get("messages", [])
|
||||
user_msgs = [m for m in messages if m.get("role") in ("user", "assistant")]
|
||||
tool_msgs = [m for m in messages if m.get("role") == "tool_call"]
|
||||
total_messages += len(user_msgs)
|
||||
total_tool_calls += len(tool_msgs)
|
||||
|
||||
model_counts[s.get("model", "unknown")] += 1
|
||||
provider_counts[s.get("provider", "anthropic")] += 1
|
||||
status_counts[s.get("status", "unknown")] += 1
|
||||
|
||||
# Duration
|
||||
created = s.get("created_at")
|
||||
closed = s.get("closed_at")
|
||||
if created and closed:
|
||||
try:
|
||||
c_str = created[:19]
|
||||
cl_str = closed[:19]
|
||||
dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds()
|
||||
if dur > 0:
|
||||
total_duration += dur
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Count individual tools
|
||||
for m in tool_msgs:
|
||||
content = m.get("content", {})
|
||||
if isinstance(content, dict):
|
||||
tool_name = content.get("tool", "")
|
||||
if tool_name:
|
||||
tool_counts[tool_name] += 1
|
||||
|
||||
avg_duration = total_duration / total_sessions if total_sessions > 0 else 0
|
||||
completed = status_counts.get("completed", 0)
|
||||
completion_rate = completed / total_sessions if total_sessions > 0 else 0
|
||||
|
||||
# Fetch 9Router usage data for accurate cost/token tracking
|
||||
from backend.apps.nine_router import get_usage_stats, is_running as _9r_running
|
||||
stats = compute_session_stats(sessions)
|
||||
nine_router_stats = await get_usage_stats() if _9r_running() else None
|
||||
|
||||
# Determine best cost source
|
||||
if nine_router_stats and nine_router_stats.get("totalCost", 0) > 0:
|
||||
cost_source = "9router"
|
||||
total_cost = nine_router_stats["totalCost"]
|
||||
elif total_cost > 0:
|
||||
cost_source = "sdk"
|
||||
else:
|
||||
cost_source = "none"
|
||||
|
||||
avg_cost = total_cost / total_sessions if total_sessions > 0 else 0
|
||||
|
||||
# Extract 9Router breakdowns
|
||||
cost_by_model = {}
|
||||
cost_by_provider = {}
|
||||
total_prompt_tokens = 0
|
||||
total_completion_tokens = 0
|
||||
total_requests = 0
|
||||
|
||||
if nine_router_stats:
|
||||
total_prompt_tokens = nine_router_stats.get("totalPromptTokens", 0)
|
||||
total_completion_tokens = nine_router_stats.get("totalCompletionTokens", 0)
|
||||
total_requests = nine_router_stats.get("totalRequests", 0)
|
||||
for key, val in (nine_router_stats.get("byModel") or {}).items():
|
||||
cost_by_model[key] = {
|
||||
"cost": val.get("cost", 0),
|
||||
"requests": val.get("count", 0),
|
||||
"prompt_tokens": val.get("promptTokens", 0),
|
||||
"completion_tokens": val.get("completionTokens", 0),
|
||||
}
|
||||
for key, val in (nine_router_stats.get("byProvider") or {}).items():
|
||||
cost_by_provider[key] = {
|
||||
"cost": val.get("cost", 0),
|
||||
"requests": val.get("count", 0),
|
||||
}
|
||||
|
||||
return {
|
||||
"total_sessions": total_sessions,
|
||||
"total_cost_usd": round(total_cost, 4),
|
||||
"total_messages": total_messages,
|
||||
"total_tool_calls": total_tool_calls,
|
||||
"avg_duration_seconds": round(avg_duration, 1),
|
||||
"avg_cost_per_session": round(avg_cost, 4),
|
||||
"completion_rate": round(completion_rate, 3),
|
||||
"models_used": dict(model_counts.most_common(10)),
|
||||
"providers_used": dict(provider_counts.most_common(10)),
|
||||
"top_tools": dict(tool_counts.most_common(15)),
|
||||
"status_breakdown": dict(status_counts),
|
||||
# 9Router enrichment
|
||||
"total_prompt_tokens": total_prompt_tokens,
|
||||
"total_completion_tokens": total_completion_tokens,
|
||||
"cost_by_model": cost_by_model,
|
||||
"cost_by_provider": cost_by_provider,
|
||||
"cost_source": cost_source,
|
||||
"nine_router_available": nine_router_stats is not None,
|
||||
"total_requests": total_requests,
|
||||
}
|
||||
return enrich_with_nine_router(stats, nine_router_stats)
|
||||
|
||||
|
||||
@analytics.router.get("/cost-breakdown")
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Session-level aggregation logic for the usage-summary endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
|
||||
from backend.config.paths import SESSIONS_DIR
|
||||
|
||||
|
||||
def load_all_sessions() -> list[dict]:
|
||||
"""Load all persisted session JSON files."""
|
||||
results = []
|
||||
if not os.path.exists(SESSIONS_DIR):
|
||||
return results
|
||||
for fname in os.listdir(SESSIONS_DIR):
|
||||
if fname.endswith(".json"):
|
||||
try:
|
||||
with open(os.path.join(SESSIONS_DIR, fname)) as f:
|
||||
results.append(json.load(f))
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
|
||||
def compute_session_stats(sessions: list[dict]) -> dict:
|
||||
"""Aggregate counters and durations from a list of session dicts."""
|
||||
total_sessions = len(sessions)
|
||||
total_cost = sum(s.get("cost_usd", 0) for s in sessions)
|
||||
total_messages = 0
|
||||
total_tool_calls = 0
|
||||
total_duration = 0.0
|
||||
model_counts: Counter = Counter()
|
||||
provider_counts: Counter = Counter()
|
||||
tool_counts: Counter = Counter()
|
||||
status_counts: Counter = Counter()
|
||||
|
||||
for s in sessions:
|
||||
messages = s.get("messages", [])
|
||||
user_msgs = [m for m in messages if m.get("role") in ("user", "assistant")]
|
||||
tool_msgs = [m for m in messages if m.get("role") == "tool_call"]
|
||||
total_messages += len(user_msgs)
|
||||
total_tool_calls += len(tool_msgs)
|
||||
|
||||
model_counts[s.get("model", "unknown")] += 1
|
||||
provider_counts[s.get("provider", "anthropic")] += 1
|
||||
status_counts[s.get("status", "unknown")] += 1
|
||||
|
||||
created = s.get("created_at")
|
||||
closed = s.get("closed_at")
|
||||
if created and closed:
|
||||
try:
|
||||
c_str = created[:19]
|
||||
cl_str = closed[:19]
|
||||
dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds()
|
||||
if dur > 0:
|
||||
total_duration += dur
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for m in tool_msgs:
|
||||
content = m.get("content", {})
|
||||
if isinstance(content, dict):
|
||||
tool_name = content.get("tool", "")
|
||||
if tool_name:
|
||||
tool_counts[tool_name] += 1
|
||||
|
||||
avg_duration = total_duration / total_sessions if total_sessions > 0 else 0
|
||||
completed = status_counts.get("completed", 0)
|
||||
completion_rate = completed / total_sessions if total_sessions > 0 else 0
|
||||
|
||||
return {
|
||||
"total_sessions": total_sessions,
|
||||
"total_cost_usd": total_cost,
|
||||
"total_messages": total_messages,
|
||||
"total_tool_calls": total_tool_calls,
|
||||
"avg_duration_seconds": avg_duration,
|
||||
"completion_rate": completion_rate,
|
||||
"models_used": dict(model_counts.most_common(10)),
|
||||
"providers_used": dict(provider_counts.most_common(10)),
|
||||
"top_tools": dict(tool_counts.most_common(15)),
|
||||
"status_breakdown": dict(status_counts),
|
||||
}
|
||||
|
||||
|
||||
def enrich_with_nine_router(stats: dict, nine_router_stats: dict | None) -> dict:
|
||||
"""Merge 9Router cost/token data into the aggregated stats dict."""
|
||||
total_cost = stats["total_cost_usd"]
|
||||
total_sessions = stats["total_sessions"]
|
||||
|
||||
if nine_router_stats and nine_router_stats.get("totalCost", 0) > 0:
|
||||
cost_source = "9router"
|
||||
total_cost = nine_router_stats["totalCost"]
|
||||
elif total_cost > 0:
|
||||
cost_source = "sdk"
|
||||
else:
|
||||
cost_source = "none"
|
||||
|
||||
avg_cost = total_cost / total_sessions if total_sessions > 0 else 0
|
||||
|
||||
cost_by_model = {}
|
||||
cost_by_provider = {}
|
||||
total_prompt_tokens = 0
|
||||
total_completion_tokens = 0
|
||||
total_requests = 0
|
||||
|
||||
if nine_router_stats:
|
||||
total_prompt_tokens = nine_router_stats.get("totalPromptTokens", 0)
|
||||
total_completion_tokens = nine_router_stats.get("totalCompletionTokens", 0)
|
||||
total_requests = nine_router_stats.get("totalRequests", 0)
|
||||
for key, val in (nine_router_stats.get("byModel") or {}).items():
|
||||
cost_by_model[key] = {
|
||||
"cost": val.get("cost", 0),
|
||||
"requests": val.get("count", 0),
|
||||
"prompt_tokens": val.get("promptTokens", 0),
|
||||
"completion_tokens": val.get("completionTokens", 0),
|
||||
}
|
||||
for key, val in (nine_router_stats.get("byProvider") or {}).items():
|
||||
cost_by_provider[key] = {
|
||||
"cost": val.get("cost", 0),
|
||||
"requests": val.get("count", 0),
|
||||
}
|
||||
|
||||
return {
|
||||
**stats,
|
||||
"total_cost_usd": round(total_cost, 4),
|
||||
"avg_duration_seconds": round(stats["avg_duration_seconds"], 1),
|
||||
"avg_cost_per_session": round(avg_cost, 4),
|
||||
"completion_rate": round(stats["completion_rate"], 3),
|
||||
"total_prompt_tokens": total_prompt_tokens,
|
||||
"total_completion_tokens": total_completion_tokens,
|
||||
"cost_by_model": cost_by_model,
|
||||
"cost_by_provider": cost_by_provider,
|
||||
"cost_source": cost_source,
|
||||
"nine_router_available": nine_router_stats is not None,
|
||||
"total_requests": total_requests,
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
"""MCP Registry SubApp: caches community + Google servers, enriches with GitHub stars."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
@@ -9,11 +10,12 @@ from typing import Optional
|
||||
import httpx
|
||||
from fastapi import Query
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.apps.mcp_registry.registry_fetcher import (
|
||||
extract_gh_repo, fetch_all_servers, fetch_google_servers,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REGISTRY_BASE = "https://registry.modelcontextprotocol.io/v0.1"
|
||||
PAGE_LIMIT = 100
|
||||
REFRESH_INTERVAL_S = 3600
|
||||
|
||||
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
|
||||
@@ -26,200 +28,6 @@ _refresh_task: Optional[asyncio.Task] = None
|
||||
_stars_cache: dict[str, int] = {}
|
||||
|
||||
|
||||
def _extract_gh_repo(repo_url: str) -> Optional[str]:
|
||||
"""Parse 'owner/repo' from a GitHub URL."""
|
||||
if not repo_url or "github.com" not in repo_url:
|
||||
return None
|
||||
parts = repo_url.rstrip("/").split("/")
|
||||
try:
|
||||
idx = next(i for i, p in enumerate(parts) if "github.com" in p)
|
||||
if len(parts) > idx + 2:
|
||||
owner = parts[idx + 1]
|
||||
repo = parts[idx + 2].removesuffix(".git")
|
||||
return f"{owner}/{repo}"
|
||||
except StopIteration:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _extract_server(entry: dict) -> Optional[dict]:
|
||||
"""Extract a flat server record from a registry entry, keeping only latest versions."""
|
||||
meta = entry.get("_meta", {}).get("io.modelcontextprotocol.registry/official", {})
|
||||
if not meta.get("isLatest"):
|
||||
return None
|
||||
|
||||
srv = entry.get("server", {})
|
||||
name = srv.get("name", "")
|
||||
if not name:
|
||||
return None
|
||||
|
||||
remotes = srv.get("remotes", [])
|
||||
remote_url = ""
|
||||
remote_type = ""
|
||||
if remotes:
|
||||
remote_url = remotes[0].get("url", "")
|
||||
remote_type = remotes[0].get("type", "")
|
||||
|
||||
repo = srv.get("repository", {})
|
||||
|
||||
packages = srv.get("packages", [])
|
||||
env_vars = []
|
||||
if packages:
|
||||
env_vars = packages[0].get("environmentVariables", [])
|
||||
|
||||
pub_meta = srv.get("_meta", {}).get("io.modelcontextprotocol.registry/publisher-provided", {})
|
||||
|
||||
icons = srv.get("icons", [])
|
||||
icon_url = icons[0]["src"] if icons else ""
|
||||
repo_url = repo.get("url", "") if isinstance(repo, dict) else ""
|
||||
if not icon_url and repo_url and "github.com" in repo_url:
|
||||
parts = repo_url.rstrip("/").split("/")
|
||||
gh_idx = next((i for i, p in enumerate(parts) if "github.com" in p), -1)
|
||||
if gh_idx >= 0 and len(parts) > gh_idx + 1:
|
||||
icon_url = f"https://github.com/{parts[gh_idx + 1]}.png?size=64"
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"title": srv.get("title", ""),
|
||||
"description": srv.get("description", ""),
|
||||
"version": srv.get("version", ""),
|
||||
"websiteUrl": srv.get("websiteUrl", ""),
|
||||
"repositoryUrl": repo_url,
|
||||
"remoteUrl": remote_url,
|
||||
"remoteType": remote_type,
|
||||
"iconUrl": icon_url,
|
||||
"environmentVariables": env_vars,
|
||||
"keywords": pub_meta.get("keywords", []),
|
||||
"license": pub_meta.get("license", ""),
|
||||
"stars": None,
|
||||
"source": "community",
|
||||
}
|
||||
|
||||
|
||||
async def _fetch_all_servers() -> dict[str, dict]:
|
||||
"""Paginate through the full registry and return a dict keyed by server name."""
|
||||
servers: dict[str, dict] = {}
|
||||
cursor: Optional[str] = None
|
||||
pages = 0
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
while True:
|
||||
params: dict = {"limit": PAGE_LIMIT}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
|
||||
try:
|
||||
resp = await client.get(f"{REGISTRY_BASE}/servers", params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP registry fetch failed on page {pages}: {e}")
|
||||
break
|
||||
|
||||
entries = data.get("servers", [])
|
||||
if not entries:
|
||||
break
|
||||
|
||||
for entry in entries:
|
||||
record = _extract_server(entry)
|
||||
if record:
|
||||
servers[record["name"]] = record
|
||||
|
||||
pages += 1
|
||||
next_cursor = data.get("metadata", {}).get("nextCursor")
|
||||
if not next_cursor:
|
||||
break
|
||||
cursor = next_cursor
|
||||
|
||||
logger.info(f"MCP registry cache refreshed: {len(servers)} servers from {pages} pages")
|
||||
return servers
|
||||
|
||||
|
||||
GOOGLE_README_URL = "https://raw.githubusercontent.com/google/mcp/main/README.md"
|
||||
GOOGLE_ICON_URL = "https://github.com/google.png?size=64"
|
||||
_ENTRY_RE = re.compile(r"\[\*\*(.+?)\*\*\]\((.+?)\)(?:[,\s]*(.+))?")
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
|
||||
|
||||
def _parse_google_readme(text: str) -> dict[str, dict]:
|
||||
servers: dict[str, dict] = {}
|
||||
section: Optional[str] = None
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if "remote mcp servers" in stripped.lower() and stripped.startswith("#"):
|
||||
section = "remote"
|
||||
continue
|
||||
if "open-source mcp servers" in stripped.lower() and stripped.startswith("#"):
|
||||
section = "open-source"
|
||||
continue
|
||||
if stripped.startswith("#") and section is not None:
|
||||
# Hit a new top-level section (e.g. Examples, Resources), stop parsing
|
||||
if not stripped.lower().startswith("### **"):
|
||||
section = None
|
||||
continue
|
||||
if section is None:
|
||||
continue
|
||||
|
||||
m = _ENTRY_RE.search(stripped)
|
||||
if not m:
|
||||
continue
|
||||
|
||||
title = m.group(1).strip()
|
||||
url = m.group(2).strip()
|
||||
desc_raw = (m.group(3) or "").strip().rstrip(".")
|
||||
|
||||
slug = _slugify(title)
|
||||
key = f"google/{slug}"
|
||||
|
||||
is_github = "github.com" in url or "go.dev" in url
|
||||
repo_url = url if is_github else ""
|
||||
website_url = url if not is_github else ""
|
||||
|
||||
if section == "remote":
|
||||
remote_type = "google-cloud-remote"
|
||||
description = desc_raw or f"Google Cloud managed MCP server for {title}"
|
||||
else:
|
||||
remote_type = "open-source"
|
||||
description = desc_raw or f"Google open-source MCP server for {title}"
|
||||
|
||||
servers[key] = {
|
||||
"name": key,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"version": "",
|
||||
"websiteUrl": website_url,
|
||||
"repositoryUrl": repo_url,
|
||||
"remoteUrl": "",
|
||||
"remoteType": remote_type,
|
||||
"iconUrl": GOOGLE_ICON_URL,
|
||||
"environmentVariables": [],
|
||||
"keywords": ["google", section],
|
||||
"license": "Apache-2.0",
|
||||
"stars": None,
|
||||
"source": "google",
|
||||
}
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
async def _fetch_google_servers() -> dict[str, dict]:
|
||||
"""Fetch and parse Google's MCP server catalog from their GitHub README."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(GOOGLE_README_URL)
|
||||
resp.raise_for_status()
|
||||
servers = _parse_google_readme(resp.text)
|
||||
logger.info(f"Google MCP catalog: parsed {len(servers)} servers")
|
||||
return servers
|
||||
except Exception as e:
|
||||
logger.warning(f"Google MCP catalog fetch failed: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
async def _fetch_github_stars(servers: dict[str, dict]):
|
||||
"""Batch-fetch GitHub star counts for servers with GitHub repos.
|
||||
|
||||
@@ -230,7 +38,7 @@ async def _fetch_github_stars(servers: dict[str, dict]):
|
||||
|
||||
needed: list[str] = []
|
||||
for srv in servers.values():
|
||||
gh = _extract_gh_repo(srv.get("repositoryUrl", ""))
|
||||
gh = extract_gh_repo(srv.get("repositoryUrl", ""))
|
||||
if gh and gh not in _stars_cache and gh not in needed:
|
||||
needed.append(gh)
|
||||
|
||||
@@ -285,7 +93,7 @@ async def _fetch_github_stars(servers: dict[str, dict]):
|
||||
|
||||
def _apply_stars(servers: dict[str, dict]):
|
||||
for srv in servers.values():
|
||||
gh = _extract_gh_repo(srv.get("repositoryUrl", ""))
|
||||
gh = extract_gh_repo(srv.get("repositoryUrl", ""))
|
||||
srv["stars"] = _stars_cache.get(gh) if gh else None
|
||||
|
||||
|
||||
@@ -295,8 +103,8 @@ async def _refresh_loop():
|
||||
while True:
|
||||
try:
|
||||
community, google = await asyncio.gather(
|
||||
_fetch_all_servers(),
|
||||
_fetch_google_servers(),
|
||||
fetch_all_servers(),
|
||||
fetch_google_servers(),
|
||||
)
|
||||
_cache = {**community, **google}
|
||||
await _fetch_github_stars(_cache)
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Data-fetching and parsing logic for MCP registry servers."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REGISTRY_BASE = "https://registry.modelcontextprotocol.io/v0.1"
|
||||
PAGE_LIMIT = 100
|
||||
|
||||
GOOGLE_README_URL = "https://raw.githubusercontent.com/google/mcp/main/README.md"
|
||||
GOOGLE_ICON_URL = "https://github.com/google.png?size=64"
|
||||
_ENTRY_RE = re.compile(r"\[\*\*(.+?)\*\*\]\((.+?)\)(?:[,\s]*(.+))?")
|
||||
|
||||
|
||||
def extract_gh_repo(repo_url: str) -> Optional[str]:
|
||||
"""Parse 'owner/repo' from a GitHub URL."""
|
||||
if not repo_url or "github.com" not in repo_url:
|
||||
return None
|
||||
parts = repo_url.rstrip("/").split("/")
|
||||
try:
|
||||
idx = next(i for i, p in enumerate(parts) if "github.com" in p)
|
||||
if len(parts) > idx + 2:
|
||||
owner = parts[idx + 1]
|
||||
repo = parts[idx + 2].removesuffix(".git")
|
||||
return f"{owner}/{repo}"
|
||||
except StopIteration:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _extract_server(entry: dict) -> Optional[dict]:
|
||||
"""Extract a flat server record from a registry entry, keeping only latest versions."""
|
||||
meta = entry.get("_meta", {}).get("io.modelcontextprotocol.registry/official", {})
|
||||
if not meta.get("isLatest"):
|
||||
return None
|
||||
|
||||
srv = entry.get("server", {})
|
||||
name = srv.get("name", "")
|
||||
if not name:
|
||||
return None
|
||||
|
||||
remotes = srv.get("remotes", [])
|
||||
remote_url = ""
|
||||
remote_type = ""
|
||||
if remotes:
|
||||
remote_url = remotes[0].get("url", "")
|
||||
remote_type = remotes[0].get("type", "")
|
||||
|
||||
repo = srv.get("repository", {})
|
||||
|
||||
packages = srv.get("packages", [])
|
||||
env_vars = []
|
||||
if packages:
|
||||
env_vars = packages[0].get("environmentVariables", [])
|
||||
|
||||
pub_meta = srv.get("_meta", {}).get("io.modelcontextprotocol.registry/publisher-provided", {})
|
||||
|
||||
icons = srv.get("icons", [])
|
||||
icon_url = icons[0]["src"] if icons else ""
|
||||
repo_url = repo.get("url", "") if isinstance(repo, dict) else ""
|
||||
if not icon_url and repo_url and "github.com" in repo_url:
|
||||
parts = repo_url.rstrip("/").split("/")
|
||||
gh_idx = next((i for i, p in enumerate(parts) if "github.com" in p), -1)
|
||||
if gh_idx >= 0 and len(parts) > gh_idx + 1:
|
||||
icon_url = f"https://github.com/{parts[gh_idx + 1]}.png?size=64"
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"title": srv.get("title", ""),
|
||||
"description": srv.get("description", ""),
|
||||
"version": srv.get("version", ""),
|
||||
"websiteUrl": srv.get("websiteUrl", ""),
|
||||
"repositoryUrl": repo_url,
|
||||
"remoteUrl": remote_url,
|
||||
"remoteType": remote_type,
|
||||
"iconUrl": icon_url,
|
||||
"environmentVariables": env_vars,
|
||||
"keywords": pub_meta.get("keywords", []),
|
||||
"license": pub_meta.get("license", ""),
|
||||
"stars": None,
|
||||
"source": "community",
|
||||
}
|
||||
|
||||
|
||||
async def fetch_all_servers() -> dict[str, dict]:
|
||||
"""Paginate through the full registry and return a dict keyed by server name."""
|
||||
servers: dict[str, dict] = {}
|
||||
cursor: Optional[str] = None
|
||||
pages = 0
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
while True:
|
||||
params: dict = {"limit": PAGE_LIMIT}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
|
||||
try:
|
||||
resp = await client.get(f"{REGISTRY_BASE}/servers", params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP registry fetch failed on page {pages}: {e}")
|
||||
break
|
||||
|
||||
entries = data.get("servers", [])
|
||||
if not entries:
|
||||
break
|
||||
|
||||
for entry in entries:
|
||||
record = _extract_server(entry)
|
||||
if record:
|
||||
servers[record["name"]] = record
|
||||
|
||||
pages += 1
|
||||
next_cursor = data.get("metadata", {}).get("nextCursor")
|
||||
if not next_cursor:
|
||||
break
|
||||
cursor = next_cursor
|
||||
|
||||
logger.info(f"MCP registry cache refreshed: {len(servers)} servers from {pages} pages")
|
||||
return servers
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
|
||||
|
||||
def _parse_google_readme(text: str) -> dict[str, dict]:
|
||||
servers: dict[str, dict] = {}
|
||||
section: Optional[str] = None
|
||||
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if "remote mcp servers" in stripped.lower() and stripped.startswith("#"):
|
||||
section = "remote"
|
||||
continue
|
||||
if "open-source mcp servers" in stripped.lower() and stripped.startswith("#"):
|
||||
section = "open-source"
|
||||
continue
|
||||
if stripped.startswith("#") and section is not None:
|
||||
if not stripped.lower().startswith("### **"):
|
||||
section = None
|
||||
continue
|
||||
if section is None:
|
||||
continue
|
||||
|
||||
m = _ENTRY_RE.search(stripped)
|
||||
if not m:
|
||||
continue
|
||||
|
||||
title = m.group(1).strip()
|
||||
url = m.group(2).strip()
|
||||
desc_raw = (m.group(3) or "").strip().rstrip(".")
|
||||
|
||||
slug = _slugify(title)
|
||||
key = f"google/{slug}"
|
||||
|
||||
is_github = "github.com" in url or "go.dev" in url
|
||||
repo_url = url if is_github else ""
|
||||
website_url = url if not is_github else ""
|
||||
|
||||
if section == "remote":
|
||||
remote_type = "google-cloud-remote"
|
||||
description = desc_raw or f"Google Cloud managed MCP server for {title}"
|
||||
else:
|
||||
remote_type = "open-source"
|
||||
description = desc_raw or f"Google open-source MCP server for {title}"
|
||||
|
||||
servers[key] = {
|
||||
"name": key,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"version": "",
|
||||
"websiteUrl": website_url,
|
||||
"repositoryUrl": repo_url,
|
||||
"remoteUrl": "",
|
||||
"remoteType": remote_type,
|
||||
"iconUrl": GOOGLE_ICON_URL,
|
||||
"environmentVariables": [],
|
||||
"keywords": ["google", section],
|
||||
"license": "Apache-2.0",
|
||||
"stars": None,
|
||||
"source": "google",
|
||||
}
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
async def fetch_google_servers() -> dict[str, dict]:
|
||||
"""Fetch and parse Google's MCP server catalog from their GitHub README."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(GOOGLE_README_URL)
|
||||
resp.raise_for_status()
|
||||
servers = _parse_google_readme(resp.text)
|
||||
logger.info(f"Google MCP catalog: parsed {len(servers)} servers")
|
||||
return servers
|
||||
except Exception as e:
|
||||
logger.warning(f"Google MCP catalog fetch failed: {e}")
|
||||
return {}
|
||||
+13
-27
@@ -79,8 +79,6 @@ async def ensure_running():
|
||||
_is_packaged = os.environ.get("OPENSWARM_PACKAGED") == "1"
|
||||
|
||||
if is_running():
|
||||
# In dev mode, kill stale standalone servers (from previous builds)
|
||||
# so we can start `next dev` which always uses latest source code
|
||||
if not _is_packaged:
|
||||
import subprocess as _sp
|
||||
try:
|
||||
@@ -89,73 +87,62 @@ async def ensure_running():
|
||||
capture_output=True, text=True, timeout=3,
|
||||
)
|
||||
if result.stdout.strip():
|
||||
logger.info("Dev mode: killing stale standalone 9Router to use next dev instead")
|
||||
print("9Router: killing stale standalone to use next dev", flush=True)
|
||||
_sp.run(["pkill", "-f", "next-server"], timeout=5)
|
||||
import asyncio
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
|
||||
return
|
||||
except Exception:
|
||||
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
|
||||
return
|
||||
else:
|
||||
logger.info("9Router already running on port %d", NINE_ROUTER_PORT)
|
||||
return
|
||||
|
||||
_9router_dir = _find_9router_dir()
|
||||
|
||||
if _is_packaged and _9router_dir:
|
||||
# Production mode — use pre-built standalone server
|
||||
# In packaged app, build-staging copies .next/standalone/ contents to 9router/
|
||||
# So server.js is at 9router/server.js (not 9router/.next/standalone/server.js)
|
||||
standalone_server = os.path.join(_9router_dir, "server.js")
|
||||
if not os.path.exists(standalone_server):
|
||||
# Fallback: check nested path in case build layout changes
|
||||
standalone_server = os.path.join(_9router_dir, ".next", "standalone", "server.js")
|
||||
if not os.path.exists(standalone_server):
|
||||
logger.warning("9Router standalone build not found in %s", _9router_dir)
|
||||
print("9Router: standalone build not found in", _9router_dir, flush=True)
|
||||
return
|
||||
|
||||
node = _find_node()
|
||||
if not node:
|
||||
logger.warning("Node.js not found — cannot start 9Router in packaged mode.")
|
||||
print("9Router: Node.js not found, cannot start in packaged mode", flush=True)
|
||||
return
|
||||
|
||||
logger.info("Starting 9Router (production) on port %d...", NINE_ROUTER_PORT)
|
||||
print(f"9Router: starting (production) on port {NINE_ROUTER_PORT}...", flush=True)
|
||||
cmd = [node, standalone_server]
|
||||
cwd = os.path.dirname(standalone_server)
|
||||
env = {**os.environ, "PORT": str(NINE_ROUTER_PORT), "NODE_ENV": "production"}
|
||||
# If using Electron binary as node, enable ELECTRON_RUN_AS_NODE
|
||||
if node == os.environ.get("OPENSWARM_ELECTRON_PATH"):
|
||||
env["ELECTRON_RUN_AS_NODE"] = "1"
|
||||
|
||||
elif _9router_dir:
|
||||
# Dev mode with bundled 9Router — use next dev
|
||||
npx = shutil.which("npx")
|
||||
if not npx:
|
||||
logger.warning("npx not found — cannot auto-start 9Router.")
|
||||
print("9Router: npx not found, cannot auto-start", flush=True)
|
||||
return
|
||||
|
||||
# Install deps if needed
|
||||
if not os.path.isdir(os.path.join(_9router_dir, "node_modules")):
|
||||
logger.info("Installing 9Router dependencies...")
|
||||
print("9Router: installing dependencies...", flush=True)
|
||||
npm = shutil.which("npm")
|
||||
if npm:
|
||||
subprocess.run([npm, "install"], cwd=_9router_dir,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=120)
|
||||
|
||||
logger.info("Starting 9Router (dev) on port %d...", NINE_ROUTER_PORT)
|
||||
print(f"9Router: starting (dev) on port {NINE_ROUTER_PORT}...", flush=True)
|
||||
cmd = [npx, "next", "dev", "--webpack", "-p", str(NINE_ROUTER_PORT)]
|
||||
cwd = _9router_dir
|
||||
env = {**os.environ, "PORT": str(NINE_ROUTER_PORT)}
|
||||
|
||||
else:
|
||||
# No bundled 9Router — try npx 9router as last resort
|
||||
npx = shutil.which("npx")
|
||||
if not npx:
|
||||
logger.warning("npx not found and no bundled 9Router — cannot start.")
|
||||
print("9Router: npx not found and no bundled 9router directory", flush=True)
|
||||
return
|
||||
logger.info("Starting 9Router (npx) on port %d...", NINE_ROUTER_PORT)
|
||||
print(f"9Router: starting (npx) on port {NINE_ROUTER_PORT}...", flush=True)
|
||||
cmd = [npx, "9router"]
|
||||
cwd = None
|
||||
env = {**os.environ, "PORT": str(NINE_ROUTER_PORT)}
|
||||
@@ -169,17 +156,16 @@ async def ensure_running():
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Wait up to 30 seconds for startup (production standalone is faster)
|
||||
timeout = 20 if _is_packaged else 30
|
||||
for _ in range(timeout * 2):
|
||||
await asyncio.sleep(0.5)
|
||||
if is_running():
|
||||
logger.info("9Router started successfully")
|
||||
print("9Router: started successfully", flush=True)
|
||||
return
|
||||
|
||||
logger.warning("9Router did not start within %ds", timeout)
|
||||
print(f"9Router: did not start within {timeout}s", flush=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to start 9Router: {e}")
|
||||
print(f"9Router: failed to start: {e}", flush=True)
|
||||
|
||||
|
||||
def stop():
|
||||
|
||||
@@ -5,6 +5,7 @@ Moved from agents.py and main.py to a dedicated sub-app.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -16,12 +17,16 @@ from backend.config.Apps import SubApp
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_pending_oauth: dict[str, dict] = {}
|
||||
_ensure_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def subscriptions_lifespan():
|
||||
logger.info("Subscriptions sub-app starting")
|
||||
yield
|
||||
global _ensure_task
|
||||
if _ensure_task and not _ensure_task.done():
|
||||
_ensure_task.cancel()
|
||||
logger.info("Subscriptions sub-app shutting down")
|
||||
|
||||
|
||||
@@ -31,8 +36,11 @@ subscriptions = SubApp("subscriptions", subscriptions_lifespan)
|
||||
@subscriptions.router.get("/status")
|
||||
async def subscriptions_status():
|
||||
"""Check if 9Router is running and list connected providers."""
|
||||
from backend.apps.nine_router import is_running, get_providers, get_models
|
||||
global _ensure_task
|
||||
from backend.apps.nine_router import is_running, ensure_running, get_providers, get_models
|
||||
if not is_running():
|
||||
if _ensure_task is None or _ensure_task.done():
|
||||
_ensure_task = asyncio.create_task(ensure_running())
|
||||
return {"running": False, "providers": [], "models": []}
|
||||
providers = await get_providers()
|
||||
models = await get_models()
|
||||
|
||||
@@ -4,7 +4,6 @@ import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import { AgentMessage, editMessage, switchBranch, duplicateSession, setActiveSession } from '@/shared/state/agentsSlice';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import MessageActionBar from './MessageActionBar';
|
||||
@@ -12,9 +11,9 @@ import ToolCallBubble from './ToolCallBubble';
|
||||
import ToolGroupBubble, { isToolGroup, isToolPair } from './ToolGroupBubble';
|
||||
import ApprovalBar, { BatchApprovalBar } from './ApprovalBar';
|
||||
import ChatInput from './ChatInput';
|
||||
import ThinkingBubble from './ThinkingBubble';
|
||||
import ChatHeader from './ChatHeader';
|
||||
import MessageQueue from './MessageQueue';
|
||||
import StreamingSection from './StreamingSection';
|
||||
import { useAgentChat } from './hooks/useAgentChat';
|
||||
import { useMessageRendering } from './hooks/useMessageRendering';
|
||||
import { ContextPath } from '@/app/components/DirectoryBrowser';
|
||||
@@ -136,49 +135,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId, onClose, embedded, aut
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{session.streamingMessage && (
|
||||
session.streamingMessage.role === 'tool_call' ? (
|
||||
<ToolCallBubble
|
||||
key={`streaming-${session.streamingMessage.id}`}
|
||||
isStreaming
|
||||
isPending
|
||||
sessionId={session.id}
|
||||
call={{
|
||||
id: session.streamingMessage.id, role: 'tool_call',
|
||||
content: { tool: session.streamingMessage.tool_name || '', input: session.streamingMessage.content },
|
||||
timestamp: new Date().toISOString(), branch_id: session.active_branch_id || 'main', parent_id: null,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MessageBubble
|
||||
key={`streaming-${session.streamingMessage.id}`}
|
||||
isStreaming
|
||||
message={{
|
||||
id: session.streamingMessage.id, role: session.streamingMessage.role,
|
||||
content: session.streamingMessage.content, timestamp: new Date().toISOString(),
|
||||
branch_id: session.active_branch_id || 'main', parent_id: null,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{(awaitingResponse || (session.status === 'running' && !session.streamingMessage)) && <ThinkingBubble />}
|
||||
{showResumeBubble && session.status === 'stopped' && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
|
||||
<Box
|
||||
onClick={handleResume}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5, px: 1.5, py: 0.75,
|
||||
borderRadius: '12px', cursor: 'pointer',
|
||||
bgcolor: `${c.accent.primary}10`, border: `1px solid ${c.accent.primary}30`,
|
||||
transition: 'all 0.15s',
|
||||
'&:hover': { bgcolor: `${c.accent.primary}1a`, border: `1px solid ${c.accent.primary}50` },
|
||||
}}
|
||||
>
|
||||
<PlayArrowIcon sx={{ fontSize: 14, color: c.accent.primary }} />
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 500, color: c.accent.primary }}>Resume Agent Response</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<StreamingSection session={session} awaitingResponse={awaitingResponse} showResumeBubble={showResumeBubble} handleResume={handleResume} />
|
||||
</Box>
|
||||
{showScrollButton && (
|
||||
<Tooltip title="Scroll to bottom">
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import ToolCallBubble from './ToolCallBubble';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import ThinkingBubble from './ThinkingBubble';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
interface StreamingSectionProps {
|
||||
session: any;
|
||||
awaitingResponse: boolean;
|
||||
showResumeBubble: boolean;
|
||||
handleResume: () => void;
|
||||
}
|
||||
|
||||
const StreamingSection: React.FC<StreamingSectionProps> = ({ session, awaitingResponse, showResumeBubble, handleResume }) => {
|
||||
const c = useClaudeTokens();
|
||||
return (
|
||||
<>
|
||||
{session.streamingMessage && (
|
||||
session.streamingMessage.role === 'tool_call' ? (
|
||||
<ToolCallBubble
|
||||
key={`streaming-${session.streamingMessage.id}`}
|
||||
isStreaming
|
||||
isPending
|
||||
sessionId={session.id}
|
||||
call={{
|
||||
id: session.streamingMessage.id, role: 'tool_call',
|
||||
content: { tool: session.streamingMessage.tool_name || '', input: session.streamingMessage.content },
|
||||
timestamp: new Date().toISOString(), branch_id: session.active_branch_id || 'main', parent_id: null,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MessageBubble
|
||||
key={`streaming-${session.streamingMessage.id}`}
|
||||
isStreaming
|
||||
message={{
|
||||
id: session.streamingMessage.id, role: session.streamingMessage.role,
|
||||
content: session.streamingMessage.content, timestamp: new Date().toISOString(),
|
||||
branch_id: session.active_branch_id || 'main', parent_id: null,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{(awaitingResponse || (session.status === 'running' && !session.streamingMessage)) && <ThinkingBubble />}
|
||||
{showResumeBubble && session.status === 'stopped' && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
|
||||
<Box
|
||||
onClick={handleResume}
|
||||
sx={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 0.5, px: 1.5, py: 0.75,
|
||||
borderRadius: '12px', cursor: 'pointer',
|
||||
bgcolor: `${c.accent.primary}10`, border: `1px solid ${c.accent.primary}30`,
|
||||
transition: 'all 0.15s',
|
||||
'&:hover': { bgcolor: `${c.accent.primary}1a`, border: `1px solid ${c.accent.primary}50` },
|
||||
}}
|
||||
>
|
||||
<PlayArrowIcon sx={{ fontSize: 14, color: c.accent.primary }} />
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 500, color: c.accent.primary }}>Resume Agent Response</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StreamingSection;
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Button, CircularProgress } from '@mui/material';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
export const SUBSCRIPTION_PROVIDERS = [
|
||||
{ id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet, Opus, Haiku — use your Anthropic subscription', color: '#E8927A', preview: false },
|
||||
{ id: 'gemini-cli', name: 'Gemini Advanced', desc: 'Gemini 2.5 Pro and Flash — use your Google subscription', color: '#4285F4', preview: true },
|
||||
{ id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, o3, o4-mini — use your OpenAI subscription', color: '#74AA9C', preview: true },
|
||||
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models via your Copilot subscription', color: '#8B949E', preview: true },
|
||||
];
|
||||
|
||||
export type SubscriptionProvider = typeof SUBSCRIPTION_PROVIDERS[0];
|
||||
|
||||
interface SubscriptionCardProps {
|
||||
provider: SubscriptionProvider;
|
||||
connected: boolean;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
connecting: boolean;
|
||||
userCode?: string;
|
||||
disconnecting?: boolean;
|
||||
}
|
||||
|
||||
const SubscriptionCard: React.FC<SubscriptionCardProps> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode, disconnecting }) => {
|
||||
const c = useClaudeTokens();
|
||||
const isPreview = (provider as any).preview;
|
||||
return (
|
||||
<Box sx={{
|
||||
p: 1.5, borderRadius: `${c.radius.md}px`,
|
||||
border: `1px solid ${connected ? c.status.success + '30' : connecting ? c.accent.primary + '30' : c.border.subtle}`,
|
||||
bgcolor: connected ? `${c.status.success}04` : connecting ? `${c.accent.primary}04` : 'transparent',
|
||||
opacity: isPreview ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{
|
||||
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
||||
bgcolor: connected ? c.status.success : connecting ? c.accent.primary : c.border.medium,
|
||||
transition: 'background-color 0.3s ease',
|
||||
...(connecting ? {
|
||||
animation: 'pulse-dot 1.5s ease-in-out infinite',
|
||||
'@keyframes pulse-dot': {
|
||||
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
|
||||
'50%': { opacity: 0.4, transform: 'scale(0.8)' },
|
||||
},
|
||||
} : {}),
|
||||
}} />
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.text.primary }}>{provider.name}</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: connecting ? c.accent.primary : c.text.muted, transition: 'color 0.3s ease' }}>
|
||||
{connecting ? 'Waiting for authorization...' : provider.desc}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{isPreview ? (
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost, fontStyle: 'italic' }}>
|
||||
Coming soon
|
||||
</Typography>
|
||||
) : connected ? (
|
||||
disconnecting ? (
|
||||
<CircularProgress size={14} sx={{ color: c.text.ghost }} />
|
||||
) : (
|
||||
<Typography onClick={onDisconnect} sx={{ fontSize: '0.68rem', color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.status.error }, transition: 'color 0.2s ease' }}>
|
||||
Disconnect
|
||||
</Typography>
|
||||
)
|
||||
) : connecting && userCode ? (
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>Enter code:</Typography>
|
||||
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.accent.primary, fontFamily: 'monospace', letterSpacing: '0.1em' }}>{userCode}</Typography>
|
||||
</Box>
|
||||
) : connecting ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8 }}>
|
||||
<CircularProgress size={14} sx={{ color: c.accent.primary }} />
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.accent.primary }}>Connecting...</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Button onClick={onConnect} variant="outlined" size="small" sx={{ textTransform: 'none', fontSize: '0.7rem', color: c.text.primary, borderColor: c.border.medium, minWidth: 70, '&:hover': { borderColor: c.accent.primary }, transition: 'all 0.2s ease' }}>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionCard;
|
||||
@@ -1,78 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Typography, Button, CircularProgress } from '@mui/material';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Box, Typography, CircularProgress } from '@mui/material';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const SUBSCRIPTION_PROVIDERS = [
|
||||
{ id: 'claude', name: 'Claude Pro / Max', desc: 'Sonnet, Opus, Haiku — use your Anthropic subscription', color: '#E8927A', preview: false },
|
||||
{ id: 'gemini-cli', name: 'Gemini Advanced', desc: 'Gemini 2.5 Pro and Flash — use your Google subscription', color: '#4285F4', preview: true },
|
||||
{ id: 'codex', name: 'ChatGPT Plus / Pro', desc: 'GPT-5.4, o3, o4-mini — use your OpenAI subscription', color: '#74AA9C', preview: true },
|
||||
{ id: 'github', name: 'GitHub Copilot', desc: 'Claude + GPT models via your Copilot subscription', color: '#8B949E', preview: true },
|
||||
];
|
||||
|
||||
const SubscriptionCard: React.FC<{ provider: typeof SUBSCRIPTION_PROVIDERS[0]; connected: boolean; onConnect: () => void; onDisconnect: () => void; connecting: boolean; userCode?: string; disconnecting?: boolean }> = ({ provider, connected, onConnect, onDisconnect, connecting, userCode, disconnecting }) => {
|
||||
const c = useClaudeTokens();
|
||||
const isPreview = (provider as any).preview;
|
||||
return (
|
||||
<Box sx={{
|
||||
p: 1.5, borderRadius: `${c.radius.md}px`,
|
||||
border: `1px solid ${connected ? c.status.success + '30' : connecting ? c.accent.primary + '30' : c.border.subtle}`,
|
||||
bgcolor: connected ? `${c.status.success}04` : connecting ? `${c.accent.primary}04` : 'transparent',
|
||||
opacity: isPreview ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{
|
||||
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
||||
bgcolor: connected ? c.status.success : connecting ? c.accent.primary : c.border.medium,
|
||||
transition: 'background-color 0.3s ease',
|
||||
...(connecting ? {
|
||||
animation: 'pulse-dot 1.5s ease-in-out infinite',
|
||||
'@keyframes pulse-dot': {
|
||||
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
|
||||
'50%': { opacity: 0.4, transform: 'scale(0.8)' },
|
||||
},
|
||||
} : {}),
|
||||
}} />
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: '0.78rem', fontWeight: 600, color: c.text.primary }}>{provider.name}</Typography>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: connecting ? c.accent.primary : c.text.muted, transition: 'color 0.3s ease' }}>
|
||||
{connecting ? 'Waiting for authorization...' : provider.desc}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{isPreview ? (
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.text.ghost, fontStyle: 'italic' }}>
|
||||
Coming soon
|
||||
</Typography>
|
||||
) : connected ? (
|
||||
disconnecting ? (
|
||||
<CircularProgress size={14} sx={{ color: c.text.ghost }} />
|
||||
) : (
|
||||
<Typography onClick={onDisconnect} sx={{ fontSize: '0.68rem', color: c.text.tertiary, cursor: 'pointer', '&:hover': { color: c.status.error }, transition: 'color 0.2s ease' }}>
|
||||
Disconnect
|
||||
</Typography>
|
||||
)
|
||||
) : connecting && userCode ? (
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.text.muted }}>Enter code:</Typography>
|
||||
<Typography sx={{ fontSize: '0.85rem', fontWeight: 700, color: c.accent.primary, fontFamily: 'monospace', letterSpacing: '0.1em' }}>{userCode}</Typography>
|
||||
</Box>
|
||||
) : connecting ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.8 }}>
|
||||
<CircularProgress size={14} sx={{ color: c.accent.primary }} />
|
||||
<Typography sx={{ fontSize: '0.68rem', color: c.accent.primary }}>Connecting...</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Button onClick={onConnect} variant="outlined" size="small" sx={{ textTransform: 'none', fontSize: '0.7rem', color: c.text.primary, borderColor: c.border.medium, minWidth: 70, '&:hover': { borderColor: c.accent.primary }, transition: 'all 0.2s ease' }}>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
import SubscriptionCard, { SUBSCRIPTION_PROVIDERS } from './SubscriptionCard';
|
||||
|
||||
const SubscriptionCards: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
@@ -81,13 +11,24 @@ const SubscriptionCards: React.FC = () => {
|
||||
const [disconnecting, setDisconnecting] = useState<string | null>(null);
|
||||
const [userCode, setUserCode] = useState('');
|
||||
const [pollTimer, setPollTimer] = useState<any>(null);
|
||||
const retryRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const fetchStatus = () => {
|
||||
fetch(`${API_BASE}/subscriptions/status`)
|
||||
.then(r => r.json())
|
||||
.then(setStatus)
|
||||
.catch(() => setStatus({ running: false, providers: [], models: [] }));
|
||||
};
|
||||
useEffect(() => { fetchStatus(); }, []);
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
retryRef.current = setInterval(fetchStatus, 3000);
|
||||
return () => { if (retryRef.current) clearInterval(retryRef.current); };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (status?.running && retryRef.current) {
|
||||
clearInterval(retryRef.current);
|
||||
retryRef.current = null;
|
||||
}
|
||||
}, [status?.running]);
|
||||
const isConnected = (providerId: string) => {
|
||||
if (!status?.providers) return false;
|
||||
const connections = status.providers?.connections || (Array.isArray(status.providers) ? status.providers : []);
|
||||
|
||||
Reference in New Issue
Block a user