diff --git a/.agent/skills/frontend.md b/.agent/skills/frontend.md deleted file mode 100644 index 46889ae8..00000000 --- a/.agent/skills/frontend.md +++ /dev/null @@ -1,548 +0,0 @@ -# Skill: React Chat Frontend with OpenHands TypeScript Client - -Build a React chat frontend that connects to an OpenHands agent server, sends messages, and displays a live conversation including all event types. - -## Basic structure -You will probably want the following features: -* A settings modal/page where the user can set: - * host and password for agent-server - * LLM model and key -* a chat input -* the ability to display both messages and events from the agent - * events typically come in action/observation pairs. it's usually best to only show one UI element per pair, depending on status - * events can contain a lot of information. good to make the display collapsed by default, with a small summary, but expandable to see full details - * condensation events should get displayed like other events -* a list of conversations the user can navigate between - * there are APIs for automatically setting a conversation title after the first user message is sent, use them - -## Packages - -```bash -npm install @openhands/typescript-client -# or from local repo: -npm install /path/to/typescript-client -``` - -Peer deps: `react`, `react-dom` (18+). No extra WebSocket library needed in browsers. - ---- - -## 1. Core Concepts - -- **`Conversation`** — creates/resumes a conversation on the agent server -- **`Agent`** — LLM config (model + api_key) -- **`Workspace`** — points at the agent server host -- **`WebSocketCallbackClient`** — streams events in real-time via WS -- Events arrive with a `kind` discriminator field; render each kind differently - -The agent server WebSocket endpoint is: -``` -ws[s]:///sockets/events/[?session_api_key=] -``` - ---- - -## 2. Minimal Hook: `useAgentConversation` - -```tsx -import { useEffect, useRef, useState, useCallback } from 'react'; -import { - Conversation, - Agent, - Workspace, - ConversationExecutionStatus, -} from '@openhands/typescript-client'; -import type { Event } from '@openhands/typescript-client'; - -interface UseAgentConversationOptions { - serverUrl: string; // e.g. "http://localhost:8000" - model: string; // e.g. "claude-sonnet-4-6" - apiKey: string; // LLM API key - sessionKey?: string; // Agent server session key (if secured) -} - -export function useAgentConversation({ - serverUrl, - model, - apiKey, - sessionKey, -}: UseAgentConversationOptions) { - const [events, setEvents] = useState([]); - const [status, setStatus] = useState(null); - const [conversationId, setConversationId] = useState(null); - const convRef = useRef(null); - - // Append incoming events (WebSocket + initial load) - const onEvent = useCallback((event: Event) => { - setEvents(prev => [...prev, event]); - }, []); - - const start = useCallback(async (initialMessage: string) => { - const agent = new Agent({ llm: { model, api_key: apiKey } }); - const workspace = new Workspace({ - host: serverUrl, - workingDir: '/workspace', - apiKey: sessionKey, - }); - - const conv = new Conversation(agent, workspace, { callback: onEvent }); - convRef.current = conv; - - await conv.start({ initialMessage }); - setConversationId(conv.id); - - // Stream events live - await conv.startWebSocketClient(); - - // Kick the agent - await conv.run(); - - setStatus(await conv.state.getAgentStatus()); - }, [serverUrl, model, apiKey, sessionKey, onEvent]); - - const sendMessage = useCallback(async (text: string) => { - const conv = convRef.current; - if (!conv) return; - await conv.sendMessage(text); - await conv.run(); - }, []); - - const pause = useCallback(async () => { - await convRef.current?.pause(); - }, []); - - // Poll status while running - useEffect(() => { - if (status !== ConversationExecutionStatus.RUNNING) return; - const t = setInterval(async () => { - const s = await convRef.current?.state.getAgentStatus(); - if (s) setStatus(s); - }, 1500); - return () => clearInterval(t); - }, [status]); - - // Cleanup - useEffect(() => { - return () => { convRef.current?.stopWebSocketClient(); }; - }, []); - - return { events, status, conversationId, start, sendMessage, pause }; -} -``` - ---- - -## 3. Event Renderer - -Handle every `kind` the server can emit. The `kind` field is the discriminator. - -```tsx -import type { Event } from '@openhands/typescript-client'; - -interface EventBubbleProps { event: Event } - -export function EventBubble({ event }: EventBubbleProps) { - switch (event.kind) { - - // ── Visible chat messages ───────────────────────────────────────────── - case 'MessageEvent': { - const msg = (event as any).llm_message; - const role: string = msg?.role ?? (event as any).source ?? 'unknown'; - const text = extractText(msg?.content); - const isUser = role === 'user'; - return ( -
- {isUser ? 'You' : 'Agent'} -

