feat: add interactive control session and tool UI components
- Add global control session API (/api/control/*) for interactive agent chat - Add Tool UI components (OptionList) for structured frontend interactions - Add frontend tool hub for awaiting user responses to tool calls - Update executor to support cancellation and control events - Add deployment documentation to cursor rules - Configure dashboard to run on port 3001 (backend on 3000) - Add files page and improve console with tabbed interface - Add LLM error handling module - Update retry strategy with better failure analysis
This commit is contained in:
@@ -391,6 +391,36 @@ MEMORY_RERANK_MODEL - Optional. Default: qwen/qwen3-reranker-8b
|
||||
- **Budget fallback**: `anthropic/claude-3.5-haiku` - Fast, cheap, good for simple tasks
|
||||
- **Complex tasks**: `anthropic/claude-opus-4.5` - Highest capability when needed
|
||||
|
||||
## Deployment
|
||||
|
||||
### Production Server
|
||||
- **Host**: `95.216.112.253`
|
||||
- **SSH Access**: `ssh root@95.216.112.253` (key-based auth)
|
||||
- **Backend URL**: `https://agent-backend.thomas.md` (proxied to localhost:3000)
|
||||
- **Dashboard URL**: `https://agent.thomas.md` (Vercel deployment)
|
||||
- **Environment files**: `/etc/open_agent/open_agent.env`
|
||||
- **Service**: `systemctl status open_agent` (runs as systemd service)
|
||||
- **Binary**: `/usr/local/bin/open_agent`
|
||||
|
||||
### Local Development
|
||||
- **Backend API**: `http://127.0.0.1:3000` (Rust server via `cargo run`)
|
||||
- **Dashboard**: `http://127.0.0.1:3001` (Next.js via `bun run dev`)
|
||||
- **Environment files**:
|
||||
- Backend: `.env` in project root
|
||||
- Dashboard: `dashboard/.env.local`
|
||||
|
||||
### Accessing Environment Variables
|
||||
The cursor agent has SSH access to the production server and can:
|
||||
- Read/modify env variables at `/etc/open_agent/open_agent.env`
|
||||
- Restart services: `systemctl restart open_agent`
|
||||
- Check logs: `journalctl -u open_agent -f`
|
||||
|
||||
### Port Configuration
|
||||
| Service | Local Port | Production URL |
|
||||
|---------|-----------|----------------|
|
||||
| Backend API | 3000 | https://agent-backend.thomas.md |
|
||||
| Dashboard | 3001 | https://agent.thomas.md |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
This agent has **full machine access**. It can:
|
||||
|
||||
14
.cursorrules
14
.cursorrules
@@ -142,6 +142,20 @@ PORT - Optional. Default: 3000
|
||||
MAX_ITERATIONS - Optional. Default: 50
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Production Server
|
||||
- **Host**: `95.216.112.253`
|
||||
- **SSH Access**: `ssh root@95.216.112.253` (key-based auth available to agent)
|
||||
- **Environment files**: Located in `/root/open_agent/.env` on the server
|
||||
|
||||
### Local Development
|
||||
- **Backend API**: `http://127.0.0.1:3000` (Rust server via `cargo run`)
|
||||
- **Dashboard**: `http://127.0.0.1:3001` (Next.js via `bun run dev`)
|
||||
- **Environment files**:
|
||||
- Backend: `.env` in project root
|
||||
- Dashboard: `dashboard/.env.local`
|
||||
|
||||
## Security Considerations
|
||||
|
||||
This agent has **full machine access**. It can:
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"zod": "^4.2.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"packageManager": "bun@1.3.4",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --port 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
@@ -18,10 +18,11 @@
|
||||
"next": "16.0.10",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"recharts": "^3.6.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"recharts": "^3.6.0",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
"zod": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
createTask,
|
||||
getTask,
|
||||
stopTask,
|
||||
streamTask,
|
||||
TaskLogEntry,
|
||||
cancelControl,
|
||||
postControlMessage,
|
||||
postControlToolResult,
|
||||
streamControl,
|
||||
type ControlRunState,
|
||||
} from '@/lib/api';
|
||||
import {
|
||||
Send,
|
||||
@@ -16,33 +15,75 @@ import {
|
||||
Bot,
|
||||
User,
|
||||
Loader,
|
||||
Terminal,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Code,
|
||||
FileText,
|
||||
Ban,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
OptionList,
|
||||
OptionListErrorBoundary,
|
||||
parseSerializableOptionList,
|
||||
type OptionListSelection,
|
||||
} from '@/components/tool-ui/option-list';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
logs?: TaskLogEntry[];
|
||||
type ChatItem =
|
||||
| {
|
||||
kind: 'user';
|
||||
id: string;
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
kind: 'assistant';
|
||||
id: string;
|
||||
content: string;
|
||||
success: boolean;
|
||||
costCents: number;
|
||||
model: string | null;
|
||||
}
|
||||
| {
|
||||
kind: 'tool';
|
||||
id: string;
|
||||
toolCallId: string;
|
||||
name: string;
|
||||
args: unknown;
|
||||
result?: unknown;
|
||||
}
|
||||
| {
|
||||
kind: 'system';
|
||||
id: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function statusLabel(state: ControlRunState): {
|
||||
label: string;
|
||||
Icon: typeof Loader;
|
||||
className: string;
|
||||
} {
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
return { label: 'Idle', Icon: Clock, className: 'text-[var(--foreground-muted)]' };
|
||||
case 'running':
|
||||
return { label: 'Running', Icon: Loader, className: 'text-[var(--accent)]' };
|
||||
case 'waiting_for_tool':
|
||||
return { label: 'Waiting', Icon: Loader, className: 'text-[var(--warning)]' };
|
||||
}
|
||||
}
|
||||
|
||||
export default function ControlClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [items, setItems] = useState<ChatItem[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [runState, setRunState] = useState<ControlRunState>('idle');
|
||||
const [queueLen, setQueueLen] = useState(0);
|
||||
|
||||
const isBusy = runState !== 'idle';
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const loadedFromUrlTaskIdRef = useRef<string | null>(null);
|
||||
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set());
|
||||
const streamCleanupRef = useRef<null | (() => void)>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
@@ -51,424 +92,365 @@ export default function ControlClient() {
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
streamCleanupRef.current?.();
|
||||
|
||||
const cleanup = streamControl((event) => {
|
||||
const data: unknown = event.data;
|
||||
|
||||
if (event.type === 'status' && isRecord(data)) {
|
||||
const st = data['state'];
|
||||
setRunState(typeof st === 'string' ? (st as ControlRunState) : 'idle');
|
||||
const q = data['queue_len'];
|
||||
setQueueLen(typeof q === 'number' ? q : 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'user_message' && isRecord(data)) {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
kind: 'user',
|
||||
id: String(data['id'] ?? Date.now()),
|
||||
content: String(data['content'] ?? ''),
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'assistant_message' && isRecord(data)) {
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
kind: 'assistant',
|
||||
id: String(data['id'] ?? Date.now()),
|
||||
content: String(data['content'] ?? ''),
|
||||
success: Boolean(data['success']),
|
||||
costCents: Number(data['cost_cents'] ?? 0),
|
||||
model: data['model'] ? String(data['model']) : null,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'tool_call' && isRecord(data)) {
|
||||
const name = String(data['name'] ?? '');
|
||||
if (!name.startsWith('ui_')) return;
|
||||
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
kind: 'tool',
|
||||
id: `tool-${String(data['tool_call_id'] ?? Date.now())}`,
|
||||
toolCallId: String(data['tool_call_id'] ?? ''),
|
||||
name,
|
||||
args: data['args'],
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'tool_result' && isRecord(data)) {
|
||||
const name = String(data['name'] ?? '');
|
||||
if (!name.startsWith('ui_')) return;
|
||||
|
||||
const toolCallId = String(data['tool_call_id'] ?? '');
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.kind === 'tool' && it.toolCallId === toolCallId
|
||||
? { ...it, result: data['result'] }
|
||||
: it,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'error') {
|
||||
const msg =
|
||||
(isRecord(data) && data['message'] ? String(data['message']) : null) ??
|
||||
'An error occurred.';
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{ kind: 'system', id: `err-${Date.now()}`, content: msg },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
streamCleanupRef.current = cleanup;
|
||||
|
||||
return () => {
|
||||
// Ensure we don't leak SSE connections when navigating away.
|
||||
streamCleanupRef.current?.();
|
||||
streamCleanupRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadTask = useCallback(async (taskId: string) => {
|
||||
try {
|
||||
const task = await getTask(taskId);
|
||||
const userMessage: Message = {
|
||||
id: `user-${taskId}`,
|
||||
role: 'user',
|
||||
content: task.task,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
const assistantMessage: Message = {
|
||||
id: `assistant-${taskId}`,
|
||||
role: 'assistant',
|
||||
content: task.result || 'Processing...',
|
||||
timestamp: new Date(),
|
||||
status: task.status,
|
||||
logs: task.log,
|
||||
};
|
||||
setMessages([userMessage, assistantMessage]);
|
||||
const shouldStream = task.status === 'running' || task.status === 'pending';
|
||||
setCurrentTaskId(shouldStream ? taskId : null);
|
||||
|
||||
// If the task is still running, attach streaming so the UI keeps updating (and Stop button is visible).
|
||||
if (shouldStream) {
|
||||
setIsLoading(true);
|
||||
streamCleanupRef.current?.();
|
||||
|
||||
const cleanup = streamTask(taskId, (event) => {
|
||||
if (event.type === 'log') {
|
||||
const logEntry = event.data as TaskLogEntry;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `assistant-${taskId}` ? { ...m, logs: [...(m.logs || []), logEntry] } : m
|
||||
)
|
||||
);
|
||||
} else if (event.type === 'done') {
|
||||
const doneData = event.data as { status: string; result: string | null };
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `assistant-${taskId}`
|
||||
? {
|
||||
...m,
|
||||
content: doneData.result || 'Task completed',
|
||||
status: doneData.status as Message['status'],
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
setCurrentTaskId(null);
|
||||
setIsLoading(false);
|
||||
streamCleanupRef.current = null;
|
||||
} else if (event.type === 'error') {
|
||||
const err = event.data as { message?: string; status?: number };
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `assistant-${taskId}`
|
||||
? { ...m, content: err?.message || 'Streaming failed', status: 'failed' }
|
||||
: m
|
||||
)
|
||||
);
|
||||
setCurrentTaskId(null);
|
||||
setIsLoading(false);
|
||||
streamCleanupRef.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
streamCleanupRef.current = cleanup;
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
streamCleanupRef.current?.();
|
||||
streamCleanupRef.current = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load task:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load task from URL if provided
|
||||
useEffect(() => {
|
||||
const taskId = searchParams.get('task');
|
||||
if (!taskId) return;
|
||||
if (loadedFromUrlTaskIdRef.current === taskId) return;
|
||||
loadedFromUrlTaskIdRef.current = taskId;
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void loadTask(taskId);
|
||||
}, [searchParams, loadTask]);
|
||||
const status = useMemo(() => statusLabel(runState), [runState]);
|
||||
const StatusIcon = status.Icon;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
const content = input.trim();
|
||||
if (!content) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
id: `user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: input,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInput('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await createTask({ task: input });
|
||||
setCurrentTaskId(response.id);
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: `assistant-${response.id}`,
|
||||
role: 'assistant',
|
||||
content: 'Processing your request...',
|
||||
timestamp: new Date(),
|
||||
status: 'running',
|
||||
logs: [],
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
|
||||
// Start streaming
|
||||
// Abort any previous stream.
|
||||
streamCleanupRef.current?.();
|
||||
|
||||
const cleanup = streamTask(response.id, (event) => {
|
||||
if (event.type === 'log') {
|
||||
const logEntry = event.data as TaskLogEntry;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `assistant-${response.id}`
|
||||
? { ...m, logs: [...(m.logs || []), logEntry] }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} else if (event.type === 'done') {
|
||||
const doneData = event.data as { status: string; result: string | null };
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `assistant-${response.id}`
|
||||
? {
|
||||
...m,
|
||||
content: doneData.result || 'Task completed',
|
||||
status: doneData.status as Message['status'],
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
setCurrentTaskId(null);
|
||||
setIsLoading(false);
|
||||
streamCleanupRef.current = null;
|
||||
} else if (event.type === 'error') {
|
||||
const err = event.data as { message?: string; status?: number };
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `assistant-${response.id}`
|
||||
? {
|
||||
...m,
|
||||
content: err?.message || 'Streaming failed',
|
||||
status: 'failed',
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
setCurrentTaskId(null);
|
||||
setIsLoading(false);
|
||||
streamCleanupRef.current = null;
|
||||
}
|
||||
});
|
||||
streamCleanupRef.current = cleanup;
|
||||
} catch (error) {
|
||||
console.error('Failed to create task:', error);
|
||||
setMessages((prev) => [
|
||||
await postControlMessage(content);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `error-${Date.now()}`,
|
||||
role: 'system',
|
||||
content: 'Failed to create task. Please try again.',
|
||||
timestamp: new Date(),
|
||||
kind: 'system',
|
||||
id: `err-${Date.now()}`,
|
||||
content: 'Failed to send message to control session.',
|
||||
},
|
||||
]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!currentTaskId) return;
|
||||
const taskId = currentTaskId;
|
||||
try {
|
||||
// Do NOT disconnect the stream until we know the stop request succeeded.
|
||||
await stopTask(taskId);
|
||||
|
||||
// Now stop streaming locally.
|
||||
streamCleanupRef.current?.();
|
||||
streamCleanupRef.current = null;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === `assistant-${taskId}`
|
||||
? { ...m, status: 'cancelled' as const, content: 'Task was cancelled' }
|
||||
: m
|
||||
)
|
||||
);
|
||||
setCurrentTaskId(null);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to stop task:', error);
|
||||
setMessages((prev) => [
|
||||
await cancelControl();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setItems((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `stop-error-${Date.now()}`,
|
||||
role: 'system',
|
||||
content: 'Failed to stop task. The stream is still connected; try again.',
|
||||
timestamp: new Date(),
|
||||
kind: 'system',
|
||||
id: `err-${Date.now()}`,
|
||||
content: 'Failed to cancel control session.',
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLogs = (messageId: string) => {
|
||||
setExpandedLogs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(messageId)) {
|
||||
next.delete(messageId);
|
||||
} else {
|
||||
next.add(messageId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const getLogIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'tool_call':
|
||||
return Terminal;
|
||||
case 'response':
|
||||
return FileText;
|
||||
case 'error':
|
||||
return XCircle;
|
||||
default:
|
||||
return Code;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--border)] bg-[var(--background-secondary)]/70 backdrop-blur px-6 py-4">
|
||||
<h1 className="text-xl font-semibold text-[var(--foreground)]">Agent Control</h1>
|
||||
<p className="text-sm text-[var(--foreground-muted)]">
|
||||
Give tasks to the autonomous agent
|
||||
</p>
|
||||
<div className="flex min-h-screen flex-col p-8">
|
||||
<div className="mb-6 flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-[var(--foreground)]">
|
||||
Agent Control
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[var(--foreground-muted)]">
|
||||
Talk to the global RootAgent session (messages queue while busy)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn('flex items-center gap-2 text-sm', status.className)}>
|
||||
<StatusIcon className={cn('h-4 w-4', runState !== 'idle' && 'animate-spin')} />
|
||||
<span>{status.label}</span>
|
||||
<span className="text-[var(--foreground-muted)]">•</span>
|
||||
<span className="text-[var(--foreground-muted)]">Queue: {queueLen}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Bot className="mx-auto h-12 w-12 text-[var(--foreground-muted)]" />
|
||||
<h2 className="mt-4 text-lg font-medium text-[var(--foreground)]">
|
||||
Start a conversation
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-muted)]">
|
||||
Describe a task for the agent to complete
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
'flex gap-4',
|
||||
message.role === 'user' ? 'justify-end' : 'justify-start'
|
||||
)}
|
||||
>
|
||||
{message.role !== 'user' && (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[var(--accent)] to-[var(--accent-secondary)]">
|
||||
<Bot className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%] rounded-lg px-4 py-3',
|
||||
message.role === 'user'
|
||||
? 'bg-[var(--accent)] text-white'
|
||||
: 'bg-[var(--background-secondary)] text-[var(--foreground)]'
|
||||
)}
|
||||
>
|
||||
{/* Status badge */}
|
||||
{message.status && message.role === 'assistant' && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{message.status === 'pending' && (
|
||||
<span className="flex items-center gap-1 text-xs text-[var(--warning)]">
|
||||
<Clock className="h-3 w-3" />
|
||||
Pending
|
||||
</span>
|
||||
)}
|
||||
{message.status === 'running' && (
|
||||
<span className="flex items-center gap-1 text-xs text-[var(--accent)]">
|
||||
<Loader className="h-3 w-3 animate-spin" />
|
||||
Running
|
||||
</span>
|
||||
)}
|
||||
{message.status === 'completed' && (
|
||||
<span className="flex items-center gap-1 text-xs text-[var(--success)]">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Completed
|
||||
</span>
|
||||
)}
|
||||
{message.status === 'cancelled' && (
|
||||
<span className="flex items-center gap-1 text-xs text-[var(--foreground-muted)]">
|
||||
<Ban className="h-3 w-3" />
|
||||
Cancelled
|
||||
</span>
|
||||
)}
|
||||
{message.status === 'failed' && (
|
||||
<span className="flex items-center gap-1 text-xs text-[var(--error)]">
|
||||
<XCircle className="h-3 w-3" />
|
||||
Failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
|
||||
|
||||
{/* Logs */}
|
||||
{message.logs && message.logs.length > 0 && (
|
||||
<div className="mt-3 border-t border-[var(--border)] pt-3">
|
||||
<button
|
||||
onClick={() => toggleLogs(message.id)}
|
||||
className="text-xs text-[var(--foreground-muted)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
{expandedLogs.has(message.id)
|
||||
? `Hide ${message.logs.length} logs`
|
||||
: `Show ${message.logs.length} logs`}
|
||||
</button>
|
||||
|
||||
{expandedLogs.has(message.id) && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{message.logs.map((log, i) => {
|
||||
const Icon = getLogIcon(log.entry_type);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-2 rounded-lg bg-[var(--background-tertiary)] p-2 text-xs"
|
||||
>
|
||||
<Icon className="mt-0.5 h-3 w-3 text-[var(--foreground-muted)]" />
|
||||
<span className="font-mono text-[var(--foreground-muted)]">
|
||||
{log.content.length > 200
|
||||
? `${log.content.slice(0, 200)}...`
|
||||
: log.content}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message.role === 'user' && (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--background-tertiary)]">
|
||||
<User className="h-4 w-4 text-[var(--foreground-muted)]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="panel flex-1 min-h-0 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--background-secondary)]/70 backdrop-blur-xl">
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Bot className="mx-auto h-12 w-12 text-[var(--foreground-muted)]" />
|
||||
<h2 className="mt-4 text-lg font-medium text-[var(--foreground)]">
|
||||
Start a conversation
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-[var(--foreground-muted)]">
|
||||
Ask the agent to do something (it will queue if already busy)
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-[var(--border)] bg-[var(--background-secondary)]/70 backdrop-blur p-4">
|
||||
<form onSubmit={handleSubmit} className="mx-auto flex max-w-3xl gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Describe a task for the agent..."
|
||||
className="flex-1 rounded-lg border border-[var(--border)] bg-[var(--background)]/60 px-4 py-3 text-sm text-[var(--foreground)] placeholder-[var(--foreground-muted)] focus:border-[var(--accent)] focus:outline-none focus-visible:!border-[var(--border)]"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="flex items-center gap-2 rounded-lg bg-[var(--error)] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--error)]/90"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="flex items-center gap-2 rounded-lg bg-[var(--accent)] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--accent)]/90 disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Send
|
||||
</button>
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
{items.map((item) => {
|
||||
if (item.kind === 'user') {
|
||||
return (
|
||||
<div key={item.id} className="flex justify-end gap-4">
|
||||
<div className="max-w-[80%] rounded-lg bg-[var(--accent)] px-4 py-3 text-white">
|
||||
<p className="whitespace-pre-wrap text-sm">{item.content}</p>
|
||||
</div>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--background-tertiary)]">
|
||||
<User className="h-4 w-4 text-[var(--foreground-muted)]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === 'assistant') {
|
||||
const statusIcon = item.success ? CheckCircle : XCircle;
|
||||
const StatusIcon = statusIcon;
|
||||
return (
|
||||
<div key={item.id} className="flex justify-start gap-4">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[var(--accent)] to-[var(--accent-secondary)]">
|
||||
<Bot className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div className="max-w-[80%] rounded-lg bg-[var(--background-secondary)] px-4 py-3 text-[var(--foreground)]">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs text-[var(--foreground-muted)]">
|
||||
<StatusIcon
|
||||
className={cn(
|
||||
'h-3 w-3',
|
||||
item.success ? 'text-[var(--success)]' : 'text-[var(--error)]',
|
||||
)}
|
||||
/>
|
||||
<span>{item.success ? 'Completed' : 'Failed'}</span>
|
||||
{item.model && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="font-mono">{item.model}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm">{item.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === 'tool') {
|
||||
if (item.name === 'ui_optionList') {
|
||||
const toolCallId = item.toolCallId;
|
||||
const rawArgs: Record<string, unknown> = isRecord(item.args) ? item.args : {};
|
||||
|
||||
let optionList: ReturnType<typeof parseSerializableOptionList> | null = null;
|
||||
let parseErr: string | null = null;
|
||||
try {
|
||||
optionList = parseSerializableOptionList({
|
||||
...rawArgs,
|
||||
id:
|
||||
typeof rawArgs['id'] === 'string' && rawArgs['id']
|
||||
? (rawArgs['id'] as string)
|
||||
: `option-list-${toolCallId}`,
|
||||
});
|
||||
} catch (e) {
|
||||
parseErr = e instanceof Error ? e.message : 'Invalid option list payload';
|
||||
}
|
||||
|
||||
const confirmed = item.result as OptionListSelection | undefined;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="flex justify-start gap-4">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[var(--accent)] to-[var(--accent-secondary)]">
|
||||
<Bot className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div className="max-w-[80%] rounded-lg bg-[var(--background-secondary)] px-4 py-3 text-[var(--foreground)]">
|
||||
<div className="mb-2 text-xs text-[var(--foreground-muted)]">
|
||||
Tool UI: <span className="font-mono">{item.name}</span>
|
||||
</div>
|
||||
|
||||
{parseErr || !optionList ? (
|
||||
<div className="rounded-lg border border-[var(--border)] bg-[var(--background-tertiary)] p-3 text-sm text-[var(--error)]">
|
||||
{parseErr ?? 'Failed to render OptionList'}
|
||||
</div>
|
||||
) : (
|
||||
<OptionListErrorBoundary>
|
||||
<OptionList
|
||||
{...optionList}
|
||||
value={undefined}
|
||||
confirmed={confirmed}
|
||||
onConfirm={async (selection) => {
|
||||
// Optimistic receipt state.
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.kind === 'tool' && it.toolCallId === toolCallId
|
||||
? { ...it, result: selection }
|
||||
: it,
|
||||
),
|
||||
);
|
||||
await postControlToolResult({
|
||||
tool_call_id: toolCallId,
|
||||
name: item.name,
|
||||
result: selection,
|
||||
});
|
||||
}}
|
||||
onCancel={async () => {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.kind === 'tool' && it.toolCallId === toolCallId
|
||||
? { ...it, result: null }
|
||||
: it,
|
||||
),
|
||||
);
|
||||
await postControlToolResult({
|
||||
tool_call_id: toolCallId,
|
||||
name: item.name,
|
||||
result: null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</OptionListErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Unknown UI tool.
|
||||
return (
|
||||
<div key={item.id} className="flex justify-start gap-4">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[var(--accent)] to-[var(--accent-secondary)]">
|
||||
<Bot className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div className="max-w-[80%] rounded-lg bg-[var(--background-secondary)] px-4 py-3 text-[var(--foreground)]">
|
||||
<p className="text-sm">
|
||||
Unsupported Tool UI: <span className="font-mono">{item.name}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// system
|
||||
return (
|
||||
<div key={item.id} className="flex justify-start gap-4">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--background-tertiary)]">
|
||||
<Ban className="h-4 w-4 text-[var(--foreground-muted)]" />
|
||||
</div>
|
||||
<div className="max-w-[80%] rounded-lg bg-[var(--background-tertiary)] px-4 py-3 text-[var(--foreground)]">
|
||||
<p className="whitespace-pre-wrap text-sm">{item.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--border)] bg-[var(--background-secondary)]/40 backdrop-blur p-4">
|
||||
<form onSubmit={handleSubmit} className="mx-auto flex max-w-3xl gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Message the root agent…"
|
||||
className="flex-1 rounded-lg border border-[var(--border)] bg-[var(--background)]/60 px-4 py-3 text-sm text-[var(--foreground)] placeholder-[var(--foreground-muted)] focus:border-[var(--accent)] focus:outline-none"
|
||||
/>
|
||||
|
||||
{isBusy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="flex items-center gap-2 rounded-lg bg-[var(--error)] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--error)]/90"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
className="flex items-center gap-2 rounded-lg bg-[var(--accent)] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--accent)]/90 disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
305
dashboard/src/app/files/page.tsx
Normal file
305
dashboard/src/app/files/page.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { authHeader, getValidJwt } from '@/lib/auth';
|
||||
import { getRuntimeApiBase } from '@/lib/settings';
|
||||
|
||||
type FsEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: 'file' | 'dir' | 'link' | 'other' | string;
|
||||
size: number;
|
||||
mtime: number;
|
||||
};
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (!Number.isFinite(n)) return '-';
|
||||
if (n < 1024) return `${n} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'] as const;
|
||||
let v = n / 1024;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
async function listDir(path: string): Promise<FsEntry[]> {
|
||||
const API_BASE = getRuntimeApiBase();
|
||||
const res = await fetch(`${API_BASE}/api/fs/list?path=${encodeURIComponent(path)}`, {
|
||||
headers: { ...authHeader() },
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function mkdir(path: string): Promise<void> {
|
||||
const API_BASE = getRuntimeApiBase();
|
||||
const res = await fetch(`${API_BASE}/api/fs/mkdir`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
async function rm(path: string, recursive = false): Promise<void> {
|
||||
const API_BASE = getRuntimeApiBase();
|
||||
const res = await fetch(`${API_BASE}/api/fs/rm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({ path, recursive }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
async function downloadFile(path: string) {
|
||||
const API_BASE = getRuntimeApiBase();
|
||||
const res = await fetch(`${API_BASE}/api/fs/download?path=${encodeURIComponent(path)}`, {
|
||||
headers: { ...authHeader() },
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const blob = await res.blob();
|
||||
const name = path.split('/').filter(Boolean).pop() ?? 'download';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function uploadFiles(dir: string, files: File[], onProgress?: (done: number, total: number) => void) {
|
||||
let done = 0;
|
||||
for (const f of files) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const API_BASE = getRuntimeApiBase();
|
||||
const form = new FormData();
|
||||
form.append('file', f, f.name);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${API_BASE}/api/fs/upload?path=${encodeURIComponent(dir)}`, true);
|
||||
const jwt = getValidJwt()?.token;
|
||||
if (jwt) xhr.setRequestHeader('Authorization', `Bearer ${jwt}`);
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new Error(xhr.responseText || `Upload failed (${xhr.status})`));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Upload failed (network error)'));
|
||||
xhr.send(form);
|
||||
});
|
||||
done += 1;
|
||||
onProgress?.(done, files.length);
|
||||
}
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const [cwd, setCwd] = useState('/root');
|
||||
const [entries, setEntries] = useState<FsEntry[]>([]);
|
||||
const [fsLoading, setFsLoading] = useState(false);
|
||||
const [fsError, setFsError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<FsEntry | null>(null);
|
||||
const [uploading, setUploading] = useState<{ done: number; total: number } | null>(null);
|
||||
|
||||
const sortedEntries = useMemo(() => {
|
||||
const dirs = entries.filter((e) => e.kind === 'dir').sort((a, b) => a.name.localeCompare(b.name));
|
||||
const files = entries.filter((e) => e.kind !== 'dir').sort((a, b) => a.name.localeCompare(b.name));
|
||||
return [...dirs, ...files];
|
||||
}, [entries]);
|
||||
|
||||
async function refreshDir(path: string) {
|
||||
setFsLoading(true);
|
||||
setFsError(null);
|
||||
try {
|
||||
const data = await listDir(path);
|
||||
setEntries(data);
|
||||
setSelected(null);
|
||||
} catch (e) {
|
||||
setFsError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setFsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refreshDir(cwd);
|
||||
}, [cwd]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-[var(--foreground)]">Files</h1>
|
||||
<p className="mt-1 text-sm text-[var(--foreground-muted)]">Remote file explorer (SFTP).</p>
|
||||
</div>
|
||||
|
||||
<div className="panel rounded-lg border border-[var(--border)] bg-[var(--background-secondary)]/70 p-3 backdrop-blur-xl">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-[var(--foreground)]">Explorer</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md border border-[var(--border)] bg-[var(--background-tertiary)] px-2 py-1 text-xs text-[var(--foreground)] hover:bg-[var(--background-tertiary)]/70"
|
||||
onClick={() => void refreshDir(cwd)}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-[var(--border)] bg-[var(--background-tertiary)] px-2 py-1 text-xs text-[var(--foreground)] hover:bg-[var(--background-tertiary)]/70"
|
||||
onClick={async () => {
|
||||
const name = prompt('New folder name');
|
||||
if (!name) return;
|
||||
const target = cwd.endsWith('/') ? `${cwd}${name}` : `${cwd}/${name}`;
|
||||
await mkdir(target);
|
||||
await refreshDir(cwd);
|
||||
}}
|
||||
>
|
||||
New folder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md border border-[var(--border)] bg-[var(--background-tertiary)] px-2 py-1 text-xs text-[var(--foreground)] hover:bg-[var(--background-tertiary)]/70"
|
||||
onClick={() => {
|
||||
const parts = cwd.split('/').filter(Boolean);
|
||||
if (parts.length === 0) return;
|
||||
parts.pop();
|
||||
setCwd('/' + parts.join('/'));
|
||||
}}
|
||||
disabled={cwd === '/'}
|
||||
>
|
||||
Up
|
||||
</button>
|
||||
<input
|
||||
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)]/40 px-3 py-2 text-sm text-[var(--foreground)] placeholder:text-[var(--foreground-muted)] focus-visible:!border-[var(--border)]"
|
||||
value={cwd}
|
||||
onChange={(e) => setCwd(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void refreshDir(cwd);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mb-3 rounded-md border border-dashed border-[var(--border)] bg-[var(--background)]/20 p-3 text-sm text-[var(--foreground-muted)]"
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (files.length === 0) return;
|
||||
setUploading({ done: 0, total: files.length });
|
||||
try {
|
||||
await uploadFiles(cwd, files, (done, total) => setUploading({ done, total }));
|
||||
await refreshDir(cwd);
|
||||
} catch (err) {
|
||||
setFsError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setUploading(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Drag & drop to upload into <span className="text-[var(--foreground)]">{cwd}</span>
|
||||
{uploading ? (
|
||||
<span className="ml-2 text-xs">
|
||||
({uploading.done}/{uploading.total})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{fsError ? (
|
||||
<div className="mb-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{fsError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-5">
|
||||
<div className="md:col-span-3">
|
||||
<div className="rounded-md border border-[var(--border)] bg-[var(--background)]/30">
|
||||
<div className="grid grid-cols-12 gap-2 border-b border-[var(--border)] px-3 py-2 text-xs text-[var(--foreground-muted)]">
|
||||
<div className="col-span-7">Name</div>
|
||||
<div className="col-span-3">Size</div>
|
||||
<div className="col-span-2">Type</div>
|
||||
</div>
|
||||
<div className="max-h-[520px] overflow-auto">
|
||||
{fsLoading ? (
|
||||
<div className="px-3 py-3 text-sm text-[var(--foreground-muted)]">Loading…</div>
|
||||
) : sortedEntries.length === 0 ? (
|
||||
<div className="px-3 py-3 text-sm text-[var(--foreground-muted)]">Empty</div>
|
||||
) : (
|
||||
sortedEntries.map((e) => (
|
||||
<button
|
||||
key={e.path}
|
||||
className={
|
||||
'grid w-full grid-cols-12 gap-2 px-3 py-2 text-left text-sm hover:bg-[var(--background-tertiary)]/60 ' +
|
||||
(selected?.path === e.path ? 'bg-[var(--accent)]/10' : '')
|
||||
}
|
||||
onClick={() => setSelected(e)}
|
||||
onDoubleClick={() => {
|
||||
if (e.kind === 'dir') setCwd(e.path);
|
||||
}}
|
||||
>
|
||||
<div className="col-span-7 truncate text-[var(--foreground)]">{e.name}</div>
|
||||
<div className="col-span-3 text-[var(--foreground-muted)]">
|
||||
{e.kind === 'file' ? formatBytes(e.size) : '-'}
|
||||
</div>
|
||||
<div className="col-span-2 text-[var(--foreground-muted)]">{e.kind}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<div className="rounded-md border border-[var(--border)] bg-[var(--background)]/30 p-3">
|
||||
<div className="text-sm font-medium text-[var(--foreground)]">Selection</div>
|
||||
{selected ? (
|
||||
<div className="mt-2 space-y-2 text-sm">
|
||||
<div className="break-words text-[var(--foreground)]">{selected.path}</div>
|
||||
<div className="text-[var(--foreground-muted)]">
|
||||
<span className="text-[var(--foreground)]">Type:</span> {selected.kind}
|
||||
</div>
|
||||
{selected.kind === 'file' ? (
|
||||
<div className="text-[var(--foreground-muted)]">
|
||||
<span className="text-[var(--foreground)]">Size:</span> {formatBytes(selected.size)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{selected.kind === 'file' ? (
|
||||
<button
|
||||
className="rounded-md border border-[var(--border)] bg-[var(--background-tertiary)] px-2 py-1 text-xs text-[var(--foreground)] hover:bg-[var(--background-tertiary)]/70"
|
||||
onClick={() => void downloadFile(selected.path)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="rounded-md border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-200 hover:bg-red-500/15"
|
||||
onClick={async () => {
|
||||
if (!confirm(`Delete ${selected.path}?`)) return;
|
||||
await rm(selected.path, selected.kind === 'dir');
|
||||
await refreshDir(cwd);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-sm text-[var(--foreground-muted)]">Click a file/folder.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,18 @@
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--background-secondary);
|
||||
--color-card-foreground: var(--foreground);
|
||||
--color-muted: var(--background-tertiary);
|
||||
--color-muted-foreground: var(--foreground-muted);
|
||||
--color-border: var(--border);
|
||||
|
||||
--color-primary: var(--accent);
|
||||
--color-primary-foreground: #ffffff;
|
||||
|
||||
--color-destructive: var(--error);
|
||||
--color-destructive-foreground: #ffffff;
|
||||
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@@ -62,8 +62,12 @@ export default function HistoryPage() {
|
||||
<div className="p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-[var(--foreground)]">History</h1>
|
||||
<p className="text-sm text-[var(--foreground-muted)]">View all past and current tasks</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-[var(--foreground)]">
|
||||
History
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[var(--foreground-muted)]">
|
||||
View all past and current tasks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
|
||||
14
dashboard/src/components/tool-ui/option-list/_adapter.tsx
Normal file
14
dashboard/src/components/tool-ui/option-list/_adapter.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Adapter: UI and utility re-exports for copy-standalone portability.
|
||||
*
|
||||
* When copying this component to another project, update these imports
|
||||
* to match your project's paths:
|
||||
*
|
||||
* cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
|
||||
* Button → shadcn/ui Button
|
||||
* Separator → shadcn/ui Separator
|
||||
*/
|
||||
|
||||
export { cn } from "../../../lib/ui/cn";
|
||||
export { Button } from "../../ui/button";
|
||||
export { Separator } from "../../ui/separator";
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
ToolUIErrorBoundary,
|
||||
type ToolUIErrorBoundaryProps,
|
||||
} from "../shared";
|
||||
|
||||
export function OptionListErrorBoundary(
|
||||
props: Omit<ToolUIErrorBoundaryProps, "componentName">,
|
||||
) {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<ToolUIErrorBoundary componentName="OptionList" {...rest}>
|
||||
{children}
|
||||
</ToolUIErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
14
dashboard/src/components/tool-ui/option-list/index.tsx
Normal file
14
dashboard/src/components/tool-ui/option-list/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export { OptionList } from "./option-list";
|
||||
export { OptionListErrorBoundary } from "./error-boundary";
|
||||
export type {
|
||||
OptionListProps,
|
||||
OptionListOption,
|
||||
OptionListSelection,
|
||||
SerializableOptionList,
|
||||
} from "./schema";
|
||||
export {
|
||||
OptionListOptionSchema,
|
||||
OptionListPropsSchema,
|
||||
SerializableOptionListSchema,
|
||||
parseSerializableOptionList,
|
||||
} from "./schema";
|
||||
628
dashboard/src/components/tool-ui/option-list/option-list.tsx
Normal file
628
dashboard/src/components/tool-ui/option-list/option-list.tsx
Normal file
@@ -0,0 +1,628 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
Fragment,
|
||||
} from "react";
|
||||
import type { KeyboardEvent } from "react";
|
||||
import type {
|
||||
OptionListProps,
|
||||
OptionListSelection,
|
||||
OptionListOption,
|
||||
} from "./schema";
|
||||
import { ActionButtons, normalizeActionsConfig } from "../shared";
|
||||
import type { Action } from "../shared";
|
||||
import { cn, Button, Separator } from "./_adapter";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
function parseSelectionToIdSet(
|
||||
value: OptionListSelection | undefined,
|
||||
mode: "multi" | "single",
|
||||
maxSelections?: number,
|
||||
): Set<string> {
|
||||
if (mode === "single") {
|
||||
const single =
|
||||
typeof value === "string"
|
||||
? value
|
||||
: Array.isArray(value)
|
||||
? value[0]
|
||||
: null;
|
||||
return single ? new Set([single]) : new Set();
|
||||
}
|
||||
|
||||
const arr =
|
||||
typeof value === "string" ? [value] : Array.isArray(value) ? value : [];
|
||||
|
||||
return new Set(maxSelections ? arr.slice(0, maxSelections) : arr);
|
||||
}
|
||||
|
||||
function convertIdSetToSelection(
|
||||
selected: Set<string>,
|
||||
mode: "multi" | "single",
|
||||
): OptionListSelection {
|
||||
if (mode === "single") {
|
||||
const [first] = selected;
|
||||
return first ?? null;
|
||||
}
|
||||
return Array.from(selected);
|
||||
}
|
||||
|
||||
function areSetsEqual(a: Set<string>, b: Set<string>) {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const val of a) {
|
||||
if (!b.has(val)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface SelectionIndicatorProps {
|
||||
mode: "multi" | "single";
|
||||
isSelected: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function SelectionIndicator({
|
||||
mode,
|
||||
isSelected,
|
||||
disabled,
|
||||
}: SelectionIndicatorProps) {
|
||||
const shape = mode === "single" ? "rounded-full" : "rounded";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-4 shrink-0 items-center justify-center border-2 transition-colors",
|
||||
shape,
|
||||
isSelected && "border-primary bg-primary text-primary-foreground",
|
||||
!isSelected && "border-muted-foreground/50",
|
||||
disabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
{mode === "multi" && isSelected && <Check className="size-3" />}
|
||||
{mode === "single" && isSelected && (
|
||||
<span className="size-2 rounded-full bg-current" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionItemProps {
|
||||
option: OptionListOption;
|
||||
isSelected: boolean;
|
||||
isDisabled: boolean;
|
||||
selectionMode: "multi" | "single";
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onToggle: () => void;
|
||||
tabIndex?: number;
|
||||
onFocus?: () => void;
|
||||
buttonRef?: (el: HTMLButtonElement | null) => void;
|
||||
}
|
||||
|
||||
function OptionItem({
|
||||
option,
|
||||
isSelected,
|
||||
isDisabled,
|
||||
selectionMode,
|
||||
isFirst,
|
||||
isLast,
|
||||
onToggle,
|
||||
tabIndex,
|
||||
onFocus,
|
||||
buttonRef,
|
||||
}: OptionItemProps) {
|
||||
const hasAdjacentOptions = !isFirst && !isLast;
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
data-id={option.id}
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
onClick={onToggle}
|
||||
onFocus={onFocus}
|
||||
tabIndex={tabIndex}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"peer group relative h-auto min-h-[50px] w-full justify-start text-left text-sm font-medium",
|
||||
"rounded-none border-0 bg-transparent px-0 py-2 text-base shadow-none transition-none hover:bg-transparent! @md/option-list:text-sm",
|
||||
isFirst && "pb-2.5",
|
||||
hasAdjacentOptions && "py-2.5",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"bg-primary/5 absolute inset-0 -mx-3 -my-0.5 rounded-xl opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
/>
|
||||
<div className="relative flex items-start gap-3">
|
||||
<span className="flex h-6 items-center">
|
||||
<SelectionIndicator
|
||||
mode={selectionMode}
|
||||
isSelected={isSelected}
|
||||
disabled={option.disabled}
|
||||
/>
|
||||
</span>
|
||||
{option.icon && (
|
||||
<span className="flex h-6 items-center">{option.icon}</span>
|
||||
)}
|
||||
<div className="flex flex-col text-left">
|
||||
<span className="leading-6 text-pretty">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="text-muted-foreground text-sm font-normal text-pretty">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionListConfirmationProps {
|
||||
id: string;
|
||||
options: OptionListOption[];
|
||||
selectedIds: Set<string>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function OptionListConfirmation({
|
||||
id,
|
||||
options,
|
||||
selectedIds,
|
||||
className,
|
||||
}: OptionListConfirmationProps) {
|
||||
const confirmedOptions = options.filter((opt) => selectedIds.has(opt.id));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"@container/option-list flex w-full max-w-md min-w-80 flex-col",
|
||||
"text-foreground",
|
||||
className,
|
||||
)}
|
||||
data-slot="option-list"
|
||||
data-tool-ui-id={id}
|
||||
data-receipt="true"
|
||||
role="status"
|
||||
aria-label="Confirmed selection"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card/60 flex w-full flex-col overflow-hidden rounded-2xl border px-5 py-2.5 shadow-xs",
|
||||
)}
|
||||
>
|
||||
{confirmedOptions.map((option, index) => (
|
||||
<Fragment key={option.id}>
|
||||
{index > 0 && <Separator orientation="horizontal" />}
|
||||
<div className="flex items-start gap-3 py-1">
|
||||
<span className="flex h-6 items-center">
|
||||
<Check className="text-primary size-4 shrink-0" />
|
||||
</span>
|
||||
{option.icon && (
|
||||
<span className="flex h-6 items-center">{option.icon}</span>
|
||||
)}
|
||||
<div className="flex flex-col text-left">
|
||||
<span className="text-base leading-6 font-medium text-pretty @md/option-list:text-sm">
|
||||
{option.label}
|
||||
</span>
|
||||
{option.description && (
|
||||
<span className="text-muted-foreground text-sm font-normal text-pretty">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OptionList({
|
||||
id,
|
||||
options,
|
||||
selectionMode = "multi",
|
||||
minSelections = 1,
|
||||
maxSelections,
|
||||
value,
|
||||
defaultValue,
|
||||
confirmed,
|
||||
onChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
responseActions,
|
||||
onResponseAction,
|
||||
onBeforeResponseAction,
|
||||
className,
|
||||
}: OptionListProps) {
|
||||
if (process.env["NODE_ENV"] !== "production") {
|
||||
if (value !== undefined && defaultValue !== undefined) {
|
||||
console.warn(
|
||||
"[OptionList] Both `value` (controlled) and `defaultValue` (uncontrolled) were provided. `defaultValue` is ignored when `value` is set.",
|
||||
);
|
||||
}
|
||||
if (value !== undefined && !onChange) {
|
||||
console.warn(
|
||||
"[OptionList] `value` was provided without `onChange`. This makes OptionList controlled; selection will not update unless the parent updates `value`.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveMaxSelections = selectionMode === "single" ? 1 : maxSelections;
|
||||
|
||||
const [uncontrolledSelected, setUncontrolledSelected] = useState<Set<string>>(
|
||||
() =>
|
||||
parseSelectionToIdSet(
|
||||
defaultValue,
|
||||
selectionMode,
|
||||
effectiveMaxSelections,
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setUncontrolledSelected((prev) => {
|
||||
const normalized = parseSelectionToIdSet(
|
||||
Array.from(prev),
|
||||
selectionMode,
|
||||
effectiveMaxSelections,
|
||||
);
|
||||
return areSetsEqual(prev, normalized) ? prev : normalized;
|
||||
});
|
||||
}, [selectionMode, effectiveMaxSelections]);
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() =>
|
||||
value !== undefined
|
||||
? parseSelectionToIdSet(value, selectionMode, effectiveMaxSelections)
|
||||
: uncontrolledSelected,
|
||||
[value, uncontrolledSelected, selectionMode, effectiveMaxSelections],
|
||||
);
|
||||
|
||||
const selectedCount = selectedIds.size;
|
||||
|
||||
const optionStates = useMemo(() => {
|
||||
return options.map((option) => {
|
||||
const isSelected = selectedIds.has(option.id);
|
||||
const isSelectionLocked =
|
||||
selectionMode === "multi" &&
|
||||
effectiveMaxSelections !== undefined &&
|
||||
selectedCount >= effectiveMaxSelections &&
|
||||
!isSelected;
|
||||
const isDisabled = option.disabled || isSelectionLocked;
|
||||
|
||||
return { option, isSelected, isDisabled };
|
||||
});
|
||||
}, [
|
||||
options,
|
||||
selectedIds,
|
||||
selectionMode,
|
||||
effectiveMaxSelections,
|
||||
selectedCount,
|
||||
]);
|
||||
|
||||
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(() => {
|
||||
const firstSelected = optionStates.findIndex(
|
||||
(s) => s.isSelected && !s.isDisabled,
|
||||
);
|
||||
if (firstSelected >= 0) return firstSelected;
|
||||
const firstEnabled = optionStates.findIndex((s) => !s.isDisabled);
|
||||
return firstEnabled >= 0 ? firstEnabled : 0;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (optionStates.length === 0) return;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setActiveIndex((prev) => {
|
||||
if (
|
||||
prev < 0 ||
|
||||
prev >= optionStates.length ||
|
||||
optionStates[prev].isDisabled
|
||||
) {
|
||||
const firstEnabled = optionStates.findIndex((s) => !s.isDisabled);
|
||||
return firstEnabled >= 0 ? firstEnabled : 0;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [optionStates]);
|
||||
|
||||
const updateSelection = useCallback(
|
||||
(next: Set<string>) => {
|
||||
const normalizedNext = parseSelectionToIdSet(
|
||||
Array.from(next),
|
||||
selectionMode,
|
||||
effectiveMaxSelections,
|
||||
);
|
||||
|
||||
if (value === undefined) {
|
||||
if (!areSetsEqual(uncontrolledSelected, normalizedNext)) {
|
||||
setUncontrolledSelected(normalizedNext);
|
||||
}
|
||||
}
|
||||
|
||||
onChange?.(convertIdSetToSelection(normalizedNext, selectionMode));
|
||||
},
|
||||
[
|
||||
effectiveMaxSelections,
|
||||
selectionMode,
|
||||
uncontrolledSelected,
|
||||
value,
|
||||
onChange,
|
||||
],
|
||||
);
|
||||
|
||||
const toggleSelection = useCallback(
|
||||
(optionId: string) => {
|
||||
const next = new Set(selectedIds);
|
||||
const isSelected = next.has(optionId);
|
||||
|
||||
if (selectionMode === "single") {
|
||||
if (isSelected) {
|
||||
next.delete(optionId);
|
||||
} else {
|
||||
next.clear();
|
||||
next.add(optionId);
|
||||
}
|
||||
} else {
|
||||
if (isSelected) {
|
||||
next.delete(optionId);
|
||||
} else {
|
||||
if (effectiveMaxSelections && next.size >= effectiveMaxSelections) {
|
||||
return;
|
||||
}
|
||||
next.add(optionId);
|
||||
}
|
||||
}
|
||||
|
||||
updateSelection(next);
|
||||
},
|
||||
[effectiveMaxSelections, selectedIds, selectionMode, updateSelection],
|
||||
);
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (!onConfirm) return;
|
||||
if (selectedCount === 0 || selectedCount < minSelections) return;
|
||||
await onConfirm(convertIdSetToSelection(selectedIds, selectionMode));
|
||||
}, [minSelections, onConfirm, selectedCount, selectedIds, selectionMode]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
const empty = new Set<string>();
|
||||
updateSelection(empty);
|
||||
onCancel?.();
|
||||
}, [onCancel, updateSelection]);
|
||||
|
||||
const hasCustomResponseActions = responseActions !== undefined;
|
||||
|
||||
const handleFooterAction = useCallback(
|
||||
async (actionId: string) => {
|
||||
if (hasCustomResponseActions) {
|
||||
await onResponseAction?.(actionId);
|
||||
return;
|
||||
}
|
||||
if (actionId === "confirm") {
|
||||
await handleConfirm();
|
||||
} else if (actionId === "cancel") {
|
||||
handleCancel();
|
||||
}
|
||||
},
|
||||
[handleConfirm, handleCancel, hasCustomResponseActions, onResponseAction],
|
||||
);
|
||||
|
||||
const normalizedFooterActions = useMemo(() => {
|
||||
const normalized = normalizeActionsConfig(responseActions);
|
||||
if (normalized) return normalized;
|
||||
return {
|
||||
items: [
|
||||
{ id: "cancel", label: "Clear", variant: "ghost" as const },
|
||||
{ id: "confirm", label: "Confirm", variant: "default" as const },
|
||||
],
|
||||
align: "right" as const,
|
||||
} satisfies ReturnType<typeof normalizeActionsConfig>;
|
||||
}, [responseActions]);
|
||||
|
||||
const isConfirmDisabled =
|
||||
selectedCount < minSelections || selectedCount === 0;
|
||||
const hasNothingToClear = selectedCount === 0;
|
||||
|
||||
const focusOptionAt = useCallback((index: number) => {
|
||||
const el = optionRefs.current[index];
|
||||
if (el) el.focus();
|
||||
setActiveIndex(index);
|
||||
}, []);
|
||||
|
||||
const findFirstEnabledIndex = useCallback(() => {
|
||||
const idx = optionStates.findIndex((s) => !s.isDisabled);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [optionStates]);
|
||||
|
||||
const findLastEnabledIndex = useCallback(() => {
|
||||
for (let i = optionStates.length - 1; i >= 0; i--) {
|
||||
if (!optionStates[i].isDisabled) return i;
|
||||
}
|
||||
return 0;
|
||||
}, [optionStates]);
|
||||
|
||||
const findNextEnabledIndex = useCallback(
|
||||
(start: number, direction: 1 | -1) => {
|
||||
const len = optionStates.length;
|
||||
if (len === 0) return 0;
|
||||
for (let step = 1; step <= len; step++) {
|
||||
const idx = (start + direction * step + len) % len;
|
||||
if (!optionStates[idx].isDisabled) return idx;
|
||||
}
|
||||
return start;
|
||||
},
|
||||
[optionStates],
|
||||
);
|
||||
|
||||
const handleListboxKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (optionStates.length === 0) return;
|
||||
|
||||
const key = e.key;
|
||||
|
||||
if (key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
focusOptionAt(findNextEnabledIndex(activeIndex, 1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
focusOptionAt(findNextEnabledIndex(activeIndex, -1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Home") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
focusOptionAt(findFirstEnabledIndex());
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "End") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
focusOptionAt(findLastEnabledIndex());
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Enter" || key === " ") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const current = optionStates[activeIndex];
|
||||
if (!current || current.isDisabled) return;
|
||||
toggleSelection(current.option.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!hasNothingToClear) {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
activeIndex,
|
||||
findFirstEnabledIndex,
|
||||
findLastEnabledIndex,
|
||||
findNextEnabledIndex,
|
||||
focusOptionAt,
|
||||
handleCancel,
|
||||
hasNothingToClear,
|
||||
optionStates,
|
||||
toggleSelection,
|
||||
],
|
||||
);
|
||||
|
||||
const actionsWithDisabledState = useMemo((): Action[] => {
|
||||
return normalizedFooterActions.items.map((action) => {
|
||||
const isDisabledByValidation =
|
||||
(action.id === "confirm" && isConfirmDisabled) ||
|
||||
(action.id === "cancel" && hasNothingToClear);
|
||||
return {
|
||||
...action,
|
||||
disabled: action.disabled || isDisabledByValidation,
|
||||
label:
|
||||
action.id === "confirm" &&
|
||||
selectionMode === "multi" &&
|
||||
selectedCount > 0
|
||||
? `${action.label} (${selectedCount})`
|
||||
: action.label,
|
||||
};
|
||||
});
|
||||
}, [
|
||||
normalizedFooterActions.items,
|
||||
isConfirmDisabled,
|
||||
hasNothingToClear,
|
||||
selectionMode,
|
||||
selectedCount,
|
||||
]);
|
||||
|
||||
if (confirmed !== undefined && confirmed !== null) {
|
||||
const selectedIds = parseSelectionToIdSet(confirmed, selectionMode);
|
||||
return (
|
||||
<OptionListConfirmation
|
||||
id={id}
|
||||
options={options}
|
||||
selectedIds={selectedIds}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"@container/option-list flex w-full max-w-md min-w-80 flex-col gap-3",
|
||||
"text-foreground",
|
||||
className,
|
||||
)}
|
||||
data-slot="option-list"
|
||||
data-tool-ui-id={id}
|
||||
role="group"
|
||||
aria-label="Option list"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"group/list bg-card flex w-full flex-col overflow-hidden rounded-2xl border px-4 py-1.5 shadow-xs",
|
||||
)}
|
||||
role="listbox"
|
||||
aria-multiselectable={selectionMode === "multi"}
|
||||
onKeyDown={handleListboxKeyDown}
|
||||
>
|
||||
{optionStates.map(({ option, isSelected, isDisabled }, index) => {
|
||||
return (
|
||||
<Fragment key={option.id}>
|
||||
{index > 0 && (
|
||||
<Separator
|
||||
className="[@media(hover:hover)]:[&:has(+_:hover)]:opacity-0 [@media(hover:hover)]:[.peer:hover+&]:opacity-0"
|
||||
orientation="horizontal"
|
||||
/>
|
||||
)}
|
||||
<OptionItem
|
||||
option={option}
|
||||
isSelected={isSelected}
|
||||
isDisabled={isDisabled}
|
||||
selectionMode={selectionMode}
|
||||
isFirst={index === 0}
|
||||
isLast={index === optionStates.length - 1}
|
||||
tabIndex={index === activeIndex ? 0 : -1}
|
||||
onFocus={() => setActiveIndex(index)}
|
||||
buttonRef={(el) => {
|
||||
optionRefs.current[index] = el;
|
||||
}}
|
||||
onToggle={() => toggleSelection(option.id)}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="@container/actions">
|
||||
<ActionButtons
|
||||
actions={actionsWithDisabledState}
|
||||
align={normalizedFooterActions.align}
|
||||
confirmTimeout={normalizedFooterActions.confirmTimeout}
|
||||
onAction={handleFooterAction}
|
||||
onBeforeAction={
|
||||
hasCustomResponseActions ? onBeforeResponseAction : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
dashboard/src/components/tool-ui/option-list/schema.ts
Normal file
117
dashboard/src/components/tool-ui/option-list/schema.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { z } from "zod";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ActionsProp } from "../shared";
|
||||
import {
|
||||
ActionSchema,
|
||||
SerializableActionSchema,
|
||||
SerializableActionsConfigSchema,
|
||||
ToolUIIdSchema,
|
||||
ToolUIReceiptSchema,
|
||||
ToolUIRoleSchema,
|
||||
parseWithSchema,
|
||||
} from "../shared";
|
||||
|
||||
export const OptionListOptionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
icon: z.custom<ReactNode>().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const OptionListPropsSchema = z.object({
|
||||
/**
|
||||
* Unique identifier for this tool UI instance in the conversation.
|
||||
*
|
||||
* Used for:
|
||||
* - Assistant referencing ("the options above")
|
||||
* - Receipt generation (linking selections to their source)
|
||||
* - Narration context
|
||||
*
|
||||
* Should be stable across re-renders, meaningful, and unique within the conversation.
|
||||
*
|
||||
* @example "option-list-deploy-target", "format-selection"
|
||||
*/
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
options: z.array(OptionListOptionSchema).min(1),
|
||||
selectionMode: z.enum(["multi", "single"]).optional(),
|
||||
/**
|
||||
* Controlled selection value (advanced / runtime only).
|
||||
*
|
||||
* For Tool UI tool payloads, prefer `defaultValue` (initial selection) and
|
||||
* `confirmed` (receipt state). Controlled `value` is intentionally excluded
|
||||
* from `SerializableOptionListSchema` to avoid accidental "controlled but
|
||||
* non-interactive" states when an LLM includes `value` in args.
|
||||
*/
|
||||
value: z.union([z.array(z.string()), z.string(), z.null()]).optional(),
|
||||
defaultValue: z.union([z.array(z.string()), z.string(), z.null()]).optional(),
|
||||
/**
|
||||
* When set, renders the component in receipt/confirmed state.
|
||||
*
|
||||
* In receipt state:
|
||||
* - Only the confirmed option(s) are shown
|
||||
* - Response actions are hidden
|
||||
* - The component is read-only
|
||||
*
|
||||
* Use this with assistant-ui's `addResult` to show the outcome of a decision.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In makeAssistantToolUI render:
|
||||
* if (result) {
|
||||
* return <OptionList {...args} confirmed={result} />;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
confirmed: z.union([z.array(z.string()), z.string(), z.null()]).optional(),
|
||||
responseActions: z
|
||||
.union([z.array(ActionSchema), SerializableActionsConfigSchema])
|
||||
.optional(),
|
||||
minSelections: z.number().min(0).optional(),
|
||||
maxSelections: z.number().min(1).optional(),
|
||||
});
|
||||
|
||||
export type OptionListSelection = string[] | string | null;
|
||||
|
||||
export type OptionListOption = z.infer<typeof OptionListOptionSchema>;
|
||||
|
||||
export type OptionListProps = Omit<
|
||||
z.infer<typeof OptionListPropsSchema>,
|
||||
"value" | "defaultValue" | "confirmed"
|
||||
> & {
|
||||
/** @see OptionListPropsSchema.id */
|
||||
id: string;
|
||||
value?: OptionListSelection;
|
||||
defaultValue?: OptionListSelection;
|
||||
/** @see OptionListPropsSchema.confirmed */
|
||||
confirmed?: OptionListSelection;
|
||||
onChange?: (value: OptionListSelection) => void;
|
||||
onConfirm?: (value: OptionListSelection) => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
responseActions?: ActionsProp;
|
||||
onResponseAction?: (actionId: string) => void | Promise<void>;
|
||||
onBeforeResponseAction?: (actionId: string) => boolean | Promise<boolean>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SerializableOptionListSchema = OptionListPropsSchema.omit({
|
||||
// Exclude controlled selection from tool/LLM payloads.
|
||||
value: true,
|
||||
}).extend({
|
||||
options: z.array(OptionListOptionSchema.omit({ icon: true })),
|
||||
responseActions: z
|
||||
.union([z.array(SerializableActionSchema), SerializableActionsConfigSchema])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type SerializableOptionList = z.infer<
|
||||
typeof SerializableOptionListSchema
|
||||
>;
|
||||
|
||||
export function parseSerializableOptionList(
|
||||
input: unknown,
|
||||
): SerializableOptionList {
|
||||
return parseWithSchema(SerializableOptionListSchema, input, "OptionList");
|
||||
}
|
||||
12
dashboard/src/components/tool-ui/shared/_adapter.tsx
Normal file
12
dashboard/src/components/tool-ui/shared/_adapter.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Adapter: UI and utility re-exports for copy-standalone portability.
|
||||
*
|
||||
* When copying this component to another project, update these imports
|
||||
* to match your project's paths:
|
||||
*
|
||||
* cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
|
||||
* Button → shadcn/ui Button
|
||||
*/
|
||||
|
||||
export { cn } from "../../../lib/ui/cn";
|
||||
export { Button } from "../../ui/button";
|
||||
102
dashboard/src/components/tool-ui/shared/action-buttons.tsx
Normal file
102
dashboard/src/components/tool-ui/shared/action-buttons.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import type { Action } from "./schema";
|
||||
import { cn, Button } from "./_adapter";
|
||||
import { useActionButtons } from "./use-action-buttons";
|
||||
|
||||
export interface ActionButtonsProps {
|
||||
actions: Action[];
|
||||
onAction: (actionId: string) => void | Promise<void>;
|
||||
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
|
||||
confirmTimeout?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ActionButtons({
|
||||
actions,
|
||||
onAction,
|
||||
onBeforeAction,
|
||||
confirmTimeout = 3000,
|
||||
align = "right",
|
||||
className,
|
||||
}: ActionButtonsProps) {
|
||||
const { actions: resolvedActions, runAction } = useActionButtons({
|
||||
actions,
|
||||
onAction,
|
||||
onBeforeAction,
|
||||
confirmTimeout,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3",
|
||||
"@sm/actions:flex-row @sm/actions:flex-wrap @sm/actions:items-center @sm/actions:gap-2",
|
||||
align === "left" && "@sm/actions:justify-start",
|
||||
align === "center" && "@sm/actions:justify-center",
|
||||
align === "right" && "@sm/actions:justify-end",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{resolvedActions.map((action) => {
|
||||
const label = action.currentLabel;
|
||||
const variant = action.variant || "default";
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={action.id}
|
||||
variant={variant}
|
||||
size="lg"
|
||||
onClick={() => runAction(action.id)}
|
||||
disabled={action.isDisabled}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
"justify-center",
|
||||
"min-w-24",
|
||||
"min-h-11 w-full text-base",
|
||||
"@sm/actions:min-h-0 @sm/actions:w-auto @sm/actions:px-3 @sm/actions:py-2 @sm/actions:text-sm",
|
||||
action.isConfirming &&
|
||||
"ring-destructive ring-2 ring-offset-2 motion-safe:animate-pulse",
|
||||
)}
|
||||
aria-label={
|
||||
action.shortcut ? `${label} (${action.shortcut})` : label
|
||||
}
|
||||
>
|
||||
{action.isLoading && (
|
||||
<svg
|
||||
className="mr-2 h-4 w-4 motion-safe:animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{action.icon && !action.isLoading && (
|
||||
<span className="mr-2">{action.icon}</span>
|
||||
)}
|
||||
{label}
|
||||
{action.shortcut && !action.isLoading && (
|
||||
<kbd className="border-border bg-muted ml-2.5 hidden rounded-lg border px-2 py-0.5 font-mono text-xs font-medium sm:inline-block">
|
||||
{action.shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
dashboard/src/components/tool-ui/shared/actions-config.ts
Normal file
23
dashboard/src/components/tool-ui/shared/actions-config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Action, ActionsConfig } from "./schema";
|
||||
|
||||
export type ActionsProp = ActionsConfig | Action[];
|
||||
|
||||
export function normalizeActionsConfig(
|
||||
actions?: ActionsProp,
|
||||
): ActionsConfig | null {
|
||||
if (!actions) return null;
|
||||
|
||||
const resolved = Array.isArray(actions)
|
||||
? { items: actions }
|
||||
: {
|
||||
items: actions.items ?? [],
|
||||
align: actions.align,
|
||||
confirmTimeout: actions.confirmTimeout,
|
||||
};
|
||||
|
||||
if (!resolved.items || resolved.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
51
dashboard/src/components/tool-ui/shared/error-boundary.tsx
Normal file
51
dashboard/src/components/tool-ui/shared/error-boundary.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
export interface ToolUIErrorBoundaryProps {
|
||||
componentName: string;
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface ToolUIErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ToolUIErrorBoundary extends React.Component<
|
||||
ToolUIErrorBoundaryProps,
|
||||
ToolUIErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ToolUIErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ToolUIErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error(`[${this.props.componentName}] render error:`, error, errorInfo);
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback ?? (
|
||||
<div className="border-destructive text-destructive rounded-lg border p-4">
|
||||
<p className="font-semibold">
|
||||
{this.props.componentName} failed to render
|
||||
</p>
|
||||
<p className="text-sm">{this.state.error?.message}</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
8
dashboard/src/components/tool-ui/shared/index.ts
Normal file
8
dashboard/src/components/tool-ui/shared/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { ActionButtons } from "./action-buttons";
|
||||
export type { ActionButtonsProps } from "./action-buttons";
|
||||
export { normalizeActionsConfig, type ActionsProp } from "./actions-config";
|
||||
export * from "./error-boundary";
|
||||
export * from "./parse";
|
||||
export * from "./schema";
|
||||
export * from "./use-copy-to-clipboard";
|
||||
export * from "./utils";
|
||||
37
dashboard/src/components/tool-ui/shared/parse.ts
Normal file
37
dashboard/src/components/tool-ui/shared/parse.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
|
||||
function formatZodPath(path: Array<string | number | symbol>): string {
|
||||
if (path.length === 0) return "root";
|
||||
return path
|
||||
.map((segment) =>
|
||||
typeof segment === "number" ? `[${segment}]` : String(segment),
|
||||
)
|
||||
.join(".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format Zod errors into a compact `path: message` string.
|
||||
*/
|
||||
export function formatZodError(error: z.ZodError): string {
|
||||
const parts = error.issues.map((issue) => {
|
||||
const path = formatZodPath(issue.path);
|
||||
return `${path}: ${issue.message}`;
|
||||
});
|
||||
|
||||
return Array.from(new Set(parts)).join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse unknown input and throw a readable error.
|
||||
*/
|
||||
export function parseWithSchema<T>(
|
||||
schema: z.ZodType<T>,
|
||||
input: unknown,
|
||||
name: string,
|
||||
): T {
|
||||
const res = schema.safeParse(input);
|
||||
if (!res.success) {
|
||||
throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
122
dashboard/src/components/tool-ui/shared/schema.ts
Normal file
122
dashboard/src/components/tool-ui/shared/schema.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { z } from "zod";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Tool UI conventions:
|
||||
* - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
|
||||
* - Schema: `SerializableXSchema`
|
||||
* - Parser: `parseSerializableX(input: unknown)`
|
||||
* - Actions: `responseActions`, `onResponseAction`, `onBeforeResponseAction`
|
||||
* - Root attrs: `data-tool-ui-id` + `data-slot`
|
||||
*/
|
||||
|
||||
/**
|
||||
* Schema for tool UI identity.
|
||||
*
|
||||
* Every tool UI should have a unique identifier that:
|
||||
* - Is stable across re-renders
|
||||
* - Is meaningful (not auto-generated)
|
||||
* - Is unique within the conversation
|
||||
*
|
||||
* Format recommendation: `{component-type}-{semantic-identifier}`
|
||||
* Examples: "data-table-expenses-q3", "option-list-deploy-target"
|
||||
*/
|
||||
export const ToolUIIdSchema = z.string().min(1);
|
||||
|
||||
export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
|
||||
|
||||
/**
|
||||
* Primary role of a Tool UI surface in a chat context.
|
||||
*/
|
||||
export const ToolUIRoleSchema = z.enum([
|
||||
"information",
|
||||
"decision",
|
||||
"control",
|
||||
"state",
|
||||
"composite",
|
||||
]);
|
||||
|
||||
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
|
||||
|
||||
export const ToolUIReceiptOutcomeSchema = z.enum([
|
||||
"success",
|
||||
"partial",
|
||||
"failed",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
|
||||
|
||||
/**
|
||||
* Optional receipt metadata: a durable summary of an outcome.
|
||||
*/
|
||||
export const ToolUIReceiptSchema = z.object({
|
||||
outcome: ToolUIReceiptOutcomeSchema,
|
||||
summary: z.string().min(1),
|
||||
identifiers: z.record(z.string(), z.string()).optional(),
|
||||
at: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
|
||||
|
||||
/**
|
||||
* Base schema for Tool UI payloads (id + optional role/receipt).
|
||||
*/
|
||||
export const ToolUISurfaceSchema = z.object({
|
||||
id: ToolUIIdSchema,
|
||||
role: ToolUIRoleSchema.optional(),
|
||||
receipt: ToolUIReceiptSchema.optional(),
|
||||
});
|
||||
|
||||
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
|
||||
|
||||
export const ActionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
/**
|
||||
* Canonical narration the assistant can use after this action is taken.
|
||||
*
|
||||
* Example: "I exported the table as CSV." / "I opened the link in a new tab."
|
||||
*/
|
||||
sentence: z.string().optional(),
|
||||
confirmLabel: z.string().optional(),
|
||||
variant: z
|
||||
.enum(["default", "destructive", "secondary", "ghost", "outline"])
|
||||
.optional(),
|
||||
icon: z.custom<ReactNode>().optional(),
|
||||
loading: z.boolean().optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
shortcut: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Action = z.infer<typeof ActionSchema>;
|
||||
|
||||
export const ActionButtonsPropsSchema = z.object({
|
||||
actions: z.array(ActionSchema).min(1),
|
||||
align: z.enum(["left", "center", "right"]).optional(),
|
||||
confirmTimeout: z.number().positive().optional(),
|
||||
className: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
|
||||
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
|
||||
actions: z.array(SerializableActionSchema),
|
||||
}).omit({ className: true });
|
||||
|
||||
export interface ActionsConfig {
|
||||
items: Action[];
|
||||
align?: "left" | "center" | "right";
|
||||
confirmTimeout?: number;
|
||||
}
|
||||
|
||||
export const SerializableActionsConfigSchema = z.object({
|
||||
items: z.array(SerializableActionSchema).min(1),
|
||||
align: z.enum(["left", "center", "right"]).optional(),
|
||||
confirmTimeout: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
export type SerializableActionsConfig = z.infer<
|
||||
typeof SerializableActionsConfigSchema
|
||||
>;
|
||||
|
||||
export type SerializableAction = z.infer<typeof SerializableActionSchema>;
|
||||
128
dashboard/src/components/tool-ui/shared/use-action-buttons.tsx
Normal file
128
dashboard/src/components/tool-ui/shared/use-action-buttons.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { Action } from "./schema";
|
||||
|
||||
export type UseActionButtonsOptions = {
|
||||
actions: Action[];
|
||||
onAction: (actionId: string) => void | Promise<void>;
|
||||
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
|
||||
confirmTimeout?: number;
|
||||
};
|
||||
|
||||
export type UseActionButtonsResult = {
|
||||
actions: Array<
|
||||
Action & {
|
||||
currentLabel: string;
|
||||
isConfirming: boolean;
|
||||
isExecuting: boolean;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
>;
|
||||
runAction: (actionId: string) => Promise<void>;
|
||||
confirmingActionId: string | null;
|
||||
executingActionId: string | null;
|
||||
};
|
||||
|
||||
export function useActionButtons(
|
||||
options: UseActionButtonsOptions,
|
||||
): UseActionButtonsResult {
|
||||
const {
|
||||
actions,
|
||||
onAction,
|
||||
onBeforeAction,
|
||||
confirmTimeout = 3000,
|
||||
} = options;
|
||||
|
||||
const [confirmingActionId, setConfirmingActionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [executingActionId, setExecutingActionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmingActionId) return;
|
||||
const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout);
|
||||
return () => clearTimeout(id);
|
||||
}, [confirmingActionId, confirmTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!confirmingActionId) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setConfirmingActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [confirmingActionId]);
|
||||
|
||||
const runAction = useCallback(
|
||||
async (actionId: string) => {
|
||||
const action = actions.find((a) => a.id === actionId);
|
||||
if (!action) return;
|
||||
|
||||
const isAnyActionExecuting = executingActionId !== null;
|
||||
if (action.disabled || action.loading || isAnyActionExecuting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.confirmLabel && confirmingActionId !== action.id) {
|
||||
setConfirmingActionId(action.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (onBeforeAction) {
|
||||
const shouldProceed = await onBeforeAction(action.id);
|
||||
if (!shouldProceed) {
|
||||
setConfirmingActionId(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setExecutingActionId(action.id);
|
||||
await onAction(action.id);
|
||||
} finally {
|
||||
setExecutingActionId(null);
|
||||
setConfirmingActionId(null);
|
||||
}
|
||||
},
|
||||
[actions, confirmingActionId, executingActionId, onAction, onBeforeAction],
|
||||
);
|
||||
|
||||
const resolvedActions = useMemo(
|
||||
() =>
|
||||
actions.map((action) => {
|
||||
const isConfirming = confirmingActionId === action.id;
|
||||
const isThisActionExecuting = executingActionId === action.id;
|
||||
const isLoading = action.loading || isThisActionExecuting;
|
||||
const isDisabled =
|
||||
action.disabled || (executingActionId !== null && !isThisActionExecuting);
|
||||
const currentLabel =
|
||||
isConfirming && action.confirmLabel
|
||||
? action.confirmLabel
|
||||
: action.label;
|
||||
|
||||
return {
|
||||
...action,
|
||||
currentLabel,
|
||||
isConfirming,
|
||||
isExecuting: isThisActionExecuting,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
};
|
||||
}),
|
||||
[actions, confirmingActionId, executingActionId],
|
||||
);
|
||||
|
||||
return {
|
||||
actions: resolvedActions,
|
||||
runAction,
|
||||
confirmingActionId,
|
||||
executingActionId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
function fallbackCopyToClipboard(text: string): boolean {
|
||||
try {
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = text;
|
||||
textArea.setAttribute("readonly", "");
|
||||
textArea.style.position = "fixed";
|
||||
textArea.style.top = "-9999px";
|
||||
textArea.style.left = "-9999px";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function useCopyToClipboard(options?: {
|
||||
resetAfterMs?: number;
|
||||
}): {
|
||||
copiedId: string | null;
|
||||
copy: (text: string, id?: string) => Promise<boolean>;
|
||||
} {
|
||||
const resetAfterMs = options?.resetAfterMs ?? 2000;
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
const copy = useCallback(
|
||||
async (text: string, id: string = "default") => {
|
||||
let ok = false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ok = true;
|
||||
} else {
|
||||
ok = fallbackCopyToClipboard(text);
|
||||
}
|
||||
} catch {
|
||||
ok = fallbackCopyToClipboard(text);
|
||||
}
|
||||
|
||||
if (ok) {
|
||||
setCopiedId(id);
|
||||
}
|
||||
|
||||
return ok;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!copiedId) return;
|
||||
const timeout = setTimeout(() => setCopiedId(null), resetAfterMs);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [copiedId, resetAfterMs]);
|
||||
|
||||
return { copiedId, copy };
|
||||
}
|
||||
|
||||
22
dashboard/src/components/tool-ui/shared/utils.ts
Normal file
22
dashboard/src/components/tool-ui/shared/utils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export function formatRelativeTime(iso: string): string {
|
||||
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
||||
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
|
||||
if (seconds < 604800) return `${Math.round(seconds / 86400)}d`;
|
||||
return `${Math.round(seconds / 604800)}w`;
|
||||
}
|
||||
|
||||
export function formatCount(count: number): string {
|
||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
|
||||
return String(count);
|
||||
}
|
||||
|
||||
export function getDomain(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
59
dashboard/src/components/ui/button.tsx
Normal file
59
dashboard/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ButtonVariant =
|
||||
| "default"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "outline"
|
||||
| "destructive";
|
||||
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-primary/30",
|
||||
secondary:
|
||||
"bg-muted text-foreground hover:bg-muted/80 focus-visible:ring-primary/30",
|
||||
ghost:
|
||||
"bg-transparent text-foreground hover:bg-muted/50 focus-visible:ring-primary/30",
|
||||
outline:
|
||||
"border border-border bg-transparent text-foreground hover:bg-muted/40 focus-visible:ring-primary/30",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/30",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: "h-9 px-3 text-sm",
|
||||
md: "h-10 px-4 text-sm",
|
||||
lg: "h-11 px-5 text-base",
|
||||
};
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", size = "md", type, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type ?? "button"}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
27
dashboard/src/components/ui/separator.tsx
Normal file
27
dashboard/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorProps) {
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -242,3 +242,117 @@ export async function getRunTasks(id: string): Promise<{ run_id: string; tasks:
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ==================== Global Control Session ====================
|
||||
|
||||
export type ControlRunState = 'idle' | 'running' | 'waiting_for_tool';
|
||||
|
||||
export type ControlAgentEvent =
|
||||
| { type: 'status'; state: ControlRunState; queue_len: number }
|
||||
| { type: 'user_message'; id: string; content: string }
|
||||
| {
|
||||
type: 'assistant_message';
|
||||
id: string;
|
||||
content: string;
|
||||
success: boolean;
|
||||
cost_cents: number;
|
||||
model: string | null;
|
||||
}
|
||||
| { type: 'tool_call'; tool_call_id: string; name: string; args: unknown }
|
||||
| { type: 'tool_result'; tool_call_id: string; name: string; result: unknown }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
export async function postControlMessage(content: string): Promise<{ id: string; queued: boolean }> {
|
||||
const res = await apiFetch('/api/control/message', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to post control message');
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function postControlToolResult(payload: {
|
||||
tool_call_id: string;
|
||||
name: string;
|
||||
result: unknown;
|
||||
}): Promise<void> {
|
||||
const res = await apiFetch('/api/control/tool_result', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to post tool result');
|
||||
}
|
||||
|
||||
export async function cancelControl(): Promise<void> {
|
||||
const res = await apiFetch('/api/control/cancel', { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Failed to cancel control session');
|
||||
}
|
||||
|
||||
export function streamControl(
|
||||
onEvent: (event: { type: string; data: unknown }) => void
|
||||
): () => void {
|
||||
const controller = new AbortController();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await apiFetch('/api/control/stream', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
onEvent({
|
||||
type: 'error',
|
||||
data: { message: `Stream request failed (${res.status})`, status: res.status },
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!res.body) {
|
||||
onEvent({ type: 'error', data: { message: 'Stream response had no body' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
let idx = buffer.indexOf('\n\n');
|
||||
while (idx !== -1) {
|
||||
const raw = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
idx = buffer.indexOf('\n\n');
|
||||
|
||||
let eventType = 'message';
|
||||
let data = '';
|
||||
for (const line of raw.split('\n')) {
|
||||
if (line.startsWith('event:')) {
|
||||
eventType = line.slice('event:'.length).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
data += line.slice('data:'.length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) continue;
|
||||
try {
|
||||
onEvent({ type: eventType, data: JSON.parse(data) });
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (!controller.signal.aborted) {
|
||||
onEvent({ type: 'error', data: { message: 'Stream connection failed' } });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}
|
||||
|
||||
|
||||
1
dashboard/src/lib/ui/cn.ts
Normal file
1
dashboard/src/lib/ui/cn.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { cn } from "../utils";
|
||||
@@ -8,6 +8,7 @@ use crate::config::Config;
|
||||
use crate::llm::LlmClient;
|
||||
use crate::memory::MemorySystem;
|
||||
use crate::tools::ToolRegistry;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
/// Shared context passed to all agents during execution.
|
||||
///
|
||||
@@ -38,6 +39,18 @@ pub struct AgentContext {
|
||||
|
||||
/// Memory system for persistent storage (optional)
|
||||
pub memory: Option<MemorySystem>,
|
||||
|
||||
/// Optional event sink for streaming agent events (e.g. control session SSE).
|
||||
pub control_events: Option<broadcast::Sender<crate::api::control::AgentEvent>>,
|
||||
|
||||
/// Optional hub for awaiting frontend (interactive) tool results.
|
||||
pub frontend_tool_hub: Option<Arc<crate::api::control::FrontendToolHub>>,
|
||||
|
||||
/// Optional shared control-session status (so the executor can switch to WaitingForTool).
|
||||
pub control_status: Option<Arc<tokio::sync::RwLock<crate::api::control::ControlStatus>>>,
|
||||
|
||||
/// Optional cancellation token for cooperative cancellation.
|
||||
pub cancel_token: Option<tokio_util::sync::CancellationToken>,
|
||||
}
|
||||
|
||||
impl AgentContext {
|
||||
@@ -58,6 +71,10 @@ impl AgentContext {
|
||||
workspace,
|
||||
max_split_depth: 3, // Default max recursion for splitting
|
||||
memory: None,
|
||||
control_events: None,
|
||||
frontend_tool_hub: None,
|
||||
control_status: None,
|
||||
cancel_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +96,10 @@ impl AgentContext {
|
||||
workspace,
|
||||
max_split_depth: 3,
|
||||
memory,
|
||||
control_events: None,
|
||||
frontend_tool_hub: None,
|
||||
control_status: None,
|
||||
cancel_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +117,10 @@ impl AgentContext {
|
||||
max_split_depth: self.max_split_depth.saturating_sub(1),
|
||||
max_iterations: self.max_iterations,
|
||||
memory: self.memory.clone(),
|
||||
control_events: self.control_events.clone(),
|
||||
frontend_tool_hub: self.frontend_tool_hub.clone(),
|
||||
control_status: self.control_status.clone(),
|
||||
cancel_token: self.cancel_token.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,5 +138,13 @@ impl AgentContext {
|
||||
pub fn has_memory(&self) -> bool {
|
||||
self.memory.is_some()
|
||||
}
|
||||
|
||||
/// Check if cooperative cancellation was requested.
|
||||
pub fn is_cancelled(&self) -> bool {
|
||||
self.cancel_token
|
||||
.as_ref()
|
||||
.map(|t| t.is_cancelled())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,6 +203,10 @@ impl Agent for ComplexityEstimator {
|
||||
/// # Returns
|
||||
/// AgentResult with Complexity data in the `data` field.
|
||||
async fn execute(&self, task: &mut Task, ctx: &AgentContext) -> AgentResult {
|
||||
if ctx.is_cancelled() {
|
||||
return AgentResult::failure("Cancelled", 0);
|
||||
}
|
||||
|
||||
let prompt = self.build_prompt(task);
|
||||
|
||||
let messages = vec![
|
||||
|
||||
@@ -9,6 +9,7 @@ use serde_json::json;
|
||||
use crate::agents::{
|
||||
Agent, AgentContext, AgentId, AgentResult, AgentType, LeafAgent, LeafCapability,
|
||||
};
|
||||
use crate::api::control::{AgentEvent, ControlRunState};
|
||||
use crate::budget::ExecutionSignals;
|
||||
use crate::llm::{ChatMessage, Role, ToolCall};
|
||||
use crate::task::{Task, TokenUsageSummary};
|
||||
@@ -74,6 +75,8 @@ You operate in the workspace: {workspace}
|
||||
3. Verify your work when possible
|
||||
4. If stuck, explain what's blocking you
|
||||
5. When done, summarize what you accomplished
|
||||
6. For structured output (tables, lists of choices), prefer calling UI tools (ui_*) so the dashboard can render rich components.
|
||||
7. If you call an interactive UI tool (e.g. ui_optionList), wait for the tool result and continue based on the user's selection.
|
||||
|
||||
## Response
|
||||
When task is complete, provide a clear summary of:
|
||||
@@ -147,6 +150,35 @@ When task is complete, provide a clear summary of:
|
||||
iterations_completed = iteration as u32 + 1;
|
||||
tracing::debug!("TaskExecutor iteration {}", iteration + 1);
|
||||
|
||||
// Cooperative cancellation (control session).
|
||||
if let Some(token) = &ctx.cancel_token {
|
||||
if token.is_cancelled() {
|
||||
has_error_messages = true;
|
||||
let signals = ExecutionSignals {
|
||||
iterations: iterations_completed,
|
||||
max_iterations: ctx.max_iterations as u32,
|
||||
successful_tool_calls,
|
||||
failed_tool_calls,
|
||||
files_modified,
|
||||
repetitive_actions,
|
||||
has_error_messages,
|
||||
partial_progress: files_modified || successful_tool_calls > 0,
|
||||
cost_spent_cents: total_cost_cents,
|
||||
budget_total_cents: task.budget().total_cents(),
|
||||
final_output: "Cancelled".to_string(),
|
||||
model_used: model.to_string(),
|
||||
};
|
||||
return ExecutionLoopResult {
|
||||
output: "Cancelled".to_string(),
|
||||
cost_cents: total_cost_cents,
|
||||
tool_log,
|
||||
usage,
|
||||
signals,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check budget
|
||||
let remaining = task.budget().remaining_cents();
|
||||
if remaining == 0 && total_cost_cents > 0 {
|
||||
@@ -249,6 +281,28 @@ When task is complete, provide a clear summary of:
|
||||
|
||||
// Execute each tool call
|
||||
for tool_call in tool_calls {
|
||||
let tool_name = tool_call.function.name.clone();
|
||||
let args_json: serde_json::Value =
|
||||
serde_json::from_str(&tool_call.function.arguments)
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
// For interactive frontend tools, register the tool_call_id before notifying the UI,
|
||||
// so a fast tool_result POST can't race ahead of registration.
|
||||
let mut pending_frontend_rx: Option<tokio::sync::oneshot::Receiver<serde_json::Value>> = None;
|
||||
if tool_name == "ui_optionList" {
|
||||
if let Some(hub) = &ctx.frontend_tool_hub {
|
||||
pending_frontend_rx = Some(hub.register(tool_call.id.clone()).await);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(events) = &ctx.control_events {
|
||||
let _ = events.send(AgentEvent::ToolCall {
|
||||
tool_call_id: tool_call.id.clone(),
|
||||
name: tool_name.clone(),
|
||||
args: args_json.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
tool_log.push(format!(
|
||||
"Tool: {} Args: {}",
|
||||
tool_call.function.name,
|
||||
@@ -256,27 +310,124 @@ When task is complete, provide a clear summary of:
|
||||
));
|
||||
|
||||
// Track file modifications
|
||||
if tool_call.function.name == "write_file"
|
||||
|| tool_call.function.name == "delete_file" {
|
||||
if tool_name == "write_file" || tool_name == "delete_file" {
|
||||
files_modified = true;
|
||||
}
|
||||
|
||||
let result = match self.execute_tool_call(tool_call, ctx).await {
|
||||
Ok(output) => {
|
||||
successful_tool_calls += 1;
|
||||
output
|
||||
}
|
||||
Err(e) => {
|
||||
failed_tool_calls += 1;
|
||||
has_error_messages = true;
|
||||
format!("Error: {}", e)
|
||||
}
|
||||
};
|
||||
// UI tools are handled by the frontend. We emit events and (optionally) wait for a user result.
|
||||
let (tool_message_content, tool_result_json): (String, serde_json::Value) =
|
||||
if tool_name.starts_with("ui_") {
|
||||
// Interactive tool: wait for frontend to POST result.
|
||||
if tool_name == "ui_optionList" {
|
||||
if let Some(rx) = pending_frontend_rx {
|
||||
if let (Some(status), Some(events)) = (&ctx.control_status, &ctx.control_events) {
|
||||
let mut s = status.write().await;
|
||||
s.state = ControlRunState::WaitingForTool;
|
||||
let q = s.queue_len;
|
||||
drop(s);
|
||||
let _ = events.send(AgentEvent::Status { state: ControlRunState::WaitingForTool, queue_len: q });
|
||||
}
|
||||
|
||||
let recv = if let Some(token) = &ctx.cancel_token {
|
||||
tokio::select! {
|
||||
v = rx => v,
|
||||
_ = token.cancelled() => {
|
||||
has_error_messages = true;
|
||||
let signals = ExecutionSignals {
|
||||
iterations: iterations_completed,
|
||||
max_iterations: ctx.max_iterations as u32,
|
||||
successful_tool_calls,
|
||||
failed_tool_calls,
|
||||
files_modified,
|
||||
repetitive_actions,
|
||||
has_error_messages,
|
||||
partial_progress: files_modified || successful_tool_calls > 0,
|
||||
cost_spent_cents: total_cost_cents,
|
||||
budget_total_cents: task.budget().total_cents(),
|
||||
final_output: "Cancelled".to_string(),
|
||||
model_used: model.to_string(),
|
||||
};
|
||||
return ExecutionLoopResult {
|
||||
output: "Cancelled".to_string(),
|
||||
cost_cents: total_cost_cents,
|
||||
tool_log,
|
||||
usage,
|
||||
signals,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rx.await
|
||||
};
|
||||
|
||||
match recv {
|
||||
Ok(v) => {
|
||||
successful_tool_calls += 1;
|
||||
let msg = serde_json::to_string(&v)
|
||||
.unwrap_or_else(|_| v.to_string());
|
||||
if let (Some(status), Some(events)) = (&ctx.control_status, &ctx.control_events) {
|
||||
let mut s = status.write().await;
|
||||
s.state = ControlRunState::Running;
|
||||
let q = s.queue_len;
|
||||
drop(s);
|
||||
let _ = events.send(AgentEvent::Status { state: ControlRunState::Running, queue_len: q });
|
||||
}
|
||||
(msg, v)
|
||||
}
|
||||
Err(_) => {
|
||||
has_error_messages = true;
|
||||
failed_tool_calls += 1;
|
||||
if let (Some(status), Some(events)) = (&ctx.control_status, &ctx.control_events) {
|
||||
let mut s = status.write().await;
|
||||
s.state = ControlRunState::Running;
|
||||
let q = s.queue_len;
|
||||
drop(s);
|
||||
let _ = events.send(AgentEvent::Status { state: ControlRunState::Running, queue_len: q });
|
||||
}
|
||||
("Error: tool result channel closed".to_string(), serde_json::Value::Null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
has_error_messages = true;
|
||||
failed_tool_calls += 1;
|
||||
("Error: frontend tool hub not configured".to_string(), serde_json::Value::Null)
|
||||
}
|
||||
} else {
|
||||
// Non-interactive UI render: echo args as the tool result payload.
|
||||
let msg = serde_json::to_string(&args_json)
|
||||
.unwrap_or_else(|_| args_json.to_string());
|
||||
successful_tool_calls += 1;
|
||||
(msg, args_json.clone())
|
||||
}
|
||||
} else {
|
||||
// Regular server tool.
|
||||
match self.execute_tool_call(tool_call, ctx).await {
|
||||
Ok(output) => {
|
||||
successful_tool_calls += 1;
|
||||
(output.clone(), serde_json::Value::String(output))
|
||||
}
|
||||
Err(e) => {
|
||||
failed_tool_calls += 1;
|
||||
has_error_messages = true;
|
||||
let s = format!("Error: {}", e);
|
||||
(s.clone(), serde_json::Value::String(s))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(events) = &ctx.control_events {
|
||||
let _ = events.send(AgentEvent::ToolResult {
|
||||
tool_call_id: tool_call.id.clone(),
|
||||
name: tool_name.clone(),
|
||||
result: tool_result_json.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Add tool result
|
||||
messages.push(ChatMessage {
|
||||
role: Role::Tool,
|
||||
content: Some(result),
|
||||
content: Some(tool_message_content),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_call.id.clone()),
|
||||
});
|
||||
|
||||
@@ -266,6 +266,10 @@ impl Agent for ModelSelector {
|
||||
/// # Returns
|
||||
/// AgentResult with ModelRecommendation in the `data` field.
|
||||
async fn execute(&self, task: &mut Task, ctx: &AgentContext) -> AgentResult {
|
||||
if ctx.is_cancelled() {
|
||||
return AgentResult::failure("Cancelled", 0);
|
||||
}
|
||||
|
||||
// Get complexity + estimated tokens from task analysis (populated by ComplexityEstimator).
|
||||
let complexity = task
|
||||
.analysis()
|
||||
|
||||
@@ -313,6 +313,10 @@ impl Agent for Verifier {
|
||||
}
|
||||
|
||||
async fn execute(&self, task: &mut Task, ctx: &AgentContext) -> AgentResult {
|
||||
if ctx.is_cancelled() {
|
||||
return AgentResult::failure("Cancelled", 0);
|
||||
}
|
||||
|
||||
let result = self.verify(task, ctx).await;
|
||||
|
||||
if result.passed() {
|
||||
|
||||
@@ -93,6 +93,10 @@ impl Agent for NodeAgent {
|
||||
async fn execute(&self, task: &mut Task, ctx: &AgentContext) -> AgentResult {
|
||||
tracing::debug!("NodeAgent '{}' executing task", self.name);
|
||||
|
||||
if ctx.is_cancelled() {
|
||||
return AgentResult::failure("Cancelled", 0);
|
||||
}
|
||||
|
||||
// Execute the task
|
||||
let result = self.task_executor.execute(task, ctx).await;
|
||||
|
||||
|
||||
@@ -206,6 +206,10 @@ Respond ONLY with the JSON object."#,
|
||||
|
||||
// Execute each subtask with planning + verification + smart retry.
|
||||
for task in &mut tasks {
|
||||
if ctx.is_cancelled() {
|
||||
return AgentResult::failure("Cancelled", total_cost);
|
||||
}
|
||||
|
||||
let subtask_result = self
|
||||
.execute_single_subtask_with_retry(task, ctx, &retry_config)
|
||||
.await;
|
||||
@@ -260,6 +264,10 @@ Respond ONLY with the JSON object."#,
|
||||
let mut retry_history = Vec::new();
|
||||
|
||||
loop {
|
||||
if ctx.is_cancelled() {
|
||||
return AgentResult::failure("Cancelled", total_cost);
|
||||
}
|
||||
|
||||
// 1) Estimate complexity for this subtask.
|
||||
let est = self.complexity_estimator.execute(task, ctx).await;
|
||||
total_cost += est.cost_cents;
|
||||
@@ -500,6 +508,10 @@ impl Agent for RootAgent {
|
||||
async fn execute(&self, task: &mut Task, ctx: &AgentContext) -> AgentResult {
|
||||
let mut total_cost = 0u64;
|
||||
|
||||
if ctx.is_cancelled() {
|
||||
return AgentResult::failure("Cancelled", total_cost);
|
||||
}
|
||||
|
||||
// Step 1: Estimate complexity (cost is tracked in the result)
|
||||
let complexity_result = self.complexity_estimator.execute(task, ctx).await;
|
||||
total_cost += complexity_result.cost_cents;
|
||||
@@ -576,6 +588,10 @@ impl RootAgent {
|
||||
let mut retry_history = Vec::new();
|
||||
|
||||
loop {
|
||||
if ctx.is_cancelled() {
|
||||
return AgentResult::failure("Cancelled", total_cost);
|
||||
}
|
||||
|
||||
// Select model (U-curve) for execution
|
||||
let sel = self.model_selector.execute(task, ctx).await;
|
||||
total_cost += sel.cost_cents;
|
||||
|
||||
529
src/api/control.rs
Normal file
529
src/api/control.rs
Normal file
@@ -0,0 +1,529 @@
|
||||
//! Global control session API (interactive, queued).
|
||||
//!
|
||||
//! This module implements a single global "control session" that:
|
||||
//! - accepts user messages at any time (queued FIFO)
|
||||
//! - runs a persistent root-agent conversation sequentially
|
||||
//! - streams structured events via SSE (Tool UI friendly)
|
||||
//! - supports frontend/interactive tools by accepting tool results
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::sse::{Event, Sse},
|
||||
Json,
|
||||
};
|
||||
use futures::stream::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::{broadcast, mpsc, oneshot, Mutex, RwLock};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::agents::{AgentContext, AgentRef};
|
||||
use crate::budget::{Budget, ModelPricing};
|
||||
use crate::config::Config;
|
||||
use crate::llm::OpenRouterClient;
|
||||
use crate::memory::MemorySystem;
|
||||
use crate::task::VerificationCriteria;
|
||||
use crate::tools::ToolRegistry;
|
||||
|
||||
use super::routes::AppState;
|
||||
|
||||
/// Message posted by a user to the control session.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ControlMessageRequest {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ControlMessageResponse {
|
||||
pub id: Uuid,
|
||||
pub queued: bool,
|
||||
}
|
||||
|
||||
/// Tool result posted by the frontend for an interactive tool call.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ControlToolResultRequest {
|
||||
pub tool_call_id: String,
|
||||
pub name: String,
|
||||
pub result: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ControlRunState {
|
||||
Idle,
|
||||
Running,
|
||||
WaitingForTool,
|
||||
}
|
||||
|
||||
impl Default for ControlRunState {
|
||||
fn default() -> Self {
|
||||
ControlRunState::Idle
|
||||
}
|
||||
}
|
||||
|
||||
/// A structured event emitted by the control session.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum AgentEvent {
|
||||
Status {
|
||||
state: ControlRunState,
|
||||
queue_len: usize,
|
||||
},
|
||||
UserMessage {
|
||||
id: Uuid,
|
||||
content: String,
|
||||
},
|
||||
AssistantMessage {
|
||||
id: Uuid,
|
||||
content: String,
|
||||
success: bool,
|
||||
cost_cents: u64,
|
||||
model: Option<String>,
|
||||
},
|
||||
ToolCall {
|
||||
tool_call_id: String,
|
||||
name: String,
|
||||
args: serde_json::Value,
|
||||
},
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
name: String,
|
||||
result: serde_json::Value,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl AgentEvent {
|
||||
pub fn event_name(&self) -> &'static str {
|
||||
match self {
|
||||
AgentEvent::Status { .. } => "status",
|
||||
AgentEvent::UserMessage { .. } => "user_message",
|
||||
AgentEvent::AssistantMessage { .. } => "assistant_message",
|
||||
AgentEvent::ToolCall { .. } => "tool_call",
|
||||
AgentEvent::ToolResult { .. } => "tool_result",
|
||||
AgentEvent::Error { .. } => "error",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal control commands (queued and processed by the actor).
|
||||
#[derive(Debug)]
|
||||
pub enum ControlCommand {
|
||||
UserMessage { id: Uuid, content: String },
|
||||
ToolResult {
|
||||
tool_call_id: String,
|
||||
name: String,
|
||||
result: serde_json::Value,
|
||||
},
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// Shared tool hub used to await frontend tool results.
|
||||
#[derive(Debug)]
|
||||
pub struct FrontendToolHub {
|
||||
pending: Mutex<HashMap<String, oneshot::Sender<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl FrontendToolHub {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pending: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a tool call that expects a frontend-provided result.
|
||||
pub async fn register(&self, tool_call_id: String) -> oneshot::Receiver<serde_json::Value> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let mut pending = self.pending.lock().await;
|
||||
pending.insert(tool_call_id, tx);
|
||||
rx
|
||||
}
|
||||
|
||||
/// Resolve a pending tool call by id.
|
||||
pub async fn resolve(
|
||||
&self,
|
||||
tool_call_id: &str,
|
||||
result: serde_json::Value,
|
||||
) -> Result<(), ()> {
|
||||
let mut pending = self.pending.lock().await;
|
||||
let Some(tx) = pending.remove(tool_call_id) else {
|
||||
return Err(());
|
||||
};
|
||||
let _ = tx.send(result);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Control session runtime stored in `AppState`.
|
||||
#[derive(Clone)]
|
||||
pub struct ControlState {
|
||||
pub cmd_tx: mpsc::Sender<ControlCommand>,
|
||||
pub events_tx: broadcast::Sender<AgentEvent>,
|
||||
pub tool_hub: Arc<FrontendToolHub>,
|
||||
pub status: Arc<RwLock<ControlStatus>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
pub struct ControlStatus {
|
||||
pub state: ControlRunState,
|
||||
pub queue_len: usize,
|
||||
}
|
||||
|
||||
async fn set_and_emit_status(
|
||||
status: &Arc<RwLock<ControlStatus>>,
|
||||
events: &broadcast::Sender<AgentEvent>,
|
||||
state: ControlRunState,
|
||||
queue_len: usize,
|
||||
) {
|
||||
{
|
||||
let mut s = status.write().await;
|
||||
s.state = state;
|
||||
s.queue_len = queue_len;
|
||||
}
|
||||
let _ = events.send(AgentEvent::Status { state, queue_len });
|
||||
}
|
||||
|
||||
/// Enqueue a user message for the global control session.
|
||||
pub async fn post_message(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<ControlMessageRequest>,
|
||||
) -> Result<Json<ControlMessageResponse>, (StatusCode, String)> {
|
||||
let content = req.content.trim().to_string();
|
||||
if content.is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "content is required".to_string()));
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let queued = true;
|
||||
state
|
||||
.control
|
||||
.cmd_tx
|
||||
.send(ControlCommand::UserMessage { id, content })
|
||||
.await
|
||||
.map_err(|_| (StatusCode::SERVICE_UNAVAILABLE, "control session unavailable".to_string()))?;
|
||||
|
||||
Ok(Json(ControlMessageResponse { id, queued }))
|
||||
}
|
||||
|
||||
/// Submit a frontend tool result to resume the running agent.
|
||||
pub async fn post_tool_result(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<ControlToolResultRequest>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
if req.tool_call_id.trim().is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"tool_call_id is required".to_string(),
|
||||
));
|
||||
}
|
||||
if req.name.trim().is_empty() {
|
||||
return Err((StatusCode::BAD_REQUEST, "name is required".to_string()));
|
||||
}
|
||||
|
||||
state
|
||||
.control
|
||||
.cmd_tx
|
||||
.send(ControlCommand::ToolResult {
|
||||
tool_call_id: req.tool_call_id,
|
||||
name: req.name,
|
||||
result: req.result,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| (StatusCode::SERVICE_UNAVAILABLE, "control session unavailable".to_string()))?;
|
||||
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Cancel the currently running control session task.
|
||||
pub async fn post_cancel(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
state
|
||||
.control
|
||||
.cmd_tx
|
||||
.send(ControlCommand::Cancel)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::SERVICE_UNAVAILABLE, "control session unavailable".to_string()))?;
|
||||
Ok(Json(serde_json::json!({ "ok": true })))
|
||||
}
|
||||
|
||||
/// Stream control session events via SSE.
|
||||
pub async fn stream(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (StatusCode, String)> {
|
||||
let mut rx = state.control.events_tx.subscribe();
|
||||
|
||||
// Emit an initial status snapshot immediately.
|
||||
let initial = state.control.status.read().await.clone();
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
let init_ev = Event::default()
|
||||
.event("status")
|
||||
.json_data(AgentEvent::Status { state: initial.state, queue_len: initial.queue_len })
|
||||
.unwrap();
|
||||
yield Ok(init_ev);
|
||||
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(ev) => {
|
||||
let sse = Event::default().event(ev.event_name()).json_data(&ev).unwrap();
|
||||
yield Ok(sse);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
||||
let sse = Event::default()
|
||||
.event("error")
|
||||
.json_data(AgentEvent::Error { message: "event stream lagged; some events were dropped".to_string() })
|
||||
.unwrap();
|
||||
yield Ok(sse);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Sse::new(stream))
|
||||
}
|
||||
|
||||
/// Spawn the global control session actor.
|
||||
pub fn spawn_control_session(
|
||||
config: Config,
|
||||
root_agent: AgentRef,
|
||||
memory: Option<MemorySystem>,
|
||||
) -> ControlState {
|
||||
let (cmd_tx, cmd_rx) = mpsc::channel::<ControlCommand>(256);
|
||||
let (events_tx, _events_rx) = broadcast::channel::<AgentEvent>(1024);
|
||||
let tool_hub = Arc::new(FrontendToolHub::new());
|
||||
let status = Arc::new(RwLock::new(ControlStatus {
|
||||
state: ControlRunState::Idle,
|
||||
queue_len: 0,
|
||||
}));
|
||||
|
||||
let state = ControlState {
|
||||
cmd_tx,
|
||||
events_tx: events_tx.clone(),
|
||||
tool_hub: Arc::clone(&tool_hub),
|
||||
status: Arc::clone(&status),
|
||||
};
|
||||
|
||||
tokio::spawn(control_actor_loop(
|
||||
config,
|
||||
root_agent,
|
||||
memory,
|
||||
cmd_rx,
|
||||
events_tx,
|
||||
tool_hub,
|
||||
status,
|
||||
));
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
async fn control_actor_loop(
|
||||
config: Config,
|
||||
root_agent: AgentRef,
|
||||
memory: Option<MemorySystem>,
|
||||
mut cmd_rx: mpsc::Receiver<ControlCommand>,
|
||||
events_tx: broadcast::Sender<AgentEvent>,
|
||||
tool_hub: Arc<FrontendToolHub>,
|
||||
status: Arc<RwLock<ControlStatus>>,
|
||||
) {
|
||||
let mut queue: VecDeque<(Uuid, String)> = VecDeque::new();
|
||||
let mut history: Vec<(String, String)> = Vec::new(); // (role, content) pairs (user/assistant)
|
||||
let pricing = Arc::new(ModelPricing::new());
|
||||
|
||||
let mut running: Option<tokio::task::JoinHandle<(Uuid, String, crate::agents::AgentResult)>> = None;
|
||||
let mut running_cancel: Option<CancellationToken> = None;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
cmd = cmd_rx.recv() => {
|
||||
let Some(cmd) = cmd else { break };
|
||||
match cmd {
|
||||
ControlCommand::UserMessage { id, content } => {
|
||||
queue.push_back((id, content));
|
||||
set_and_emit_status(
|
||||
&status,
|
||||
&events_tx,
|
||||
if running.is_some() { ControlRunState::Running } else { ControlRunState::Idle },
|
||||
queue.len(),
|
||||
).await;
|
||||
if running.is_none() {
|
||||
if let Some((mid, msg)) = queue.pop_front() {
|
||||
set_and_emit_status(&status, &events_tx, ControlRunState::Running, queue.len()).await;
|
||||
let _ = events_tx.send(AgentEvent::UserMessage { id: mid, content: msg.clone() });
|
||||
let cfg = config.clone();
|
||||
let agent = Arc::clone(&root_agent);
|
||||
let mem = memory.clone();
|
||||
let events = events_tx.clone();
|
||||
let tools_hub = Arc::clone(&tool_hub);
|
||||
let status_ref = Arc::clone(&status);
|
||||
let cancel = CancellationToken::new();
|
||||
let pricing = Arc::clone(&pricing);
|
||||
let hist_snapshot = history.clone();
|
||||
running_cancel = Some(cancel.clone());
|
||||
running = Some(tokio::spawn(async move {
|
||||
let result = run_single_control_turn(
|
||||
cfg,
|
||||
agent,
|
||||
mem,
|
||||
pricing,
|
||||
events,
|
||||
tools_hub,
|
||||
status_ref,
|
||||
cancel,
|
||||
hist_snapshot,
|
||||
msg.clone(),
|
||||
)
|
||||
.await;
|
||||
(mid, msg, result)
|
||||
}));
|
||||
} else {
|
||||
set_and_emit_status(&status, &events_tx, ControlRunState::Idle, 0).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
ControlCommand::ToolResult { tool_call_id, name, result } => {
|
||||
// Deliver to the tool hub. The executor emits ToolResult events when it receives it.
|
||||
if tool_hub.resolve(&tool_call_id, result).await.is_err() {
|
||||
let _ = events_tx.send(AgentEvent::Error { message: format!("Unknown tool_call_id '{}' for tool '{}'", tool_call_id, name) });
|
||||
}
|
||||
}
|
||||
ControlCommand::Cancel => {
|
||||
if let Some(token) = &running_cancel {
|
||||
token.cancel();
|
||||
let _ = events_tx.send(AgentEvent::Error { message: "Cancellation requested".to_string() });
|
||||
} else {
|
||||
let _ = events_tx.send(AgentEvent::Error { message: "No running task to cancel".to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finished = async {
|
||||
match &mut running {
|
||||
Some(handle) => Some(handle.await),
|
||||
None => None
|
||||
}
|
||||
}, if running.is_some() => {
|
||||
if let Some(res) = finished {
|
||||
running = None;
|
||||
running_cancel = None;
|
||||
match res {
|
||||
Ok((_mid, user_msg, agent_result)) => {
|
||||
// Append to conversation history.
|
||||
history.push(("user".to_string(), user_msg));
|
||||
history.push(("assistant".to_string(), agent_result.output.clone()));
|
||||
|
||||
let _ = events_tx.send(AgentEvent::AssistantMessage {
|
||||
id: Uuid::new_v4(),
|
||||
content: agent_result.output.clone(),
|
||||
success: agent_result.success,
|
||||
cost_cents: agent_result.cost_cents,
|
||||
model: agent_result.model_used,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = events_tx.send(AgentEvent::Error { message: format!("Control session task join failed: {}", e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start next queued message, if any.
|
||||
if let Some((mid, msg)) = queue.pop_front() {
|
||||
set_and_emit_status(&status, &events_tx, ControlRunState::Running, queue.len()).await;
|
||||
let _ = events_tx.send(AgentEvent::UserMessage { id: mid, content: msg.clone() });
|
||||
let cfg = config.clone();
|
||||
let agent = Arc::clone(&root_agent);
|
||||
let mem = memory.clone();
|
||||
let events = events_tx.clone();
|
||||
let tools_hub = Arc::clone(&tool_hub);
|
||||
let status_ref = Arc::clone(&status);
|
||||
let cancel = CancellationToken::new();
|
||||
let pricing = Arc::clone(&pricing);
|
||||
let hist_snapshot = history.clone();
|
||||
running_cancel = Some(cancel.clone());
|
||||
running = Some(tokio::spawn(async move {
|
||||
let result = run_single_control_turn(
|
||||
cfg,
|
||||
agent,
|
||||
mem,
|
||||
pricing,
|
||||
events,
|
||||
tools_hub,
|
||||
status_ref,
|
||||
cancel,
|
||||
hist_snapshot,
|
||||
msg.clone(),
|
||||
)
|
||||
.await;
|
||||
(mid, msg, result)
|
||||
}));
|
||||
} else {
|
||||
set_and_emit_status(&status, &events_tx, ControlRunState::Idle, 0).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_single_control_turn(
|
||||
config: Config,
|
||||
root_agent: AgentRef,
|
||||
memory: Option<MemorySystem>,
|
||||
pricing: Arc<ModelPricing>,
|
||||
events_tx: broadcast::Sender<AgentEvent>,
|
||||
tool_hub: Arc<FrontendToolHub>,
|
||||
status: Arc<RwLock<ControlStatus>>,
|
||||
cancel: CancellationToken,
|
||||
history: Vec<(String, String)>,
|
||||
user_message: String,
|
||||
) -> crate::agents::AgentResult {
|
||||
// Build a task prompt that includes lightweight conversation context.
|
||||
let mut convo = String::new();
|
||||
if !history.is_empty() {
|
||||
convo.push_str("Conversation so far:\n");
|
||||
for (role, content) in &history {
|
||||
convo.push_str(&format!("{}: {}\n", role, content));
|
||||
}
|
||||
convo.push('\n');
|
||||
}
|
||||
convo.push_str("User:\n");
|
||||
convo.push_str(&user_message);
|
||||
convo.push_str("\n\nInstructions:\n- Continue the conversation helpfully.\n- You may use tools to gather information or make changes.\n- When appropriate, use Tool UI tools (ui_*) for structured output or to ask for user selections.\n");
|
||||
|
||||
let budget = Budget::new(1000);
|
||||
let verification = VerificationCriteria::None;
|
||||
let mut task = match crate::task::Task::new(convo, verification, budget) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let r = crate::agents::AgentResult::failure(format!("Failed to create task: {}", e), 0);
|
||||
return r;
|
||||
}
|
||||
};
|
||||
|
||||
// Context for agent execution.
|
||||
let llm = Arc::new(OpenRouterClient::new(config.api_key.clone()));
|
||||
let tools = ToolRegistry::new();
|
||||
let mut ctx = AgentContext::with_memory(
|
||||
config.clone(),
|
||||
llm,
|
||||
tools,
|
||||
pricing,
|
||||
config.workspace_path.clone(),
|
||||
memory,
|
||||
);
|
||||
ctx.control_events = Some(events_tx);
|
||||
ctx.frontend_tool_hub = Some(tool_hub);
|
||||
ctx.control_status = Some(status);
|
||||
ctx.cancel_token = Some(cancel);
|
||||
|
||||
let result = root_agent.execute(&mut task, &ctx).await;
|
||||
result
|
||||
}
|
||||
|
||||
@@ -12,13 +12,12 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use super::routes::AppState;
|
||||
use super::ssh_util::{materialize_private_key, sftp_batch, ssh_exec};
|
||||
use super::ssh_util::{materialize_private_key, sftp_batch, ssh_exec, ssh_exec_with_stdin};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PathQuery {
|
||||
@@ -45,9 +44,7 @@ pub struct FsEntry {
|
||||
pub mtime: i64,
|
||||
}
|
||||
|
||||
fn python_list_script_b64() -> String {
|
||||
// Reads sys.argv[1] as path; prints JSON list to stdout.
|
||||
let script = r#"
|
||||
const LIST_SCRIPT: &str = r#"
|
||||
import os, sys, json, stat
|
||||
|
||||
path = sys.argv[1]
|
||||
@@ -80,8 +77,6 @@ except FileNotFoundError:
|
||||
|
||||
print(json.dumps(out))
|
||||
"#;
|
||||
base64::engine::general_purpose::STANDARD.encode(script.as_bytes())
|
||||
}
|
||||
|
||||
async fn get_key_and_cfg(state: &Arc<AppState>) -> Result<(crate::config::ConsoleSshConfig, super::ssh_util::TempKeyFile), (StatusCode, String)> {
|
||||
let cfg = state.config.console_ssh.clone();
|
||||
@@ -101,13 +96,13 @@ pub async fn list(
|
||||
) -> Result<Json<Vec<FsEntry>>, (StatusCode, String)> {
|
||||
let (cfg, key_file) = get_key_and_cfg(&state).await?;
|
||||
|
||||
let b64 = python_list_script_b64();
|
||||
let code = format!("import base64,sys; exec(base64.b64decode('{}'))", b64);
|
||||
let out = ssh_exec(
|
||||
// Avoid ssh quoting issues by piping the script on stdin.
|
||||
let out = ssh_exec_with_stdin(
|
||||
&cfg,
|
||||
key_file.path(),
|
||||
"python3",
|
||||
&vec!["-c".into(), code, q.path.clone()],
|
||||
&vec!["-".into(), q.path.clone()],
|
||||
LIST_SCRIPT,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
mod routes;
|
||||
mod auth;
|
||||
mod console;
|
||||
pub mod control;
|
||||
mod fs;
|
||||
mod ssh_util;
|
||||
pub mod types;
|
||||
|
||||
@@ -32,6 +32,7 @@ use crate::tools::ToolRegistry;
|
||||
use super::types::*;
|
||||
use super::auth;
|
||||
use super::console;
|
||||
use super::control;
|
||||
use super::fs;
|
||||
|
||||
/// Shared application state.
|
||||
@@ -42,6 +43,8 @@ pub struct AppState {
|
||||
pub root_agent: AgentRef,
|
||||
/// Memory system (optional)
|
||||
pub memory: Option<MemorySystem>,
|
||||
/// Global interactive control session
|
||||
pub control: control::ControlState,
|
||||
}
|
||||
|
||||
/// Start the HTTP server.
|
||||
@@ -54,12 +57,20 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
|
||||
|
||||
// Initialize memory system (optional - needs Supabase config)
|
||||
let memory = memory::init_memory(&config.memory, &config.api_key).await;
|
||||
|
||||
// Spawn the single global control session actor.
|
||||
let control_state = control::spawn_control_session(
|
||||
config.clone(),
|
||||
Arc::clone(&root_agent),
|
||||
memory.clone(),
|
||||
);
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
config: config.clone(),
|
||||
tasks: RwLock::new(HashMap::new()),
|
||||
root_agent,
|
||||
memory,
|
||||
control: control_state,
|
||||
});
|
||||
|
||||
let public_routes = Router::new()
|
||||
@@ -75,6 +86,11 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
|
||||
.route("/api/task/:id/stop", post(stop_task))
|
||||
.route("/api/task/:id/stream", get(stream_task))
|
||||
.route("/api/tasks", get(list_tasks))
|
||||
// Global control session endpoints
|
||||
.route("/api/control/message", post(control::post_message))
|
||||
.route("/api/control/tool_result", post(control::post_tool_result))
|
||||
.route("/api/control/stream", get(control::stream))
|
||||
.route("/api/control/cancel", post(control::post_cancel))
|
||||
// Memory endpoints
|
||||
.route("/api/runs", get(list_runs))
|
||||
.route("/api/runs/:id", get(get_run))
|
||||
|
||||
@@ -52,6 +52,8 @@ fn ssh_base_args(cfg: &ConsoleSshConfig, key_path: &Path) -> Vec<String> {
|
||||
"-o".to_string(),
|
||||
"BatchMode=yes".to_string(),
|
||||
"-o".to_string(),
|
||||
"LogLevel=ERROR".to_string(),
|
||||
"-o".to_string(),
|
||||
"StrictHostKeyChecking=accept-new".to_string(),
|
||||
"-o".to_string(),
|
||||
// Keep known_hosts separate from system to avoid permission issues.
|
||||
@@ -88,12 +90,53 @@ pub async fn ssh_exec(cfg: &ConsoleSshConfig, key_path: &Path, remote_cmd: &str,
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
/// Execute a remote command and feed `stdin_data` to its stdin (useful to avoid shell quoting issues).
|
||||
pub async fn ssh_exec_with_stdin(
|
||||
cfg: &ConsoleSshConfig,
|
||||
key_path: &Path,
|
||||
remote_cmd: &str,
|
||||
args: &[String],
|
||||
stdin_data: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let mut cmd = Command::new("ssh");
|
||||
for a in ssh_base_args(cfg, key_path) {
|
||||
cmd.arg(a);
|
||||
}
|
||||
|
||||
cmd.arg(format!("{}@{}", cfg.user, cfg.host));
|
||||
cmd.arg("--");
|
||||
cmd.arg(remote_cmd);
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
|
||||
cmd.stdin(std::process::Stdio::piped());
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(stdin_data.as_bytes()).await?;
|
||||
}
|
||||
|
||||
let out = tokio::time::timeout(Duration::from_secs(30), child.wait_with_output()).await??;
|
||||
if !out.status.success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"ssh failed (code {:?}): {}",
|
||||
out.status.code(),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).to_string())
|
||||
}
|
||||
|
||||
pub async fn sftp_batch(cfg: &ConsoleSshConfig, key_path: &Path, batch: &str) -> anyhow::Result<()> {
|
||||
let mut cmd = Command::new("sftp");
|
||||
cmd.arg("-b").arg("-");
|
||||
cmd.arg("-i").arg(key_path);
|
||||
cmd.arg("-P").arg(cfg.port.to_string());
|
||||
cmd.arg("-o").arg("BatchMode=yes");
|
||||
cmd.arg("-o").arg("LogLevel=ERROR");
|
||||
cmd.arg("-o").arg("StrictHostKeyChecking=accept-new");
|
||||
cmd.arg("-o").arg(format!(
|
||||
"UserKnownHostsFile={}",
|
||||
|
||||
@@ -255,6 +255,22 @@ impl ExecutionSignals {
|
||||
|| output_lower.contains("connection refused")
|
||||
|| output_lower.contains("timeout")
|
||||
|| output_lower.contains("rate limit")
|
||||
|| output_lower.contains("429")
|
||||
|| output_lower.contains("too many requests")
|
||||
|| output_lower.contains("rate limited")
|
||||
|| output_lower.contains("server error")
|
||||
|| output_lower.contains("502")
|
||||
|| output_lower.contains("503")
|
||||
|| output_lower.contains("504")
|
||||
}
|
||||
|
||||
/// Check if this is specifically a rate limit error.
|
||||
pub fn is_rate_limit_error(&self) -> bool {
|
||||
let output_lower = self.final_output.to_lowercase();
|
||||
output_lower.contains("rate limit")
|
||||
|| output_lower.contains("429")
|
||||
|| output_lower.contains("too many requests")
|
||||
|| output_lower.contains("rate limited")
|
||||
}
|
||||
|
||||
/// Generate a retry recommendation based on analysis.
|
||||
@@ -308,10 +324,20 @@ impl ExecutionSignals {
|
||||
}
|
||||
|
||||
FailureMode::ExternalError => {
|
||||
// External issue, retry with same setup
|
||||
RetryRecommendation::ContinueSameModel {
|
||||
additional_budget_cents: self.cost_spent_cents / 2, // Less budget needed for retry
|
||||
reason: "External error occurred. Retry with same configuration.".to_string(),
|
||||
// Check if it's a rate limit error
|
||||
if self.is_rate_limit_error() {
|
||||
// Rate limit - try a different model to avoid hitting the same limit
|
||||
RetryRecommendation::TryCheaperModel {
|
||||
suggested_model: self.suggest_alternative_model(),
|
||||
additional_budget_cents: self.cost_spent_cents,
|
||||
reason: "Rate limited by provider. Trying alternative model to avoid same limit.".to_string(),
|
||||
}
|
||||
} else {
|
||||
// Other external issue, retry with same setup
|
||||
RetryRecommendation::ContinueSameModel {
|
||||
additional_budget_cents: self.cost_spent_cents / 2, // Less budget needed for retry
|
||||
reason: "External error occurred. Retry with same configuration.".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +414,33 @@ impl ExecutionSignals {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Suggest an alternative model from a different provider (useful for rate limits).
|
||||
fn suggest_alternative_model(&self) -> Option<String> {
|
||||
// When rate limited, try a model from a different provider
|
||||
match self.model_used.as_str() {
|
||||
// Anthropic -> OpenAI
|
||||
m if m.starts_with("anthropic/") => {
|
||||
Some("openai/gpt-4o-mini".to_string())
|
||||
}
|
||||
// OpenAI -> Google
|
||||
m if m.starts_with("openai/") => {
|
||||
Some("google/gemini-2.0-flash-001".to_string())
|
||||
}
|
||||
// Google -> Anthropic
|
||||
m if m.starts_with("google/") => {
|
||||
Some("anthropic/claude-haiku-4.5".to_string())
|
||||
}
|
||||
// Mistral -> Anthropic (particularly for free tier rate limits)
|
||||
m if m.starts_with("mistralai/") || m.contains("mistral") => {
|
||||
Some("anthropic/claude-haiku-4.5".to_string())
|
||||
}
|
||||
// Default: try Anthropic
|
||||
_ => {
|
||||
Some("anthropic/claude-haiku-4.5".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for retry behavior.
|
||||
|
||||
@@ -221,13 +221,21 @@ impl Config {
|
||||
}
|
||||
|
||||
// Memory configuration (optional)
|
||||
let embed_model = std::env::var("MEMORY_EMBED_MODEL")
|
||||
.unwrap_or_else(|_| "openai/text-embedding-3-small".to_string());
|
||||
|
||||
// Determine embed dimension from env or infer from model
|
||||
let embed_dimension = std::env::var("MEMORY_EMBED_DIMENSION")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or_else(|| infer_embed_dimension(&embed_model));
|
||||
|
||||
let memory = MemoryConfig {
|
||||
supabase_url: std::env::var("SUPABASE_URL").ok(),
|
||||
supabase_service_role_key: std::env::var("SUPABASE_SERVICE_ROLE_KEY").ok(),
|
||||
embed_model: std::env::var("MEMORY_EMBED_MODEL")
|
||||
.unwrap_or_else(|_| "openai/text-embedding-3-small".to_string()),
|
||||
embed_model,
|
||||
rerank_model: std::env::var("MEMORY_RERANK_MODEL").ok(),
|
||||
embed_dimension: 1536, // OpenAI text-embedding-3-small default
|
||||
embed_dimension,
|
||||
};
|
||||
|
||||
let console_ssh = ConsoleSshConfig {
|
||||
@@ -284,6 +292,37 @@ fn parse_bool(value: &str) -> Result<bool, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Infer embedding dimension from model name.
|
||||
fn infer_embed_dimension(model: &str) -> usize {
|
||||
let model_lower = model.to_lowercase();
|
||||
|
||||
// Qwen embedding models output 4096 dimensions
|
||||
if model_lower.contains("qwen") && model_lower.contains("embedding") {
|
||||
return 4096;
|
||||
}
|
||||
|
||||
// OpenAI text-embedding-3 models
|
||||
if model_lower.contains("text-embedding-3") {
|
||||
if model_lower.contains("large") {
|
||||
return 3072;
|
||||
}
|
||||
return 1536; // small
|
||||
}
|
||||
|
||||
// OpenAI ada
|
||||
if model_lower.contains("ada") {
|
||||
return 1536;
|
||||
}
|
||||
|
||||
// Cohere embed models
|
||||
if model_lower.contains("embed-english") || model_lower.contains("embed-multilingual") {
|
||||
return 1024;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
1536
|
||||
}
|
||||
|
||||
fn read_private_key_from_env() -> Result<Option<String>, ConfigError> {
|
||||
// Recommended: load from file path to avoid large/multiline env values.
|
||||
if let Ok(path) = std::env::var("CONSOLE_SSH_PRIVATE_KEY_PATH") {
|
||||
|
||||
258
src/llm/error.rs
Normal file
258
src/llm/error.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
//! LLM error types with retry classification.
|
||||
//!
|
||||
//! Distinguishes between transient errors (should retry) and permanent errors (should not retry).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Error from LLM API calls.
|
||||
#[derive(Debug)]
|
||||
pub struct LlmError {
|
||||
/// The kind of error
|
||||
pub kind: LlmErrorKind,
|
||||
/// HTTP status code, if applicable
|
||||
pub status_code: Option<u16>,
|
||||
/// Error message
|
||||
pub message: String,
|
||||
/// Suggested retry delay (from Retry-After header or calculated)
|
||||
pub retry_after: Option<Duration>,
|
||||
}
|
||||
|
||||
impl LlmError {
|
||||
/// Create a rate limit error.
|
||||
pub fn rate_limited(message: String, retry_after: Option<Duration>) -> Self {
|
||||
Self {
|
||||
kind: LlmErrorKind::RateLimited,
|
||||
status_code: Some(429),
|
||||
message,
|
||||
retry_after,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a server error.
|
||||
pub fn server_error(status_code: u16, message: String) -> Self {
|
||||
Self {
|
||||
kind: LlmErrorKind::ServerError,
|
||||
status_code: Some(status_code),
|
||||
message,
|
||||
retry_after: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a client error (bad request, auth, etc.).
|
||||
pub fn client_error(status_code: u16, message: String) -> Self {
|
||||
Self {
|
||||
kind: LlmErrorKind::ClientError,
|
||||
status_code: Some(status_code),
|
||||
message,
|
||||
retry_after: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a network error.
|
||||
pub fn network_error(message: String) -> Self {
|
||||
Self {
|
||||
kind: LlmErrorKind::NetworkError,
|
||||
status_code: None,
|
||||
message,
|
||||
retry_after: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a parse error.
|
||||
pub fn parse_error(message: String) -> Self {
|
||||
Self {
|
||||
kind: LlmErrorKind::ParseError,
|
||||
status_code: None,
|
||||
message,
|
||||
retry_after: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this error is transient and should be retried.
|
||||
pub fn is_transient(&self) -> bool {
|
||||
self.kind.is_transient()
|
||||
}
|
||||
|
||||
/// Get the suggested delay before retry.
|
||||
///
|
||||
/// Returns the `retry_after` if set, otherwise returns a default based on error kind.
|
||||
pub fn suggested_delay(&self, attempt: u32) -> Duration {
|
||||
if let Some(retry_after) = self.retry_after {
|
||||
return retry_after;
|
||||
}
|
||||
|
||||
// Exponential backoff with jitter
|
||||
let base_delay = match self.kind {
|
||||
LlmErrorKind::RateLimited => Duration::from_secs(5), // Start higher for rate limits
|
||||
LlmErrorKind::ServerError => Duration::from_secs(2),
|
||||
LlmErrorKind::NetworkError => Duration::from_secs(1),
|
||||
_ => Duration::from_secs(1),
|
||||
};
|
||||
|
||||
// Exponential backoff: base * 2^attempt, capped at 60 seconds
|
||||
let multiplier = 2u64.saturating_pow(attempt);
|
||||
let delay_secs = base_delay.as_secs().saturating_mul(multiplier).min(60);
|
||||
|
||||
// Add jitter (up to 25% of delay)
|
||||
let jitter_range = delay_secs / 4;
|
||||
let jitter = if jitter_range > 0 {
|
||||
// Simple deterministic jitter based on attempt number
|
||||
(attempt as u64 * 7) % jitter_range
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Duration::from_secs(delay_secs + jitter)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LlmError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self.status_code {
|
||||
Some(code) => write!(f, "{} (HTTP {}): {}", self.kind, code, self.message),
|
||||
None => write!(f, "{}: {}", self.kind, self.message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LlmError {}
|
||||
|
||||
/// Classification of LLM errors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum LlmErrorKind {
|
||||
/// Rate limited (429) - transient, should retry with backoff
|
||||
RateLimited,
|
||||
/// Server error (500, 502, 503, 504) - transient, should retry
|
||||
ServerError,
|
||||
/// Client error (400, 401, 403, 404) - permanent, should not retry
|
||||
ClientError,
|
||||
/// Network error (connection failed, timeout) - transient, should retry
|
||||
NetworkError,
|
||||
/// Response parsing error - usually permanent
|
||||
ParseError,
|
||||
}
|
||||
|
||||
impl LlmErrorKind {
|
||||
/// Check if this error kind is transient (should retry).
|
||||
pub fn is_transient(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
LlmErrorKind::RateLimited | LlmErrorKind::ServerError | LlmErrorKind::NetworkError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LlmErrorKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
LlmErrorKind::RateLimited => write!(f, "Rate limited"),
|
||||
LlmErrorKind::ServerError => write!(f, "Server error"),
|
||||
LlmErrorKind::ClientError => write!(f, "Client error"),
|
||||
LlmErrorKind::NetworkError => write!(f, "Network error"),
|
||||
LlmErrorKind::ParseError => write!(f, "Parse error"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for retry behavior.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryConfig {
|
||||
/// Maximum number of retry attempts
|
||||
pub max_retries: u32,
|
||||
/// Maximum total time to spend retrying
|
||||
pub max_retry_duration: Duration,
|
||||
/// Whether to retry on rate limit errors
|
||||
pub retry_rate_limits: bool,
|
||||
/// Whether to retry on server errors
|
||||
pub retry_server_errors: bool,
|
||||
/// Whether to retry on network errors
|
||||
pub retry_network_errors: bool,
|
||||
}
|
||||
|
||||
impl Default for RetryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_retries: 3,
|
||||
max_retry_duration: Duration::from_secs(120), // 2 minutes max
|
||||
retry_rate_limits: true,
|
||||
retry_server_errors: true,
|
||||
retry_network_errors: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RetryConfig {
|
||||
/// Check if the given error should be retried based on this config.
|
||||
pub fn should_retry(&self, error: &LlmError) -> bool {
|
||||
match error.kind {
|
||||
LlmErrorKind::RateLimited => self.retry_rate_limits,
|
||||
LlmErrorKind::ServerError => self.retry_server_errors,
|
||||
LlmErrorKind::NetworkError => self.retry_network_errors,
|
||||
LlmErrorKind::ClientError | LlmErrorKind::ParseError => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse HTTP status code into error kind.
|
||||
pub fn classify_http_status(status: u16) -> LlmErrorKind {
|
||||
match status {
|
||||
429 => LlmErrorKind::RateLimited,
|
||||
500 | 502 | 503 | 504 => LlmErrorKind::ServerError,
|
||||
400..=499 => LlmErrorKind::ClientError,
|
||||
_ => LlmErrorKind::ServerError,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_transient_classification() {
|
||||
assert!(LlmErrorKind::RateLimited.is_transient());
|
||||
assert!(LlmErrorKind::ServerError.is_transient());
|
||||
assert!(LlmErrorKind::NetworkError.is_transient());
|
||||
assert!(!LlmErrorKind::ClientError.is_transient());
|
||||
assert!(!LlmErrorKind::ParseError.is_transient());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_http_status_classification() {
|
||||
assert_eq!(classify_http_status(429), LlmErrorKind::RateLimited);
|
||||
assert_eq!(classify_http_status(500), LlmErrorKind::ServerError);
|
||||
assert_eq!(classify_http_status(502), LlmErrorKind::ServerError);
|
||||
assert_eq!(classify_http_status(503), LlmErrorKind::ServerError);
|
||||
assert_eq!(classify_http_status(400), LlmErrorKind::ClientError);
|
||||
assert_eq!(classify_http_status(401), LlmErrorKind::ClientError);
|
||||
assert_eq!(classify_http_status(403), LlmErrorKind::ClientError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_exponential_backoff() {
|
||||
let error = LlmError::rate_limited("test".to_string(), None);
|
||||
|
||||
let delay_0 = error.suggested_delay(0);
|
||||
let delay_1 = error.suggested_delay(1);
|
||||
let delay_2 = error.suggested_delay(2);
|
||||
|
||||
// Should increase exponentially
|
||||
assert!(delay_1 > delay_0);
|
||||
assert!(delay_2 > delay_1);
|
||||
|
||||
// Should be capped
|
||||
let delay_10 = error.suggested_delay(10);
|
||||
assert!(delay_10.as_secs() <= 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retry_after_respected() {
|
||||
let error = LlmError::rate_limited(
|
||||
"test".to_string(),
|
||||
Some(Duration::from_secs(30)),
|
||||
);
|
||||
|
||||
// Should use the provided retry_after, not calculate
|
||||
assert_eq!(error.suggested_delay(0), Duration::from_secs(30));
|
||||
assert_eq!(error.suggested_delay(5), Duration::from_secs(30));
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
//! This module provides a trait-based abstraction over LLM providers,
|
||||
//! with OpenRouter as the primary implementation.
|
||||
|
||||
mod error;
|
||||
mod openrouter;
|
||||
|
||||
pub use error::{LlmError, LlmErrorKind, RetryConfig, classify_http_status};
|
||||
pub use openrouter::OpenRouterClient;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -1,25 +1,206 @@
|
||||
//! OpenRouter API client implementation.
|
||||
//! OpenRouter API client implementation with automatic retry for transient errors.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::error::{classify_http_status, LlmError, LlmErrorKind, RetryConfig};
|
||||
use super::{ChatMessage, ChatOptions, ChatResponse, LlmClient, TokenUsage, ToolCall, ToolDefinition};
|
||||
|
||||
const OPENROUTER_API_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
|
||||
|
||||
/// OpenRouter API client.
|
||||
/// OpenRouter API client with automatic retry for transient errors.
|
||||
pub struct OpenRouterClient {
|
||||
client: Client,
|
||||
api_key: String,
|
||||
retry_config: RetryConfig,
|
||||
}
|
||||
|
||||
impl OpenRouterClient {
|
||||
/// Create a new OpenRouter client.
|
||||
/// Create a new OpenRouter client with default retry configuration.
|
||||
pub fn new(api_key: String) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_key,
|
||||
retry_config: RetryConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new OpenRouter client with custom retry configuration.
|
||||
pub fn with_retry_config(api_key: String, retry_config: RetryConfig) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_key,
|
||||
retry_config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse Retry-After header if present.
|
||||
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
|
||||
headers
|
||||
.get("retry-after")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| {
|
||||
// Try parsing as seconds first
|
||||
s.parse::<u64>().ok().map(Duration::from_secs)
|
||||
})
|
||||
}
|
||||
|
||||
/// Create an LlmError from HTTP response status and body.
|
||||
fn create_error(
|
||||
status: reqwest::StatusCode,
|
||||
body: &str,
|
||||
retry_after: Option<Duration>,
|
||||
) -> LlmError {
|
||||
let status_code = status.as_u16();
|
||||
let kind = classify_http_status(status_code);
|
||||
|
||||
match kind {
|
||||
LlmErrorKind::RateLimited => LlmError::rate_limited(body.to_string(), retry_after),
|
||||
LlmErrorKind::ServerError => LlmError::server_error(status_code, body.to_string()),
|
||||
LlmErrorKind::ClientError => LlmError::client_error(status_code, body.to_string()),
|
||||
_ => LlmError::server_error(status_code, body.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a single request without retry.
|
||||
async fn execute_request(
|
||||
&self,
|
||||
request: &OpenRouterRequest,
|
||||
) -> Result<ChatResponse, LlmError> {
|
||||
let response = match self
|
||||
.client
|
||||
.post(OPENROUTER_API_URL)
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("HTTP-Referer", "https://github.com/open-agent")
|
||||
.header("X-Title", "Open Agent")
|
||||
.json(request)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// Network or connection error
|
||||
if e.is_timeout() {
|
||||
return Err(LlmError::network_error(format!("Request timeout: {}", e)));
|
||||
} else if e.is_connect() {
|
||||
return Err(LlmError::network_error(format!("Connection failed: {}", e)));
|
||||
} else {
|
||||
return Err(LlmError::network_error(format!("Request failed: {}", e)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let status = response.status();
|
||||
let retry_after = Self::parse_retry_after(response.headers());
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(Self::create_error(status, &body, retry_after));
|
||||
}
|
||||
|
||||
let parsed: OpenRouterResponse = serde_json::from_str(&body).map_err(|e| {
|
||||
LlmError::parse_error(format!("Failed to parse response: {}, body: {}", e, body))
|
||||
})?;
|
||||
|
||||
let choice = parsed
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| LlmError::parse_error("No choices in response".to_string()))?;
|
||||
|
||||
Ok(ChatResponse {
|
||||
content: choice.message.content,
|
||||
tool_calls: choice.message.tool_calls,
|
||||
finish_reason: choice.finish_reason,
|
||||
usage: parsed
|
||||
.usage
|
||||
.map(|u| TokenUsage::new(u.prompt_tokens, u.completion_tokens)),
|
||||
model: parsed.model.or_else(|| Some(request.model.clone())),
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a request with automatic retry for transient errors.
|
||||
async fn execute_with_retry(
|
||||
&self,
|
||||
request: &OpenRouterRequest,
|
||||
) -> anyhow::Result<ChatResponse> {
|
||||
let start = Instant::now();
|
||||
let mut attempt = 0;
|
||||
let mut last_error: Option<LlmError> = None;
|
||||
|
||||
loop {
|
||||
// Check if we've exceeded max retry duration
|
||||
if start.elapsed() > self.retry_config.max_retry_duration {
|
||||
let err = last_error.unwrap_or_else(|| {
|
||||
LlmError::network_error("Max retry duration exceeded".to_string())
|
||||
});
|
||||
return Err(anyhow::anyhow!("{}", err));
|
||||
}
|
||||
|
||||
match self.execute_request(request).await {
|
||||
Ok(response) => {
|
||||
if attempt > 0 {
|
||||
tracing::info!(
|
||||
"Request succeeded after {} retries (total time: {:?})",
|
||||
attempt,
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
return Ok(response);
|
||||
}
|
||||
Err(error) => {
|
||||
let should_retry =
|
||||
self.retry_config.should_retry(&error) && attempt < self.retry_config.max_retries;
|
||||
|
||||
if should_retry {
|
||||
let delay = error.suggested_delay(attempt);
|
||||
|
||||
// Make sure we won't exceed max retry duration
|
||||
let remaining = self
|
||||
.retry_config
|
||||
.max_retry_duration
|
||||
.saturating_sub(start.elapsed());
|
||||
let actual_delay = delay.min(remaining);
|
||||
|
||||
if actual_delay.is_zero() {
|
||||
tracing::warn!(
|
||||
"Retry attempt {} failed, no time remaining: {}",
|
||||
attempt + 1,
|
||||
error
|
||||
);
|
||||
return Err(anyhow::anyhow!("{}", error));
|
||||
}
|
||||
|
||||
tracing::warn!(
|
||||
"Retry attempt {} failed with {}, retrying in {:?}: {}",
|
||||
attempt + 1,
|
||||
error.kind,
|
||||
actual_delay,
|
||||
error.message
|
||||
);
|
||||
|
||||
tokio::time::sleep(actual_delay).await;
|
||||
attempt += 1;
|
||||
last_error = Some(error);
|
||||
} else {
|
||||
// Non-retryable error or max retries exceeded
|
||||
if attempt > 0 {
|
||||
tracing::error!(
|
||||
"Request failed after {} retries (total time: {:?}): {}",
|
||||
attempt,
|
||||
start.elapsed(),
|
||||
error
|
||||
);
|
||||
} else {
|
||||
tracing::error!("Request failed (non-retryable): {}", error);
|
||||
}
|
||||
return Err(anyhow::anyhow!("{}", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,47 +236,7 @@ impl LlmClient for OpenRouterClient {
|
||||
|
||||
tracing::debug!("Sending request to OpenRouter: model={}", model);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(OPENROUTER_API_URL)
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("HTTP-Referer", "https://github.com/open-agent")
|
||||
.header("X-Title", "Open Agent")
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await?;
|
||||
|
||||
if !status.is_success() {
|
||||
tracing::error!("OpenRouter error: status={}, body={}", status, body);
|
||||
return Err(anyhow::anyhow!(
|
||||
"OpenRouter API error: {} - {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
|
||||
let response: OpenRouterResponse = serde_json::from_str(&body).map_err(|e| {
|
||||
tracing::error!("Failed to parse response: {}, body: {}", e, body);
|
||||
anyhow::anyhow!("Failed to parse OpenRouter response: {}", e)
|
||||
})?;
|
||||
|
||||
let choice = response
|
||||
.choices
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("No choices in response"))?;
|
||||
|
||||
Ok(ChatResponse {
|
||||
content: choice.message.content,
|
||||
tool_calls: choice.message.tool_calls,
|
||||
finish_reason: choice.finish_reason,
|
||||
usage: response.usage.map(|u| TokenUsage::new(u.prompt_tokens, u.completion_tokens)),
|
||||
model: response.model.or_else(|| Some(model.to_string())),
|
||||
})
|
||||
self.execute_with_retry(&request).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ mod file_ops;
|
||||
mod git;
|
||||
mod search;
|
||||
mod terminal;
|
||||
mod ui;
|
||||
mod web;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -77,6 +78,10 @@ impl ToolRegistry {
|
||||
tools.insert("git_commit".to_string(), Arc::new(git::GitCommit));
|
||||
tools.insert("git_log".to_string(), Arc::new(git::GitLog));
|
||||
|
||||
// Frontend Tool UI (schemas for rich rendering in the dashboard)
|
||||
tools.insert("ui_optionList".to_string(), Arc::new(ui::UiOptionList));
|
||||
tools.insert("ui_dataTable".to_string(), Arc::new(ui::UiDataTable));
|
||||
|
||||
Self { tools }
|
||||
}
|
||||
|
||||
|
||||
94
src/tools/ui.rs
Normal file
94
src/tools/ui.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
//! Frontend (Tool UI) tools.
|
||||
//!
|
||||
//! These tools are intended to be rendered in the dashboard UI rather than executed
|
||||
//! as real side-effecting operations. They exist mainly to provide tool schemas to
|
||||
//! the LLM so it can request structured UI renderings.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::Path;
|
||||
|
||||
use super::Tool;
|
||||
|
||||
/// Ask the user to pick from a list of options (interactive).
|
||||
pub struct UiOptionList;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for UiOptionList {
|
||||
fn name(&self) -> &str {
|
||||
"ui_optionList"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Render an interactive option list for the user to choose from (frontend Tool UI)."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
// Intentionally permissive: we validate on the frontend before rendering.
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["id", "title", "options"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Stable identifier for this UI element." },
|
||||
"title": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"multiple": { "type": "boolean", "default": false },
|
||||
"confirmLabel": { "type": "string" },
|
||||
"options": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "label"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"label": { "type": "string" },
|
||||
"description": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, _workspace: &Path) -> anyhow::Result<String> {
|
||||
// The agent runtime intercepts ui_* tools and routes them to the frontend.
|
||||
// This is a safe fallback for non-control-session executions.
|
||||
Ok(serde_json::to_string(&args).unwrap_or_else(|_| args.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a read-only data table (non-interactive).
|
||||
pub struct UiDataTable;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for UiDataTable {
|
||||
fn name(&self) -> &str {
|
||||
"ui_dataTable"
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Render a data table with rows/columns (frontend Tool UI)."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["id", "columns", "rows"],
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"columns": { "type": "array", "items": { "type": "object" }, "minItems": 1 },
|
||||
"rows": { "type": "array", "items": { "type": "object" } }
|
||||
},
|
||||
"additionalProperties": true
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, args: Value, _workspace: &Path) -> anyhow::Result<String> {
|
||||
Ok(serde_json::to_string(&args).unwrap_or_else(|_| args.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user