4656af010e
Phase 1: Infrastructure (8 tasks) - Dockerfile, amplifierd, FastAPI, auth, AG-UI adapter Phase 2: Frontend (10 tasks) - React/TS/Vite, Tabler, CopilotKit headless, SSE streaming Phase 3: Bundle + Browser + Artifacts (12 tasks) - Amplifier bundle, researcher agent, AI-generated visual artifacts (Claude builds visuals style), noVNC browser panel
1820 lines
45 KiB
Markdown
1820 lines
45 KiB
Markdown
# Research Workbench — Phase 2: Frontend (Chat + Sessions)
|
|
|
|
> **Execution:** Use the subagent-driven-development workflow to implement this plan.
|
|
|
|
**Goal:** Build the React frontend with CopilotKit headless chat, Tabler UI components, session management sidebar, tool call rendering, and MCP App inline visualization support.
|
|
|
|
**Architecture:** A Vite + React + TypeScript SPA using CopilotKit's `useCopilotChat` hook for headless chat control (we build ALL UI with Tabler), connecting to the backend's AG-UI endpoint at `/api/copilotkit`. The layout is a 3-column design: session sidebar (240px), chat panel (flex), and right panel (45%) for browser/artifacts. Tool results with `_meta.ui.resourceUri` (MCP Apps, SEP-1865) are rendered as sandboxed iframes with JSON-RPC 2.0 postMessage bridges for interactive inline visualizations.
|
|
|
|
**Tech Stack:** React 19, TypeScript, Vite (with `@rolldown/vite`), Tabler (`@tabler/core`, `@tabler/icons-react`), CopilotKit (`@copilotkit/react-core`, `@copilotkit/react-ui`), react-markdown
|
|
|
|
**Depends on:** Phase 1 complete (backend running at `:8080`)
|
|
|
|
---
|
|
|
|
### Task 1: Scaffold Vite + React + TypeScript project
|
|
|
|
**Files:**
|
|
- Create: `frontend/package.json`
|
|
- Create: `frontend/tsconfig.json`
|
|
- Create: `frontend/vite.config.ts`
|
|
- Create: `frontend/index.html`
|
|
|
|
**Step 1: Initialize the frontend project**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench/frontend
|
|
npm create vite@latest . -- --template react-ts
|
|
```
|
|
|
|
When prompted, select: React, TypeScript.
|
|
|
|
If the directory already has files, answer "yes" to proceed.
|
|
|
|
**Step 2: Install dependencies**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench/frontend
|
|
npm install @copilotkit/react-core @copilotkit/react-ui @tabler/core @tabler/icons-react react-markdown
|
|
npm install -D @types/react @types/react-dom
|
|
```
|
|
|
|
**Step 3: Update vite.config.ts for API proxy**
|
|
|
|
Replace `frontend/vite.config.ts` with:
|
|
|
|
```typescript
|
|
import { defineConfig } from "vite";
|
|
import react from "@vitejs/plugin-react";
|
|
|
|
export default defineConfig({
|
|
plugins: [react()],
|
|
server: {
|
|
port: 3000,
|
|
proxy: {
|
|
"/api": {
|
|
target: "http://localhost:8080",
|
|
changeOrigin: true,
|
|
},
|
|
},
|
|
},
|
|
build: {
|
|
outDir: "dist",
|
|
sourcemap: true,
|
|
},
|
|
});
|
|
```
|
|
|
|
**Step 4: Verify the dev server starts**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench/frontend
|
|
npm run dev -- --host 2>&1 &
|
|
sleep 3
|
|
curl -s http://localhost:3000 | head -5
|
|
kill %1
|
|
```
|
|
|
|
Expected: HTML content from the Vite dev server.
|
|
|
|
**Step 5: Commit**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench
|
|
git add frontend/package.json frontend/package-lock.json frontend/tsconfig.json frontend/tsconfig.app.json frontend/tsconfig.node.json frontend/vite.config.ts frontend/index.html frontend/src frontend/public frontend/eslint.config.js -f
|
|
git commit -m "feat: scaffold Vite + React + TypeScript frontend"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: TypeScript types
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/lib/types.ts`
|
|
|
|
**Step 1: Create the types file**
|
|
|
|
Create `frontend/src/lib/types.ts`:
|
|
|
|
```typescript
|
|
/**
|
|
* Shared TypeScript types for the Research Workbench frontend.
|
|
*/
|
|
|
|
// --- Sessions ---
|
|
|
|
export interface Session {
|
|
session_id: string;
|
|
status: string;
|
|
bundle: string | null;
|
|
created_at: string | null;
|
|
last_activity: string | null;
|
|
total_messages: number | null;
|
|
tool_invocations: number | null;
|
|
stale: boolean | null;
|
|
}
|
|
|
|
export interface SessionListResponse {
|
|
sessions: Session[];
|
|
total: number;
|
|
}
|
|
|
|
// --- Artifacts ---
|
|
|
|
export interface Artifact {
|
|
name: string;
|
|
session_id: string;
|
|
size: number;
|
|
}
|
|
|
|
export interface ArtifactContent {
|
|
name: string;
|
|
session_id: string;
|
|
content: string;
|
|
}
|
|
|
|
// --- A2UI Blocks ---
|
|
|
|
export type A2UIBlockType = "filters" | "form" | "table" | "cards";
|
|
|
|
export interface A2UIFilterOption {
|
|
label: string;
|
|
active: boolean;
|
|
}
|
|
|
|
export interface A2UIFiltersBlock {
|
|
type: "filters";
|
|
options: A2UIFilterOption[];
|
|
}
|
|
|
|
export interface A2UIFormField {
|
|
name: string;
|
|
type: "text" | "number" | "select";
|
|
label?: string;
|
|
value?: string | number;
|
|
options?: string[];
|
|
}
|
|
|
|
export interface A2UIFormBlock {
|
|
type: "form";
|
|
fields: A2UIFormField[];
|
|
submitLabel?: string;
|
|
}
|
|
|
|
export interface A2UITableBlock {
|
|
type: "table";
|
|
headers: string[];
|
|
rows: string[][];
|
|
}
|
|
|
|
export interface A2UICardItem {
|
|
title: string;
|
|
description?: string;
|
|
image?: string;
|
|
price?: string;
|
|
url?: string;
|
|
}
|
|
|
|
export interface A2UICardsBlock {
|
|
type: "cards";
|
|
items: A2UICardItem[];
|
|
}
|
|
|
|
export type A2UIBlock =
|
|
| A2UIFiltersBlock
|
|
| A2UIFormBlock
|
|
| A2UITableBlock
|
|
| A2UICardsBlock;
|
|
|
|
// --- Tool Calls ---
|
|
|
|
export interface ToolCall {
|
|
id: string;
|
|
name: string;
|
|
args: string;
|
|
result?: string;
|
|
status: "pending" | "running" | "complete" | "error";
|
|
/** MCP App metadata -- when present, render MCPAppRenderer instead of ToolCallCard */
|
|
_meta?: MCPAppMeta;
|
|
/** Structured data for the MCP App iframe */
|
|
structuredContent?: MCPAppStructuredContent;
|
|
}
|
|
|
|
// --- MCP Apps (inline interactive visualizations) ---
|
|
|
|
/**
|
|
* MCP App metadata attached to tool results.
|
|
* When present, the frontend renders an MCPAppRenderer (sandboxed iframe)
|
|
* instead of a plain ToolCallCard.
|
|
*/
|
|
export interface MCPAppMeta {
|
|
ui: {
|
|
resourceUri: string; // e.g. "app://chart"
|
|
csp?: string; // Content Security Policy override
|
|
};
|
|
}
|
|
|
|
export interface MCPAppStructuredContent {
|
|
[key: string]: unknown; // App-specific data (chart data, table rows, etc.)
|
|
}
|
|
|
|
// --- Auth ---
|
|
|
|
export interface AuthUser {
|
|
email: string;
|
|
}
|
|
|
|
export interface LoginResponse {
|
|
token: string;
|
|
email: string;
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify it compiles**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench/frontend
|
|
npx tsc --noEmit --pretty 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: No errors (or only warnings from the template files which we'll replace).
|
|
|
|
**Step 3: Commit**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench
|
|
git add frontend/src/lib/types.ts && git commit -m "feat: TypeScript types for sessions, artifacts, A2UI, tool calls, auth"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Global styles (Tabler dark theme)
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/styles/globals.css`
|
|
|
|
**Step 1: Create the globals.css**
|
|
|
|
Create `frontend/src/styles/globals.css`:
|
|
|
|
```css
|
|
/* Import Tabler core CSS */
|
|
@import "@tabler/core/dist/css/tabler.min.css";
|
|
@import "@tabler/core/dist/css/tabler-vendors.min.css";
|
|
|
|
/* Force dark theme */
|
|
:root {
|
|
color-scheme: dark;
|
|
}
|
|
|
|
body {
|
|
background-color: #1a1a2e;
|
|
color: #e0e0e0;
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
margin: 0;
|
|
overflow: hidden;
|
|
height: 100vh;
|
|
}
|
|
|
|
#root {
|
|
height: 100vh;
|
|
display: flex;
|
|
}
|
|
|
|
/* --- Layout: 3-column --- */
|
|
|
|
.app-layout {
|
|
display: flex;
|
|
width: 100%;
|
|
height: 100vh;
|
|
}
|
|
|
|
.sidebar {
|
|
width: 240px;
|
|
min-width: 240px;
|
|
background-color: #16213e;
|
|
border-right: 1px solid #2a2a4a;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.chat-panel {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: 0;
|
|
}
|
|
|
|
.right-panel {
|
|
width: 45%;
|
|
min-width: 300px;
|
|
background-color: #16213e;
|
|
border-left: 1px solid #2a2a4a;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
/* --- Sidebar --- */
|
|
|
|
.sidebar-header {
|
|
padding: 1rem;
|
|
border-bottom: 1px solid #2a2a4a;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.sidebar-header h3 {
|
|
margin: 0;
|
|
font-size: 0.9rem;
|
|
color: #8888aa;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.session-list {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.session-item {
|
|
padding: 0.6rem 0.8rem;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
margin-bottom: 2px;
|
|
font-size: 0.85rem;
|
|
color: #c0c0d0;
|
|
transition: background-color 0.15s;
|
|
}
|
|
|
|
.session-item:hover {
|
|
background-color: #1e2a4a;
|
|
}
|
|
|
|
.session-item.active {
|
|
background-color: #2a3a5a;
|
|
color: #ffffff;
|
|
}
|
|
|
|
.session-item .session-meta {
|
|
font-size: 0.7rem;
|
|
color: #6a6a8a;
|
|
margin-top: 2px;
|
|
}
|
|
|
|
/* --- Chat --- */
|
|
|
|
.chat-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.chat-message {
|
|
margin-bottom: 1rem;
|
|
max-width: 85%;
|
|
}
|
|
|
|
.chat-message.user {
|
|
margin-left: auto;
|
|
background-color: #2a3a6a;
|
|
border-radius: 12px 12px 4px 12px;
|
|
padding: 0.8rem 1rem;
|
|
}
|
|
|
|
.chat-message.assistant {
|
|
background-color: #1e2a4a;
|
|
border-radius: 12px 12px 12px 4px;
|
|
padding: 0.8rem 1rem;
|
|
}
|
|
|
|
.chat-message.assistant p {
|
|
margin: 0.3rem 0;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.chat-input-area {
|
|
padding: 0.75rem 1rem;
|
|
border-top: 1px solid #2a2a4a;
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.chat-input-area input {
|
|
flex: 1;
|
|
background-color: #1e2a4a;
|
|
border: 1px solid #3a3a5a;
|
|
border-radius: 8px;
|
|
padding: 0.6rem 1rem;
|
|
color: #e0e0e0;
|
|
font-size: 0.9rem;
|
|
outline: none;
|
|
}
|
|
|
|
.chat-input-area input:focus {
|
|
border-color: #5a7aba;
|
|
}
|
|
|
|
.chat-input-area button {
|
|
background-color: #3a5aba;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
padding: 0.6rem 1.2rem;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.chat-input-area button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* --- Tool call cards --- */
|
|
|
|
.tool-call-card {
|
|
background-color: #12192e;
|
|
border: 1px solid #2a2a4a;
|
|
border-radius: 8px;
|
|
padding: 0.6rem 0.8rem;
|
|
margin: 0.5rem 0;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.tool-call-card .tool-name {
|
|
color: #7aa2f7;
|
|
font-weight: 600;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.tool-call-card .tool-args {
|
|
color: #8888aa;
|
|
font-family: monospace;
|
|
font-size: 0.75rem;
|
|
margin-top: 4px;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.tool-call-card .tool-status {
|
|
display: inline-block;
|
|
padding: 1px 6px;
|
|
border-radius: 4px;
|
|
font-size: 0.7rem;
|
|
margin-left: 0.5rem;
|
|
}
|
|
|
|
.tool-call-card .tool-status.running {
|
|
background-color: #2a4a3a;
|
|
color: #7af7a2;
|
|
}
|
|
|
|
.tool-call-card .tool-status.complete {
|
|
background-color: #2a3a4a;
|
|
color: #7aa2f7;
|
|
}
|
|
|
|
/* --- Right panel tabs --- */
|
|
|
|
.right-panel-tabs {
|
|
display: flex;
|
|
border-bottom: 1px solid #2a2a4a;
|
|
}
|
|
|
|
.right-panel-tabs button {
|
|
flex: 1;
|
|
padding: 0.6rem;
|
|
background: none;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
color: #8888aa;
|
|
cursor: pointer;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.right-panel-tabs button.active {
|
|
color: #ffffff;
|
|
border-bottom-color: #5a7aba;
|
|
}
|
|
|
|
.right-panel-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
/* --- Login page --- */
|
|
|
|
.login-page {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100vh;
|
|
width: 100%;
|
|
}
|
|
|
|
.login-card {
|
|
background-color: #16213e;
|
|
border: 1px solid #2a2a4a;
|
|
border-radius: 12px;
|
|
padding: 2rem;
|
|
width: 360px;
|
|
}
|
|
|
|
.login-card h2 {
|
|
margin-top: 0;
|
|
text-align: center;
|
|
}
|
|
|
|
.login-card input {
|
|
width: 100%;
|
|
padding: 0.6rem;
|
|
margin-bottom: 0.8rem;
|
|
background-color: #1e2a4a;
|
|
border: 1px solid #3a3a5a;
|
|
border-radius: 6px;
|
|
color: #e0e0e0;
|
|
font-size: 0.9rem;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.login-card button {
|
|
width: 100%;
|
|
padding: 0.7rem;
|
|
background-color: #3a5aba;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.login-card .error {
|
|
color: #f7607a;
|
|
font-size: 0.85rem;
|
|
text-align: center;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench
|
|
git add frontend/src/styles/globals.css && git commit -m "feat: global styles with Tabler dark theme, 3-column layout"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: API client and hooks
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/lib/api.ts`
|
|
- Create: `frontend/src/hooks/useAuth.ts`
|
|
- Create: `frontend/src/hooks/useSessions.ts`
|
|
- Create: `frontend/src/hooks/useChat.ts`
|
|
- Create: `frontend/src/hooks/useArtifacts.ts`
|
|
|
|
**Step 1: Create the API client**
|
|
|
|
Create `frontend/src/lib/api.ts`:
|
|
|
|
```typescript
|
|
/**
|
|
* HTTP client for the Research Workbench backend API.
|
|
* All endpoints are relative (proxied by Vite in dev, served by FastAPI in prod).
|
|
*/
|
|
|
|
import type {
|
|
SessionListResponse,
|
|
Artifact,
|
|
ArtifactContent,
|
|
LoginResponse,
|
|
AuthUser,
|
|
} from "./types";
|
|
|
|
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
|
const resp = await fetch(url, {
|
|
credentials: "include",
|
|
...options,
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...options?.headers,
|
|
},
|
|
});
|
|
if (!resp.ok) {
|
|
const body = await resp.text();
|
|
throw new Error(`${resp.status}: ${body}`);
|
|
}
|
|
return resp.json();
|
|
}
|
|
|
|
// Auth
|
|
export const login = (email: string, password: string) =>
|
|
request<LoginResponse>("/api/auth/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
|
|
export const getMe = () => request<AuthUser>("/api/auth/me");
|
|
|
|
export const logout = () =>
|
|
request<{ status: string }>("/api/auth/logout", { method: "POST" });
|
|
|
|
// Sessions
|
|
export const listSessions = () =>
|
|
request<SessionListResponse>("/api/sessions");
|
|
|
|
export const createSession = (name?: string) =>
|
|
request<{ session_id: string }>("/api/sessions", {
|
|
method: "POST",
|
|
body: JSON.stringify({ name }),
|
|
});
|
|
|
|
export const deleteSession = (sessionId: string) =>
|
|
request<{ status: string }>(`/api/sessions/${sessionId}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
export const getTranscript = (sessionId: string) =>
|
|
request<{ messages: Array<{ role: string; content: string }> }>(
|
|
`/api/sessions/${sessionId}/transcript`
|
|
);
|
|
|
|
// Artifacts
|
|
export const listArtifacts = (sessionId: string) =>
|
|
request<Artifact[]>(`/api/artifacts/${sessionId}`);
|
|
|
|
export const getArtifact = (sessionId: string, name: string) =>
|
|
request<ArtifactContent>(`/api/artifacts/${sessionId}/${name}`);
|
|
```
|
|
|
|
**Step 2: Create the auth hook**
|
|
|
|
Create `frontend/src/hooks/useAuth.ts`:
|
|
|
|
```typescript
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import * as api from "../lib/api";
|
|
import type { AuthUser } from "../lib/types";
|
|
|
|
export function useAuth() {
|
|
const [user, setUser] = useState<AuthUser | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Check if already authenticated on mount
|
|
useEffect(() => {
|
|
api
|
|
.getMe()
|
|
.then(setUser)
|
|
.catch(() => setUser(null))
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
const doLogin = useCallback(async (email: string, password: string) => {
|
|
const resp = await api.login(email, password);
|
|
setUser({ email: resp.email });
|
|
}, []);
|
|
|
|
const doLogout = useCallback(async () => {
|
|
await api.logout();
|
|
setUser(null);
|
|
}, []);
|
|
|
|
return { user, loading, login: doLogin, logout: doLogout };
|
|
}
|
|
```
|
|
|
|
**Step 3: Create the sessions hook**
|
|
|
|
Create `frontend/src/hooks/useSessions.ts`:
|
|
|
|
```typescript
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import * as api from "../lib/api";
|
|
import type { Session } from "../lib/types";
|
|
|
|
export function useSessions() {
|
|
const [sessions, setSessions] = useState<Session[]>([]);
|
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const refresh = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await api.listSessions();
|
|
setSessions(data.sessions);
|
|
} catch (e) {
|
|
console.error("Failed to list sessions:", e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
refresh();
|
|
}, [refresh]);
|
|
|
|
const createNew = useCallback(async () => {
|
|
try {
|
|
const data = await api.createSession();
|
|
await refresh();
|
|
setActiveSessionId(data.session_id);
|
|
return data.session_id;
|
|
} catch (e) {
|
|
console.error("Failed to create session:", e);
|
|
return null;
|
|
}
|
|
}, [refresh]);
|
|
|
|
const switchTo = useCallback((sessionId: string) => {
|
|
setActiveSessionId(sessionId);
|
|
}, []);
|
|
|
|
const activeSession = sessions.find((s) => s.session_id === activeSessionId) ?? null;
|
|
|
|
return { sessions, activeSession, activeSessionId, loading, createNew, switchTo, refresh };
|
|
}
|
|
```
|
|
|
|
**Step 4: Create the chat hook**
|
|
|
|
This is the core hook. It connects to the backend's AG-UI SSE endpoint and manages the message stream.
|
|
|
|
Create `frontend/src/hooks/useChat.ts`:
|
|
|
|
```typescript
|
|
import { useState, useCallback, useRef } from "react";
|
|
import type { ToolCall } from "../lib/types";
|
|
|
|
export interface ChatMessage {
|
|
id: string;
|
|
role: "user" | "assistant" | "tool";
|
|
content: string;
|
|
toolCalls?: ToolCall[];
|
|
}
|
|
|
|
export function useChat(sessionId: string | null) {
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
|
|
const sendMessage = useCallback(
|
|
async (content: string) => {
|
|
if (!sessionId || !content.trim()) return;
|
|
|
|
// Add user message
|
|
const userMsg: ChatMessage = {
|
|
id: `user-${Date.now()}`,
|
|
role: "user",
|
|
content: content.trim(),
|
|
};
|
|
setMessages((prev) => [...prev, userMsg]);
|
|
setIsStreaming(true);
|
|
|
|
// Prepare AG-UI request
|
|
const allMessages = [...messages, userMsg].map((m) => ({
|
|
id: m.id,
|
|
role: m.role,
|
|
content: m.content,
|
|
}));
|
|
|
|
try {
|
|
abortRef.current = new AbortController();
|
|
const response = await fetch("/api/copilotkit", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
threadId: sessionId,
|
|
runId: `run-${Date.now()}`,
|
|
messages: allMessages,
|
|
tools: [],
|
|
context: [],
|
|
}),
|
|
signal: abortRef.current.signal,
|
|
});
|
|
|
|
if (!response.ok || !response.body) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
|
|
// Process SSE stream
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
let assistantContent = "";
|
|
let assistantId = "";
|
|
const toolCalls: Record<string, ToolCall> = {};
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n\n");
|
|
buffer = lines.pop() ?? "";
|
|
|
|
for (const chunk of lines) {
|
|
if (!chunk.startsWith("data: ")) continue;
|
|
try {
|
|
const event = JSON.parse(chunk.slice(6));
|
|
switch (event.type) {
|
|
case "TEXT_MESSAGE_START":
|
|
assistantId = event.messageId;
|
|
assistantContent = "";
|
|
break;
|
|
case "TEXT_MESSAGE_CONTENT":
|
|
assistantContent += event.delta;
|
|
// Update message in place for streaming effect
|
|
setMessages((prev) => {
|
|
const existing = prev.find((m) => m.id === assistantId);
|
|
if (existing) {
|
|
return prev.map((m) =>
|
|
m.id === assistantId ? { ...m, content: assistantContent } : m
|
|
);
|
|
}
|
|
return [
|
|
...prev,
|
|
{
|
|
id: assistantId,
|
|
role: "assistant" as const,
|
|
content: assistantContent,
|
|
},
|
|
];
|
|
});
|
|
break;
|
|
case "TEXT_MESSAGE_END":
|
|
// Content is already fully updated via deltas
|
|
break;
|
|
case "TOOL_CALL_START":
|
|
toolCalls[event.toolCallId] = {
|
|
id: event.toolCallId,
|
|
name: event.toolCallName,
|
|
args: "",
|
|
status: "running",
|
|
};
|
|
break;
|
|
case "TOOL_CALL_ARGS":
|
|
if (toolCalls[event.toolCallId]) {
|
|
toolCalls[event.toolCallId].args += event.delta;
|
|
}
|
|
break;
|
|
case "TOOL_CALL_END":
|
|
if (toolCalls[event.toolCallId]) {
|
|
toolCalls[event.toolCallId].status = "complete";
|
|
}
|
|
// Update the assistant message with tool calls
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === assistantId
|
|
? { ...m, toolCalls: Object.values(toolCalls) }
|
|
: m
|
|
)
|
|
);
|
|
break;
|
|
case "TOOL_CALL_RESULT":
|
|
if (toolCalls[event.toolCallId]) {
|
|
toolCalls[event.toolCallId].result = event.content;
|
|
// Capture MCP App metadata for inline iframe rendering
|
|
if (event._meta?.ui?.resourceUri) {
|
|
toolCalls[event.toolCallId]._meta = event._meta;
|
|
}
|
|
if (event.structuredContent) {
|
|
toolCalls[event.toolCallId].structuredContent = event.structuredContent;
|
|
}
|
|
}
|
|
// Update the assistant message with tool call results (including MCP App data)
|
|
setMessages((prev) =>
|
|
prev.map((m) =>
|
|
m.id === assistantId
|
|
? { ...m, toolCalls: Object.values(toolCalls) }
|
|
: m
|
|
)
|
|
);
|
|
break;
|
|
case "RUN_FINISHED":
|
|
break;
|
|
}
|
|
} catch {
|
|
// Skip unparseable chunks
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === "AbortError") {
|
|
// User cancelled
|
|
} else {
|
|
console.error("Chat stream error:", err);
|
|
}
|
|
} finally {
|
|
setIsStreaming(false);
|
|
}
|
|
},
|
|
[sessionId, messages]
|
|
);
|
|
|
|
const cancel = useCallback(() => {
|
|
abortRef.current?.abort();
|
|
}, []);
|
|
|
|
const clearMessages = useCallback(() => {
|
|
setMessages([]);
|
|
}, []);
|
|
|
|
return { messages, isStreaming, sendMessage, cancel, clearMessages };
|
|
}
|
|
```
|
|
|
|
**Step 5: Create the artifacts hook**
|
|
|
|
Create `frontend/src/hooks/useArtifacts.ts`:
|
|
|
|
```typescript
|
|
import { useState, useCallback } from "react";
|
|
import * as api from "../lib/api";
|
|
import type { Artifact, ArtifactContent } from "../lib/types";
|
|
|
|
export function useArtifacts(sessionId: string | null) {
|
|
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
|
const [activeArtifact, setActiveArtifact] = useState<ArtifactContent | null>(null);
|
|
|
|
const refresh = useCallback(async () => {
|
|
if (!sessionId) return;
|
|
try {
|
|
const data = await api.listArtifacts(sessionId);
|
|
setArtifacts(data);
|
|
} catch (e) {
|
|
console.error("Failed to list artifacts:", e);
|
|
}
|
|
}, [sessionId]);
|
|
|
|
const loadArtifact = useCallback(
|
|
async (name: string) => {
|
|
if (!sessionId) return;
|
|
try {
|
|
const data = await api.getArtifact(sessionId, name);
|
|
setActiveArtifact(data);
|
|
} catch (e) {
|
|
console.error("Failed to load artifact:", e);
|
|
}
|
|
},
|
|
[sessionId]
|
|
);
|
|
|
|
return { artifacts, activeArtifact, refresh, loadArtifact };
|
|
}
|
|
```
|
|
|
|
**Step 6: Commit**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench
|
|
git add frontend/src/lib/api.ts frontend/src/hooks/ && git commit -m "feat: API client and hooks for auth, sessions, chat SSE, artifacts"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Login page component
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/LoginPage.tsx`
|
|
|
|
**Step 1: Create the login page**
|
|
|
|
Create `frontend/src/components/LoginPage.tsx`:
|
|
|
|
```tsx
|
|
import { useState, type FormEvent } from "react";
|
|
|
|
interface LoginPageProps {
|
|
onLogin: (email: string, password: string) => Promise<void>;
|
|
}
|
|
|
|
export function LoginPage({ onLogin }: LoginPageProps) {
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [error, setError] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const handleSubmit = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
setError("");
|
|
setLoading(true);
|
|
try {
|
|
await onLogin(email, password);
|
|
} catch {
|
|
setError("Invalid credentials");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="login-page">
|
|
<form className="login-card" onSubmit={handleSubmit}>
|
|
<h2>Research Workbench</h2>
|
|
{error && <div className="error">{error}</div>}
|
|
<input
|
|
type="email"
|
|
placeholder="Email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
autoFocus
|
|
required
|
|
/>
|
|
<input
|
|
type="password"
|
|
placeholder="Password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
/>
|
|
<button type="submit" disabled={loading}>
|
|
{loading ? "Signing in..." : "Sign In"}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench
|
|
git add frontend/src/components/LoginPage.tsx && git commit -m "feat: login page component"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Session sidebar component
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/SessionSidebar.tsx`
|
|
|
|
**Step 1: Create the sidebar**
|
|
|
|
Create `frontend/src/components/SessionSidebar.tsx`:
|
|
|
|
```tsx
|
|
import { IconPlus, IconLogout } from "@tabler/icons-react";
|
|
import type { Session } from "../lib/types";
|
|
|
|
interface SessionSidebarProps {
|
|
sessions: Session[];
|
|
activeSessionId: string | null;
|
|
onSelect: (sessionId: string) => void;
|
|
onCreate: () => void;
|
|
onLogout: () => void;
|
|
}
|
|
|
|
export function SessionSidebar({
|
|
sessions,
|
|
activeSessionId,
|
|
onSelect,
|
|
onCreate,
|
|
onLogout,
|
|
}: SessionSidebarProps) {
|
|
return (
|
|
<div className="sidebar">
|
|
<div className="sidebar-header">
|
|
<h3>Sessions</h3>
|
|
<div style={{ display: "flex", gap: "0.3rem" }}>
|
|
<button
|
|
onClick={onCreate}
|
|
title="New session"
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: "#7aa2f7",
|
|
cursor: "pointer",
|
|
padding: "4px",
|
|
}}
|
|
>
|
|
<IconPlus size={18} />
|
|
</button>
|
|
<button
|
|
onClick={onLogout}
|
|
title="Log out"
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: "#8888aa",
|
|
cursor: "pointer",
|
|
padding: "4px",
|
|
}}
|
|
>
|
|
<IconLogout size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="session-list">
|
|
{sessions.length === 0 && (
|
|
<div style={{ padding: "1rem", color: "#6a6a8a", fontSize: "0.85rem" }}>
|
|
No sessions yet. Click + to start researching.
|
|
</div>
|
|
)}
|
|
{sessions.map((s) => (
|
|
<div
|
|
key={s.session_id}
|
|
className={`session-item ${s.session_id === activeSessionId ? "active" : ""}`}
|
|
onClick={() => onSelect(s.session_id)}
|
|
>
|
|
<div>{s.session_id.slice(0, 12)}...</div>
|
|
<div className="session-meta">
|
|
{s.total_messages ?? 0} msgs · {s.status}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench
|
|
git add frontend/src/components/SessionSidebar.tsx && git commit -m "feat: session sidebar component with create, select, logout"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Chat panel and tool call card components
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/ChatPanel.tsx`
|
|
- Create: `frontend/src/components/ToolCallCard.tsx`
|
|
|
|
**Step 1: Create the ToolCallCard**
|
|
|
|
Create `frontend/src/components/ToolCallCard.tsx`:
|
|
|
|
```tsx
|
|
import { IconTerminal, IconCheck, IconLoader } from "@tabler/icons-react";
|
|
import type { ToolCall } from "../lib/types";
|
|
|
|
interface ToolCallCardProps {
|
|
toolCall: ToolCall;
|
|
}
|
|
|
|
export function ToolCallCard({ toolCall }: ToolCallCardProps) {
|
|
// Try to extract a readable summary from the tool args
|
|
let argsSummary = toolCall.args;
|
|
try {
|
|
const parsed = JSON.parse(toolCall.args);
|
|
if (parsed.command) {
|
|
argsSummary = parsed.command;
|
|
}
|
|
} catch {
|
|
// Use raw args
|
|
}
|
|
|
|
return (
|
|
<div className="tool-call-card">
|
|
<span className="tool-name">
|
|
<IconTerminal size={14} style={{ verticalAlign: "middle", marginRight: 4 }} />
|
|
{toolCall.name}
|
|
</span>
|
|
<span className={`tool-status ${toolCall.status}`}>
|
|
{toolCall.status === "running" ? (
|
|
<IconLoader size={12} />
|
|
) : (
|
|
<IconCheck size={12} />
|
|
)}{" "}
|
|
{toolCall.status}
|
|
</span>
|
|
{argsSummary && <div className="tool-args">{argsSummary}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 2: Create the ChatPanel**
|
|
|
|
Create `frontend/src/components/ChatPanel.tsx`:
|
|
|
|
```tsx
|
|
import { useState, useRef, useEffect, type KeyboardEvent } from "react";
|
|
import ReactMarkdown from "react-markdown";
|
|
import { IconSend } from "@tabler/icons-react";
|
|
import { ToolCallCard } from "./ToolCallCard";
|
|
import type { ChatMessage } from "../hooks/useChat";
|
|
|
|
interface ChatPanelProps {
|
|
messages: ChatMessage[];
|
|
isStreaming: boolean;
|
|
onSend: (content: string) => void;
|
|
sessionId: string | null;
|
|
}
|
|
|
|
export function ChatPanel({ messages, isStreaming, onSend, sessionId }: ChatPanelProps) {
|
|
const [input, setInput] = useState("");
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Auto-scroll to bottom on new messages
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages]);
|
|
|
|
const handleSend = () => {
|
|
if (input.trim() && !isStreaming) {
|
|
onSend(input.trim());
|
|
setInput("");
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
if (!sessionId) {
|
|
return (
|
|
<div className="chat-panel" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
<div style={{ color: "#6a6a8a", fontSize: "1.1rem" }}>
|
|
Select or create a session to start researching
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="chat-panel">
|
|
<div className="chat-messages">
|
|
{messages.map((msg) => (
|
|
<div key={msg.id} className={`chat-message ${msg.role}`}>
|
|
{msg.role === "assistant" ? (
|
|
<>
|
|
<ReactMarkdown>{msg.content}</ReactMarkdown>
|
|
{msg.toolCalls?.map((tc) => (
|
|
<ToolCallCard key={tc.id} toolCall={tc} />
|
|
))}
|
|
</>
|
|
) : (
|
|
<div>{msg.content}</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{isStreaming && messages.length > 0 && (
|
|
<div style={{ color: "#6a6a8a", fontSize: "0.8rem", padding: "0.5rem" }}>
|
|
Researching...
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
<div className="chat-input-area">
|
|
<input
|
|
type="text"
|
|
placeholder="Ask a research question..."
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
disabled={isStreaming}
|
|
autoFocus
|
|
/>
|
|
<button onClick={handleSend} disabled={isStreaming || !input.trim()}>
|
|
<IconSend size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 3: Commit**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench
|
|
git add frontend/src/components/ChatPanel.tsx frontend/src/components/ToolCallCard.tsx && git commit -m "feat: chat panel with SSE streaming and tool call cards"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: App shell and main entry point
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/App.tsx`
|
|
- Modify: `frontend/src/main.tsx`
|
|
- Create: `frontend/src/components/RightPanel.tsx`
|
|
|
|
**Step 1: Create the RightPanel placeholder**
|
|
|
|
Create `frontend/src/components/RightPanel.tsx`:
|
|
|
|
```tsx
|
|
/**
|
|
* Right panel with Browser and Artifacts tabs.
|
|
* Browser tab renders noVNC iframe. Artifacts tab shows per-session docs.
|
|
* Full implementation in Phase 3.
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
|
|
interface RightPanelProps {
|
|
sessionId: string | null;
|
|
}
|
|
|
|
export function RightPanel({ sessionId }: RightPanelProps) {
|
|
const [activeTab, setActiveTab] = useState<"browser" | "artifacts">("browser");
|
|
|
|
return (
|
|
<div className="right-panel">
|
|
<div className="right-panel-tabs">
|
|
<button
|
|
className={activeTab === "browser" ? "active" : ""}
|
|
onClick={() => setActiveTab("browser")}
|
|
>
|
|
Browser
|
|
</button>
|
|
<button
|
|
className={activeTab === "artifacts" ? "active" : ""}
|
|
onClick={() => setActiveTab("artifacts")}
|
|
>
|
|
Artifacts
|
|
</button>
|
|
</div>
|
|
<div className="right-panel-content">
|
|
{activeTab === "browser" ? (
|
|
<div style={{ padding: "1rem", color: "#6a6a8a" }}>
|
|
{sessionId
|
|
? "Browser panel (noVNC) — implemented in Phase 3"
|
|
: "Select a session to view the browser"}
|
|
</div>
|
|
) : (
|
|
<div style={{ padding: "1rem", color: "#6a6a8a" }}>
|
|
{sessionId
|
|
? "Artifacts panel — implemented in Phase 3"
|
|
: "Select a session to view artifacts"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 2: Replace App.tsx**
|
|
|
|
Replace `frontend/src/App.tsx` with:
|
|
|
|
```tsx
|
|
import { useAuth } from "./hooks/useAuth";
|
|
import { useSessions } from "./hooks/useSessions";
|
|
import { useChat } from "./hooks/useChat";
|
|
import { LoginPage } from "./components/LoginPage";
|
|
import { SessionSidebar } from "./components/SessionSidebar";
|
|
import { ChatPanel } from "./components/ChatPanel";
|
|
import { RightPanel } from "./components/RightPanel";
|
|
|
|
export default function App() {
|
|
const { user, loading: authLoading, login, logout } = useAuth();
|
|
const { sessions, activeSessionId, createNew, switchTo, refresh } = useSessions();
|
|
const { messages, isStreaming, sendMessage } = useChat(activeSessionId);
|
|
|
|
// Show loading while checking auth
|
|
if (authLoading) {
|
|
return (
|
|
<div className="login-page">
|
|
<div style={{ color: "#8888aa" }}>Loading...</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Show login if not authenticated
|
|
if (!user) {
|
|
return <LoginPage onLogin={login} />;
|
|
}
|
|
|
|
return (
|
|
<div className="app-layout">
|
|
<SessionSidebar
|
|
sessions={sessions}
|
|
activeSessionId={activeSessionId}
|
|
onSelect={(id) => {
|
|
switchTo(id);
|
|
}}
|
|
onCreate={async () => {
|
|
await createNew();
|
|
await refresh();
|
|
}}
|
|
onLogout={logout}
|
|
/>
|
|
<ChatPanel
|
|
messages={messages}
|
|
isStreaming={isStreaming}
|
|
onSend={sendMessage}
|
|
sessionId={activeSessionId}
|
|
/>
|
|
<RightPanel sessionId={activeSessionId} />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 3: Replace main.tsx**
|
|
|
|
Replace `frontend/src/main.tsx` with:
|
|
|
|
```tsx
|
|
import { StrictMode } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import App from "./App";
|
|
import "./styles/globals.css";
|
|
|
|
createRoot(document.getElementById("root")!).render(
|
|
<StrictMode>
|
|
<App />
|
|
</StrictMode>
|
|
);
|
|
```
|
|
|
|
**Step 4: Clean up unused Vite template files**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench/frontend
|
|
rm -f src/App.css src/index.css src/assets/react.svg public/vite.svg
|
|
```
|
|
|
|
**Step 5: Verify the build compiles**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench/frontend
|
|
npm run build 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: Build succeeds. Output in `frontend/dist/`.
|
|
|
|
**Step 6: Commit**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench
|
|
git add frontend/src/ && git commit -m "feat: App shell with login, session sidebar, chat panel, right panel"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: MCP App Renderer component (sandboxed iframe for MCP Apps)
|
|
|
|
Tool results with `_meta.ui.resourceUri` trigger inline interactive visualizations rendered in sandboxed iframes. This component handles the full MCP Apps lifecycle: fetching the HTML template, setting up the iframe sandbox, and bridging JSON-RPC 2.0 communication over `postMessage`.
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/MCPAppRenderer.tsx`
|
|
- Create: `frontend/src/components/__tests__/MCPAppRenderer.test.tsx`
|
|
|
|
**Step 1: Create the MCPAppRenderer component**
|
|
|
|
Create `frontend/src/components/MCPAppRenderer.tsx`:
|
|
|
|
```tsx
|
|
import { useRef, useEffect, useCallback, useState } from "react";
|
|
import { IconAppWindow, IconLoader } from "@tabler/icons-react";
|
|
|
|
interface MCPAppRendererProps {
|
|
/** Resource URI from tool result _meta.ui.resourceUri, e.g. "app://chart" */
|
|
resourceUri: string;
|
|
/** Structured data to send to the iframe app */
|
|
data: Record<string, unknown>;
|
|
/** Optional CSP override from _meta.ui.csp */
|
|
csp?: string;
|
|
/** Callback when the iframe app calls tools/call */
|
|
onToolCall?: (toolName: string, args: Record<string, unknown>) => void;
|
|
/** Callback when the iframe app sends a user message via ui/sendMessage */
|
|
onSendMessage?: (content: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Renders an MCP App (SEP-1865) in a sandboxed iframe.
|
|
*
|
|
* Flow:
|
|
* 1. Parse resourceUri ("app://chart" -> "/api/apps/chart")
|
|
* 2. Fetch the HTML from the backend
|
|
* 3. Set iframe srcdoc with the HTML
|
|
* 4. Listen for postMessage JSON-RPC 2.0 calls from the iframe
|
|
* 5. On "ui/initialize", send the structured data as tool/input
|
|
* 6. On "tools/call", proxy to the backend and return the result
|
|
* 7. On "ui/sendMessage", inject into the chat
|
|
*/
|
|
export function MCPAppRenderer({
|
|
resourceUri,
|
|
data,
|
|
csp,
|
|
onToolCall,
|
|
onSendMessage,
|
|
}: MCPAppRendererProps) {
|
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [iframeHeight, setIframeHeight] = useState(300);
|
|
|
|
// Parse app name from resourceUri: "app://chart" -> "chart"
|
|
const appName = resourceUri.replace(/^app:\/\//, "");
|
|
|
|
// Fetch HTML and set iframe srcdoc
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function loadApp() {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const resp = await fetch(`/api/apps/${appName}`, {
|
|
credentials: "include",
|
|
});
|
|
if (!resp.ok) {
|
|
throw new Error(`Failed to load app: ${resp.status}`);
|
|
}
|
|
const html = await resp.text();
|
|
if (!cancelled && iframeRef.current) {
|
|
iframeRef.current.srcdoc = html;
|
|
}
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
setError(err instanceof Error ? err.message : "Failed to load app");
|
|
}
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
}
|
|
|
|
loadApp();
|
|
return () => { cancelled = true; };
|
|
}, [appName]);
|
|
|
|
// Handle incoming JSON-RPC 2.0 messages from the iframe
|
|
const handleMessage = useCallback(
|
|
(event: MessageEvent) => {
|
|
// Only accept messages from our iframe
|
|
if (!iframeRef.current?.contentWindow) return;
|
|
|
|
const msg = event.data;
|
|
if (!msg || typeof msg !== "object" || msg.jsonrpc !== "2.0") return;
|
|
|
|
switch (msg.method) {
|
|
case "ui/initialize":
|
|
// App is ready -- send it the structured data
|
|
iframeRef.current.contentWindow.postMessage(
|
|
{
|
|
jsonrpc: "2.0",
|
|
method: "tool/input",
|
|
params: data,
|
|
},
|
|
"*"
|
|
);
|
|
break;
|
|
|
|
case "tools/call":
|
|
// App wants to call a server tool
|
|
if (onToolCall) {
|
|
onToolCall(msg.params?.name, msg.params?.arguments ?? {});
|
|
}
|
|
// Send back an acknowledgment (the tool result will come async)
|
|
iframeRef.current.contentWindow.postMessage(
|
|
{
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { status: "accepted" },
|
|
},
|
|
"*"
|
|
);
|
|
break;
|
|
|
|
case "ui/sendMessage":
|
|
// App wants to inject a user message into the chat
|
|
if (onSendMessage && msg.params?.content) {
|
|
onSendMessage(msg.params.content);
|
|
}
|
|
break;
|
|
|
|
case "ui/resize":
|
|
// App requests a height change
|
|
if (msg.params?.height && typeof msg.params.height === "number") {
|
|
setIframeHeight(Math.min(msg.params.height, 800));
|
|
}
|
|
break;
|
|
}
|
|
},
|
|
[data, onToolCall, onSendMessage]
|
|
);
|
|
|
|
// Attach/detach postMessage listener
|
|
useEffect(() => {
|
|
window.addEventListener("message", handleMessage);
|
|
return () => window.removeEventListener("message", handleMessage);
|
|
}, [handleMessage]);
|
|
|
|
if (error) {
|
|
return (
|
|
<div
|
|
className="tool-call-card"
|
|
style={{ color: "#f7607a", fontSize: "0.8rem" }}
|
|
>
|
|
<IconAppWindow size={14} style={{ marginRight: 4 }} />
|
|
MCP App error: {error}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="card"
|
|
style={{
|
|
margin: "0.5rem 0",
|
|
border: "1px solid #2a2a4a",
|
|
borderRadius: "8px",
|
|
overflow: "hidden",
|
|
backgroundColor: "#0a0a1a",
|
|
}}
|
|
>
|
|
{/* Header bar */}
|
|
<div
|
|
style={{
|
|
padding: "0.3rem 0.6rem",
|
|
borderBottom: "1px solid #2a2a4a",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.3rem",
|
|
fontSize: "0.75rem",
|
|
color: "#7aa2f7",
|
|
backgroundColor: "#12192e",
|
|
}}
|
|
>
|
|
{loading ? <IconLoader size={14} /> : <IconAppWindow size={14} />}
|
|
<span>{appName}</span>
|
|
</div>
|
|
|
|
{/* Sandboxed iframe */}
|
|
<iframe
|
|
ref={iframeRef}
|
|
title={`MCP App: ${appName}`}
|
|
sandbox="allow-scripts allow-forms allow-popups"
|
|
style={{
|
|
width: "100%",
|
|
height: `${iframeHeight}px`,
|
|
border: "none",
|
|
backgroundColor: "#0a0a1a",
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
**Step 2: Create a basic test**
|
|
|
|
Create `frontend/src/components/__tests__/MCPAppRenderer.test.tsx`:
|
|
|
|
```tsx
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
/**
|
|
* MCPAppRenderer is primarily integration-tested (needs iframe + postMessage).
|
|
* These tests verify the component module loads and the app name parsing logic.
|
|
*/
|
|
|
|
describe("MCPAppRenderer", () => {
|
|
it("module exports MCPAppRenderer component", async () => {
|
|
const mod = await import("../MCPAppRenderer");
|
|
expect(mod.MCPAppRenderer).toBeDefined();
|
|
expect(typeof mod.MCPAppRenderer).toBe("function");
|
|
});
|
|
|
|
it("parses app:// resource URIs correctly", () => {
|
|
// The component internally does: resourceUri.replace(/^app:\/\//, "")
|
|
const uri = "app://chart";
|
|
const appName = uri.replace(/^app:\/\//, "");
|
|
expect(appName).toBe("chart");
|
|
});
|
|
|
|
it("handles bare app names", () => {
|
|
const uri = "app://comparison";
|
|
const appName = uri.replace(/^app:\/\//, "");
|
|
expect(appName).toBe("comparison");
|
|
});
|
|
});
|
|
```
|
|
|
|
**Step 3: Install vitest if not present and run test**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench/frontend
|
|
npm install -D vitest @testing-library/react jsdom
|
|
npx vitest run src/components/__tests__/MCPAppRenderer.test.tsx 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: All 3 tests PASS.
|
|
|
|
**Step 4: Commit**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench
|
|
git add frontend/src/components/MCPAppRenderer.tsx frontend/src/components/__tests__/ && git commit -m "feat: MCPAppRenderer for inline MCP App iframes with JSON-RPC postMessage bridge"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Wire MCP App Renderer into ChatPanel
|
|
|
|
When a tool call result includes `_meta.ui.resourceUri`, render an `MCPAppRenderer` instead of a `ToolCallCard`. This requires updating the ChatPanel to check each tool call for MCP App metadata.
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/components/ChatPanel.tsx`
|
|
|
|
**Step 1: Add MCPAppRenderer import to ChatPanel**
|
|
|
|
Add this import at the top of `frontend/src/components/ChatPanel.tsx`, alongside the existing imports:
|
|
|
|
```tsx
|
|
import { MCPAppRenderer } from "./MCPAppRenderer";
|
|
```
|
|
|
|
**Step 2: Update tool call rendering to detect MCP Apps**
|
|
|
|
In `frontend/src/components/ChatPanel.tsx`, find the tool call rendering section inside `messages.map()`:
|
|
|
|
Replace:
|
|
```tsx
|
|
{msg.toolCalls?.map((tc) => (
|
|
<ToolCallCard key={tc.id} toolCall={tc} />
|
|
))}
|
|
```
|
|
|
|
With:
|
|
```tsx
|
|
{msg.toolCalls?.map((tc) =>
|
|
tc._meta?.ui?.resourceUri ? (
|
|
<MCPAppRenderer
|
|
key={tc.id}
|
|
resourceUri={tc._meta.ui.resourceUri}
|
|
data={tc.structuredContent ?? {}}
|
|
csp={tc._meta.ui.csp}
|
|
onSendMessage={(content) => onSend(content)}
|
|
/>
|
|
) : (
|
|
<ToolCallCard key={tc.id} toolCall={tc} />
|
|
)
|
|
)}
|
|
```
|
|
|
|
**Step 3: Verify the build compiles**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench/frontend
|
|
npm run build 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: Build succeeds.
|
|
|
|
**Step 4: Commit**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench
|
|
git add frontend/src/components/ChatPanel.tsx && git commit -m "feat: wire MCPAppRenderer into ChatPanel for MCP App tool results"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2 Complete
|
|
|
|
At this point you have:
|
|
- Full React SPA with login flow, session sidebar, streaming chat, tool call rendering
|
|
- MCP App Renderer component for inline interactive HTML visualizations in sandboxed iframes
|
|
- Tool call results with `_meta.ui.resourceUri` automatically render as MCP Apps instead of ToolCallCards
|
|
- Direct SSE streaming from the AG-UI endpoint (no CopilotKit runtime dependency)
|
|
- Tabler dark theme with 3-column layout
|
|
- Right panel placeholder (Browser + Artifacts tabs, implemented in Phase 3)
|
|
- Build output in `frontend/dist/` ready for Docker
|
|
|
|
**Verify the build:**
|
|
|
|
Run:
|
|
```bash
|
|
cd /home/ken/workspace/research-workbench/frontend
|
|
npm run build && echo "BUILD OK" && ls -la dist/
|
|
```
|
|
|
|
Expected: `BUILD OK` with `dist/index.html` and `dist/assets/` directory. |