Add agent selection and artifact management to RowboatX UI

- Implemented agent selection dropdown in the input area.
- Enhanced artifact management with loading, saving, and error handling.
- Added new API routes for fetching agent summaries and run details.
- Updated sidebar to display agents, configurations, and runs dynamically.
- Introduced theme selection options in the user navigation menu.
This commit is contained in:
tusharmagar 2025-12-15 10:01:48 +05:30 committed by Ramnique Singh
parent b1f6e64244
commit 023a65de45
8 changed files with 965 additions and 251 deletions

View file

@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { message, runId } = body;
const { message, runId, agentId } = body;
if (!message || typeof message !== 'string') {
return Response.json(
@ -25,7 +25,7 @@ export async function POST(request: NextRequest) {
// Create new run if no runId provided
if (!currentRunId) {
const run = await cliClient.createRun({
agentId: 'copilot',
agentId: agentId || 'copilot',
});
currentRunId = run.id;
}

View file

@ -0,0 +1,71 @@
import { NextRequest } from "next/server";
const BACKEND = process.env.CLI_BACKEND_URL || "http://localhost:3000";
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
async function forward(req: NextRequest, method: string, segments?: string[]) {
const search = req.nextUrl.search || "";
const targetPath = (segments || []).join("/");
const target = `${BACKEND}/${targetPath}${search}`;
const init: RequestInit = {
method,
headers: {
"Content-Type": req.headers.get("content-type") || "application/json",
},
};
if (method !== "GET" && method !== "HEAD") {
init.body = await req.text();
}
const res = await fetch(target, init);
const body = await res.text();
return new Response(body, {
status: res.status,
headers: {
"content-type": res.headers.get("content-type") || "application/json",
...CORS_HEADERS,
},
});
}
export async function GET(
req: NextRequest,
context: { params: Promise<{ path?: string[] }> }
) {
const { path } = await context.params;
return forward(req, "GET", path);
}
export async function POST(
req: NextRequest,
context: { params: Promise<{ path?: string[] }> }
) {
const { path } = await context.params;
return forward(req, "POST", path);
}
export async function PUT(
req: NextRequest,
context: { params: Promise<{ path?: string[] }> }
) {
const { path } = await context.params;
return forward(req, "PUT", path);
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ path?: string[] }> }
) {
const { path } = await context.params;
return forward(req, "DELETE", path);
}
export async function OPTIONS() {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}

View file

@ -0,0 +1,34 @@
import { NextRequest } from "next/server";
import os from "os";
import path from "path";
import { promises as fs } from "fs";
const ROWBOAT_ROOT = path.join(os.homedir(), ".rowboat", "runs");
export async function GET(req: NextRequest) {
const fileParam = req.nextUrl.searchParams.get("file");
if (!fileParam) {
return Response.json({ error: "file param required" }, { status: 400 });
}
// Prevent path traversal: only allow basenames.
const safeName = path.basename(fileParam);
const target = path.join(ROWBOAT_ROOT, safeName);
try {
const content = await fs.readFile(target, "utf8");
let parsed: any = null;
try {
parsed = JSON.parse(content);
} catch {
parsed = null;
}
return Response.json({ file: safeName, parsed, raw: content });
} catch (error: any) {
console.error("Failed to read run file", error);
return Response.json(
{ error: "Failed to read run file" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,28 @@
import { NextRequest } from "next/server";
import path from "path";
import os from "os";
import { promises as fs } from "fs";
const ROWBOAT_ROOT = path.join(os.homedir(), ".rowboat");
async function safeList(dir: string): Promise<string[]> {
const full = path.join(ROWBOAT_ROOT, dir);
try {
const entries = await fs.readdir(full, { withFileTypes: true });
return entries.filter((e) => e.isFile()).map((e) => e.name);
} catch {
return [];
}
}
export async function GET(_req: NextRequest) {
const agents = await safeList("agents");
const config = await safeList("config");
const runs = await safeList("runs");
return Response.json({
agents,
config,
runs,
});
}

View file

@ -36,9 +36,27 @@ import { Message, MessageContent, MessageResponse } from "@/components/ai-elemen
import { Conversation, ConversationContent } from "@/components/ai-elements/conversation";
import { Tool, ToolContent, ToolHeader, ToolInput, ToolOutput } from "@/components/ai-elements/tool";
import { Reasoning, ReasoningTrigger, ReasoningContent } from "@/components/ai-elements/reasoning";
import {
Artifact,
ArtifactAction,
ArtifactActions,
ArtifactClose,
ArtifactContent,
ArtifactDescription,
ArtifactHeader,
ArtifactTitle,
} from "@/components/ai-elements/artifact";
import { useState, useEffect, useRef } from "react";
import { GlobeIcon, MicIcon } from "lucide-react";
import { MicIcon, Save, Loader2, Lock } from "lucide-react";
import { RunEvent } from "@/lib/cli-client";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface ChatMessage {
id: string;
@ -68,14 +86,20 @@ interface ReasoningBlock {
type ConversationItem = ChatMessage | ToolCall | ReasoningBlock;
export default function HomePage() {
type ResourceKind = "agent" | "config" | "run";
type SelectedResource = {
kind: ResourceKind;
name: string;
};
function PageBody() {
// Use local proxy to avoid CORS/port mismatches.
const apiBase = "/api/cli";
const [text, setText] = useState<string>("");
const [useWebSearch, setUseWebSearch] = useState<boolean>(false);
const [useMicrophone, setUseMicrophone] = useState<boolean>(false);
const [status, setStatus] = useState<
"submitted" | "streaming" | "ready" | "error"
>("ready");
const [status, setStatus] = useState<"submitted" | "streaming" | "ready" | "error">("ready");
// Chat state
const [runId, setRunId] = useState<string | null>(null);
const [conversation, setConversation] = useState<ConversationItem[]>([]);
@ -85,6 +109,72 @@ export default function HomePage() {
const committedMessageIds = useRef<Set<string>>(new Set());
const isEmptyConversation =
conversation.length === 0 && !currentAssistantMessage && !currentReasoning;
const [selectedResource, setSelectedResource] = useState<SelectedResource | null>(null);
const [artifactTitle, setArtifactTitle] = useState("");
const [artifactSubtitle, setArtifactSubtitle] = useState("");
const [artifactText, setArtifactText] = useState("");
const [artifactOriginal, setArtifactOriginal] = useState("");
const [artifactLoading, setArtifactLoading] = useState(false);
const [artifactError, setArtifactError] = useState<string | null>(null);
const [artifactReadOnly, setArtifactReadOnly] = useState(false);
const [agentOptions, setAgentOptions] = useState<string[]>(["copilot"]);
const [selectedAgent, setSelectedAgent] = useState<string>("copilot");
const artifactDirty = !artifactReadOnly && artifactText !== artifactOriginal;
const stripExtension = (name: string) => name.replace(/\.[^/.]+$/, "");
const requestJson = async (
url: string,
options?: (RequestInit & { allow404?: boolean }) | undefined
) => {
const isLocalApi = url.startsWith("/api/rowboat");
const fullUrl =
url.startsWith("http://") || url.startsWith("https://") || isLocalApi
? url
: apiBase
? `${apiBase}${url}`
: url;
const { allow404, ...rest } = options || {};
const res = await fetch(fullUrl, {
...rest,
headers: {
"Content-Type": "application/json",
...(rest.headers || {}),
},
});
const contentType = res.headers.get("content-type")?.toLowerCase() ?? "";
const isJson = contentType.includes("application/json");
const text = await res.text();
if (!res.ok) {
if (res.status === 404 && allow404) return null;
if (isJson) {
try {
const errObj = JSON.parse(text);
const errMsg =
typeof errObj === "string"
? errObj
: errObj?.message || errObj?.error || JSON.stringify(errObj);
throw new Error(errMsg || `Request failed: ${res.status} ${res.statusText}`);
} catch {
/* fall through to generic error */
}
}
if (res.status === 404) {
throw new Error("Resource not found on the CLI backend (404)");
}
throw new Error(`Request failed: ${res.status} ${res.statusText}`);
}
if (!text) return null;
if (!isJson) return null;
try {
return JSON.parse(text);
} catch {
return null;
}
};
const renderPromptInput = () => (
<PromptInput globalDrop multiple onSubmit={handleSubmit}>
@ -116,13 +206,23 @@ export default function HomePage() {
<MicIcon size={16} />
<span className="sr-only">Microphone</span>
</PromptInputButton>
<PromptInputButton
onClick={() => setUseWebSearch(!useWebSearch)}
variant={useWebSearch ? "default" : "ghost"}
<Select
value={selectedAgent}
onValueChange={(value) => setSelectedAgent(value)}
>
<GlobeIcon size={16} />
<span>Search</span>
</PromptInputButton>
<SelectTrigger className="w-32">
<SelectValue placeholder="Agent" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{agentOptions.map((agent) => (
<SelectItem key={agent} value={agent}>
{agent}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</PromptInputTools>
<PromptInputSubmit
disabled={!(text.trim() || status) || status === "streaming"}
@ -238,27 +338,37 @@ export default function HomePage() {
);
if (toolCalls.length) {
setConversation((prev) => {
const updated = [...prev];
let updated: ConversationItem[] = prev.map((item) => {
if (item.type !== 'tool') return item;
const match = toolCalls.find(
(part: any) => part.toolCallId === item.id
);
return match
? {
...item,
name: match.toolName,
input: match.arguments,
status: 'pending',
}
: item;
});
for (const part of toolCalls) {
const idx = updated.findIndex(
const exists = updated.some(
(item) => item.type === 'tool' && item.id === part.toolCallId
);
if (idx >= 0) {
updated[idx] = {
...updated[idx],
name: part.toolName,
input: part.arguments,
status: 'pending',
};
} else {
updated.push({
id: part.toolCallId,
type: 'tool',
name: part.toolName,
input: part.arguments,
status: 'pending',
timestamp: Date.now(),
});
if (!exists) {
updated = [
...updated,
{
id: part.toolCallId,
type: 'tool',
name: part.toolName,
input: part.arguments,
status: 'pending',
timestamp: Date.now(),
},
];
}
}
return updated;
@ -362,6 +472,7 @@ export default function HomePage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: userMessage,
agentId: selectedAgent,
runId: runId,
}),
});
@ -385,9 +496,178 @@ export default function HomePage() {
}
};
useEffect(() => {
if (!selectedResource) return;
let cancelled = false;
const load = async () => {
setArtifactLoading(true);
setArtifactError(null);
try {
let title = selectedResource.name;
let subtitle = "";
let text = "";
let readOnly = false;
if (selectedResource.kind === "agent") {
const raw = selectedResource.name;
const id = stripExtension(raw) || raw;
const data = await requestJson(`/agents/${encodeURIComponent(id)}`);
subtitle = "Agent";
text = JSON.stringify(data ?? {}, null, 2);
} else if (selectedResource.kind === "config") {
const lower = selectedResource.name.toLowerCase();
if (lower.includes("mcp")) {
const data = await requestJson("/mcp");
subtitle = "MCP config";
text = JSON.stringify(data ?? {}, null, 2);
} else if (lower.includes("model")) {
const data = await requestJson("/models");
subtitle = "Models config";
text = JSON.stringify(data ?? {}, null, 2);
} else {
throw new Error("Unsupported config file");
}
} else if (selectedResource.kind === "run") {
subtitle = "Run (read-only)";
readOnly = true;
const local = await requestJson(
`/api/rowboat/run?file=${encodeURIComponent(selectedResource.name)}`
);
if (local?.parsed) {
text = JSON.stringify(local.parsed, null, 2);
} else if (local?.raw) {
text = local.raw;
} else {
text = "";
}
}
if (cancelled) return;
setArtifactTitle(title);
setArtifactSubtitle(subtitle);
setArtifactText(text);
setArtifactOriginal(text);
setArtifactReadOnly(readOnly);
} catch (error: any) {
if (!cancelled) {
setArtifactError(error?.message || "Failed to load resource");
setArtifactText("");
}
} finally {
if (!cancelled) {
setArtifactLoading(false);
}
}
};
load();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedResource]);
useEffect(() => {
const loadAgents = async () => {
try {
const res = await fetch("/api/rowboat/summary");
if (!res.ok) return;
const data = await res.json();
const agents = Array.isArray(data.agents)
? data.agents.map((a: string) => stripExtension(a))
: [];
const merged = Array.from(new Set(["copilot", ...agents]));
setAgentOptions(merged);
} catch (e) {
console.error("Failed to load agent list", e);
}
};
loadAgents();
}, []);
useEffect(() => {
// Changing agent starts a fresh conversation context
setRunId(null);
setConversation([]);
setCurrentAssistantMessage("");
setCurrentReasoning("");
}, [selectedAgent]);
const handleSave = async () => {
if (!selectedResource || artifactReadOnly || !artifactDirty) return;
setArtifactLoading(true);
setArtifactError(null);
try {
const parsed = JSON.parse(artifactText);
if (selectedResource.kind === "agent") {
const raw = selectedResource.name;
const targetId = stripExtension(raw) || raw;
await requestJson(`/agents/${encodeURIComponent(targetId)}`, {
method: "PUT",
body: JSON.stringify(parsed),
});
} else if (selectedResource.kind === "config") {
const lower = selectedResource.name.toLowerCase();
const previous = artifactOriginal ? JSON.parse(artifactOriginal) : {};
if (lower.includes("model")) {
const newProviders = parsed.providers || {};
const oldProviders = previous.providers || {};
const toDelete = Object.keys(oldProviders).filter(
(name) => !Object.prototype.hasOwnProperty.call(newProviders, name)
);
for (const name of toDelete) {
await requestJson(`/models/providers/${encodeURIComponent(name)}`, {
method: "DELETE",
});
}
for (const name of Object.keys(newProviders)) {
await requestJson(`/models/providers/${encodeURIComponent(name)}`, {
method: "PUT",
body: JSON.stringify(newProviders[name]),
});
}
if (parsed.defaults) {
await requestJson("/models/default", {
method: "PUT",
body: JSON.stringify(parsed.defaults),
});
}
} else if (lower.includes("mcp")) {
const newServers = parsed.mcpServers || parsed || {};
const oldServers = previous.mcpServers || {};
const toDelete = Object.keys(oldServers).filter(
(name) => !Object.prototype.hasOwnProperty.call(newServers, name)
);
for (const name of toDelete) {
await requestJson(`/mcp/${encodeURIComponent(name)}`, {
method: "DELETE",
});
}
for (const name of Object.keys(newServers)) {
await requestJson(`/mcp/${encodeURIComponent(name)}`, {
method: "PUT",
body: JSON.stringify(newServers[name]),
});
}
} else {
throw new Error("Unsupported config file");
}
}
setArtifactOriginal(JSON.stringify(JSON.parse(artifactText), null, 2));
} catch (error: any) {
setArtifactError(error?.message || "Failed to save changes");
} finally {
setArtifactLoading(false);
}
};
return (
<SidebarProvider>
<AppSidebar />
<>
<AppSidebar onSelectResource={setSelectedResource} />
<SidebarInset className="h-svh">
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
@ -410,113 +690,191 @@ export default function HomePage() {
</div>
</header>
<div className="relative flex w-full flex-1 min-h-0 flex-col overflow-hidden">
{/* Messages area */}
<Conversation className="flex-1 min-h-0 pb-48">
<ConversationContent className="!flex !flex-col !items-center !gap-8 !p-4">
<div className="w-full max-w-3xl mx-auto space-y-4">
<div className="flex flex-1 flex-col gap-4 overflow-hidden px-4 pb-0 md:flex-row">
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden">
{/* Messages area */}
<Conversation className="flex-1 min-h-0 overflow-y-auto">
<div className="pointer-events-none sticky bottom-0 z-10 h-16 bg-gradient-to-t from-background via-background/80 to-transparent" />
<ConversationContent className="!flex !flex-col !items-center !gap-8 !p-4 pt-4 pb-32">
<div className="w-full max-w-3xl mx-auto space-y-4">
{/* Render conversation items in order */}
{conversation.map((item) => {
if (item.type === 'message') {
return (
<Message
key={item.id}
from={item.role}
>
<MessageContent>
<MessageResponse>
{item.content}
</MessageResponse>
</MessageContent>
</Message>
);
} else if (item.type === 'tool') {
const stateMap: Record<string, any> = {
'pending': 'input-streaming',
'running': 'input-available',
'completed': 'output-available',
'error': 'output-error',
};
return (
<div key={item.id} className="mb-2">
<Tool>
<ToolHeader
title={item.name}
type="tool-call"
state={stateMap[item.status] || 'input-streaming'}
/>
<ToolContent>
<ToolInput input={item.input} />
{item.result && (
<ToolOutput output={item.result} errorText={undefined} />
)}
</ToolContent>
</Tool>
</div>
);
} else if (item.type === 'reasoning') {
return (
<div key={item.id} className="mb-2">
<Reasoning isStreaming={item.isStreaming}>
<ReasoningTrigger />
<ReasoningContent>
{item.content}
</ReasoningContent>
</Reasoning>
</div>
);
}
return null;
})}
{/* Render conversation items in order */}
{conversation.map((item) => {
if (item.type === 'message') {
return (
<Message
key={item.id}
from={item.role}
>
<MessageContent>
<MessageResponse>
{item.content}
</MessageResponse>
</MessageContent>
</Message>
);
} else if (item.type === 'tool') {
const stateMap: Record<string, any> = {
'pending': 'input-streaming',
'running': 'input-available',
'completed': 'output-available',
'error': 'output-error',
};
return (
<div key={item.id} className="mb-2">
<Tool>
<ToolHeader
title={item.name}
type="tool-call"
state={stateMap[item.status] || 'input-streaming'}
/>
<ToolContent>
<ToolInput input={item.input} />
{item.result && (
<ToolOutput output={item.result} errorText={undefined} />
)}
</ToolContent>
</Tool>
</div>
);
} else if (item.type === 'reasoning') {
return (
<div key={item.id} className="mb-2">
<Reasoning isStreaming={item.isStreaming}>
<ReasoningTrigger />
<ReasoningContent>
{item.content}
</ReasoningContent>
</Reasoning>
</div>
);
}
return null;
})}
{/* Streaming reasoning */}
{currentReasoning && (
<div className="mb-2">
<Reasoning isStreaming={true}>
<ReasoningTrigger />
<ReasoningContent>
{currentReasoning}
</ReasoningContent>
</Reasoning>
{/* Streaming reasoning */}
{currentReasoning && (
<div className="mb-2">
<Reasoning isStreaming={true}>
<ReasoningTrigger />
<ReasoningContent>
{currentReasoning}
</ReasoningContent>
</Reasoning>
</div>
)}
{/* Streaming message */}
{currentAssistantMessage && (
<Message from="assistant">
<MessageContent>
<MessageResponse>
{currentAssistantMessage}
</MessageResponse>
<span className="inline-block w-2 h-4 ml-1 bg-current animate-pulse" />
</MessageContent>
</Message>
)}
</div>
)}
</ConversationContent>
</Conversation>
{/* Streaming message */}
{currentAssistantMessage && (
<Message from="assistant">
<MessageContent>
<MessageResponse>
{currentAssistantMessage}
</MessageResponse>
<span className="inline-block w-2 h-4 ml-1 bg-current animate-pulse" />
</MessageContent>
</Message>
)}
{/* Input area */}
{isEmptyConversation ? (
<div className="absolute inset-0 flex items-center justify-center px-4 pb-16">
<div className="w-full max-w-3xl space-y-3 text-center">
<h2 className="text-4xl font-semibold text-foreground/80">
RowboatX
</h2>
{renderPromptInput()}
</div>
</div>
</ConversationContent>
</Conversation>
) : (
<div className="w-full px-4 pb-5 pt-2">
<div className="w-full max-w-3xl mx-auto">
{renderPromptInput()}
</div>
</div>
)}
</div>
{/* Input area */}
{isEmptyConversation ? (
<div className="absolute inset-0 flex items-center justify-center px-4 pb-16">
<div className="w-full max-w-3xl space-y-3 text-center">
<h2 className="text-4xl font-semibold text-foreground/80">
RowboatX
</h2>
{renderPromptInput()}
</div>
</div>
) : (
<div className="absolute bottom-2 left-0 right-0 flex justify-center w-full px-4 pb-5 pt-1 bg-background/95 backdrop-blur-sm">
<div className="w-full max-w-3xl">
{renderPromptInput()}
</div>
{selectedResource && (
<div className="flex w-full flex-col md:w-[70%] md:max-w-4xl md:shrink-0 min-h-[260px] md:min-h-0 py-5">
<Artifact className="flex-1 min-h-0 h-full">
<ArtifactHeader>
<div className="flex flex-col">
<ArtifactTitle className="truncate">{artifactTitle}</ArtifactTitle>
<ArtifactDescription className="text-xs">
{artifactSubtitle || selectedResource.kind}
{artifactReadOnly && (
<span className="ml-2 inline-flex items-center gap-1 text-muted-foreground">
<Lock className="h-3 w-3" /> Read-only
</span>
)}
</ArtifactDescription>
</div>
<ArtifactActions>
{!artifactReadOnly && (
<ArtifactAction
tooltip={artifactDirty ? "Save changes" : "Saved"}
disabled={!artifactDirty || artifactLoading}
onClick={handleSave}
>
{artifactLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
</ArtifactAction>
)}
<ArtifactClose onClick={() => setSelectedResource(null)} />
</ArtifactActions>
</ArtifactHeader>
<ArtifactContent className="bg-muted/30">
{artifactLoading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Loading
</div>
) : artifactError ? (
<div className="text-sm text-red-500 whitespace-pre-wrap break-words">
{artifactError}
</div>
) : (
<div className="flex h-full flex-col gap-2">
{artifactReadOnly ? (
<pre className="h-full min-h-[240px] max-h-[70vh] w-full overflow-auto whitespace-pre-wrap rounded-md border bg-background p-4 font-mono text-sm leading-relaxed text-foreground">
{artifactText}
</pre>
) : (
<textarea
value={artifactText}
onChange={(e) => setArtifactText(e.target.value)}
readOnly={artifactReadOnly}
className="h-full min-h-[240px] max-h-[70vh] w-full resize-none rounded-md border bg-background p-4 font-mono text-sm leading-relaxed text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
/>
)}
{artifactReadOnly && (
<p className="text-xs text-muted-foreground">
Runs are read-only; use the API to replay or inspect in detail.
</p>
)}
</div>
)}
</ArtifactContent>
</Artifact>
</div>
)}
</div>
</SidebarInset>
</>
);
}
export default function HomePage() {
return (
<SidebarProvider>
<PageBody />
</SidebarProvider>
);
}