{text}

-
- ); - } - - // ── Tool call the agent is making ───────────────────────────────────── - case 'ActionEvent': { - const e = event as any; - return ( -
- - Tool call: {e.tool_name} - -
{JSON.stringify(e.action, null, 2)}
- {e.thought &&
{extractThought(e.thought)}
} -
- ); - } - - // ── Result of a tool call ───────────────────────────────────────────── - case 'ObservationEvent': { - const e = event as any; - const content = - typeof e.observation === 'string' - ? e.observation - : JSON.stringify(e.observation, null, 2); - return ( -
- Result: {e.tool_name} -
{content}
-
- ); - } - - // ── Scaffold error (sent to LLM) ────────────────────────────────────── - case 'AgentErrorEvent': { - const e = event as any; - return ( -
- Agent error ({e.tool_name}): {e.error} -
- ); - } - - // ── Conversation-level failure (NOT sent to LLM) ────────────────────── - case 'ConversationErrorEvent': { - const e = event as any; - return ( -
- Error [{e.code}]: {e.detail} -
- ); - } - - // ── User/hook rejection ─────────────────────────────────────────────── - case 'UserRejectObservation': { - const e = event as any; - return ( -
- Action {e.tool_name} rejected by {e.rejection_source}: - {' '}{e.rejection_reason} -
- ); - } - - // ── Execution paused ────────────────────────────────────────────────── - case 'PauseEvent': - return
Agent paused
; - - // ── Condensation (history compacted) ───────────────────────────────── - case 'Condensation': { - const e = event as any; - return ( -
- Context condensed ({e.forgotten_event_ids?.length ?? 0} events removed) - {e.summary &&

{e.summary}

} -
- ); - } - - case 'CondensationSummaryEvent': { - const e = event as any; - return ( -
- Summary: {e.summary} -
- ); - } - - // ── State change (use for status badge) ─────────────────────────────── - case 'ConversationStateUpdateEvent': { - const e = event as any; - if (e.key === 'execution_status') { - return
Status → {String(e.value)}
; - } - return null; // skip noisy state updates - } - - // ── System prompt (usually hide in UI) ──────────────────────────────── - case 'SystemPromptEvent': - return null; - - // ── Token IDs (VLLM internal, usually hide) ─────────────────────────── - case 'TokenEvent': - return null; - - // ── Unknown / future events ─────────────────────────────────────────── - default: - return ( -
- Event: {event.kind} -
{JSON.stringify(event, null, 2)}
-
- ); - } -} - -// Helpers -function extractText(content: unknown): string { - if (!content) return ''; - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return content - .filter((c: any) => c.type === 'text') - .map((c: any) => c.text ?? '') - .join('\n'); - } - return JSON.stringify(content); -} - -function extractThought(thought: unknown): string { - if (!thought) return ''; - if (typeof thought === 'string') return thought; - if (Array.isArray(thought)) { - return (thought as any[]) - .filter(t => t.type === 'text') - .map(t => t.text ?? '') - .join('\n'); - } - return ''; -} -``` - ---- - -## 4. Chat Shell Component - -```tsx -import { useState, useRef, useEffect } from 'react'; -import { useAgentConversation } from './useAgentConversation'; -import { EventBubble } from './EventBubble'; - -export function ChatApp() { - const [input, setInput] = useState(''); - const [started, setStarted] = useState(false); - const scrollRef = useRef(null); - - const { events, status, start, sendMessage, pause } = useAgentConversation({ - serverUrl: 'http://localhost:8000', - model: 'claude-sonnet-4-6', - apiKey: process.env.REACT_APP_API_KEY ?? '', - }); - - // Auto-scroll to bottom - useEffect(() => { - scrollRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [events]); - - const handleSend = async () => { - const text = input.trim(); - if (!text) return; - setInput(''); - if (!started) { - setStarted(true); - await start(text); - } else { - await sendMessage(text); - } - }; - - return ( -
-
-

OpenHands Chat

- - {status ?? 'idle'} - - {status === 'running' && ( - - )} -
- -
- {events.map((evt, i) => ( - - ))} -
-
- -
-