diff --git a/apps/cli/src/agents/runtime.ts b/apps/cli/src/agents/runtime.ts index 0a0dfe61..b665730b 100644 --- a/apps/cli/src/agents/runtime.ts +++ b/apps/cli/src/agents/runtime.ts @@ -427,6 +427,13 @@ export class AgentState { this.runId = event.runId; this.agentName = event.agentName; break; + case "spawn-subflow": + // Seed the subflow state with its agent so downstream loadAgent works. + if (!this.subflowStates[event.toolCallId]) { + this.subflowStates[event.toolCallId] = new AgentState(); + } + this.subflowStates[event.toolCallId].agentName = event.agentName; + break; case "message": this.messages.push(event.message); if (event.message.content instanceof Array) { diff --git a/apps/rowboatx/app/api/chat/route.ts b/apps/rowboatx/app/api/chat/route.ts index 2b1e7704..bf9a295c 100644 --- a/apps/rowboatx/app/api/chat/route.ts +++ b/apps/rowboatx/app/api/chat/route.ts @@ -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; } diff --git a/apps/rowboatx/app/api/cli/[...path]/route.ts b/apps/rowboatx/app/api/cli/[...path]/route.ts new file mode 100644 index 00000000..5cecd160 --- /dev/null +++ b/apps/rowboatx/app/api/cli/[...path]/route.ts @@ -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 }); +} diff --git a/apps/rowboatx/app/api/rowboat/run/route.ts b/apps/rowboatx/app/api/rowboat/run/route.ts new file mode 100644 index 00000000..77a9455e --- /dev/null +++ b/apps/rowboatx/app/api/rowboat/run/route.ts @@ -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 } + ); + } +} diff --git a/apps/rowboatx/app/api/rowboat/summary/route.ts b/apps/rowboatx/app/api/rowboat/summary/route.ts new file mode 100644 index 00000000..a6dc70ad --- /dev/null +++ b/apps/rowboatx/app/api/rowboat/summary/route.ts @@ -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 { + 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, + }); +} diff --git a/apps/rowboatx/app/page.tsx b/apps/rowboatx/app/page.tsx index 93d6974c..050929dc 100644 --- a/apps/rowboatx/app/page.tsx +++ b/apps/rowboatx/app/page.tsx @@ -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(""); - const [useWebSearch, setUseWebSearch] = useState(false); const [useMicrophone, setUseMicrophone] = useState(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(null); const [conversation, setConversation] = useState([]); @@ -85,6 +109,72 @@ export default function HomePage() { const committedMessageIds = useRef>(new Set()); const isEmptyConversation = conversation.length === 0 && !currentAssistantMessage && !currentReasoning; + const [selectedResource, setSelectedResource] = useState(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(null); + const [artifactReadOnly, setArtifactReadOnly] = useState(false); + const [agentOptions, setAgentOptions] = useState(["copilot"]); + const [selectedAgent, setSelectedAgent] = useState("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 = () => ( @@ -116,13 +206,23 @@ export default function HomePage() { Microphone - setUseWebSearch(!useWebSearch)} - variant={useWebSearch ? "default" : "ghost"} + { - 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 ( - - + <> +
@@ -410,113 +690,191 @@ export default function HomePage() {
-
- {/* Messages area */} - - -
+
+
+ {/* Messages area */} + +
+ +
- {/* Render conversation items in order */} - {conversation.map((item) => { - if (item.type === 'message') { - return ( - - - - {item.content} - - - - ); - } else if (item.type === 'tool') { - const stateMap: Record = { - 'pending': 'input-streaming', - 'running': 'input-available', - 'completed': 'output-available', - 'error': 'output-error', - }; - - return ( -
- - - - - {item.result && ( - - )} - - -
- ); - } else if (item.type === 'reasoning') { - return ( -
- - - - {item.content} - - -
- ); - } - return null; - })} + {/* Render conversation items in order */} + {conversation.map((item) => { + if (item.type === 'message') { + return ( + + + + {item.content} + + + + ); + } else if (item.type === 'tool') { + const stateMap: Record = { + 'pending': 'input-streaming', + 'running': 'input-available', + 'completed': 'output-available', + 'error': 'output-error', + }; + + return ( +
+ + + + + {item.result && ( + + )} + + +
+ ); + } else if (item.type === 'reasoning') { + return ( +
+ + + + {item.content} + + +
+ ); + } + return null; + })} - {/* Streaming reasoning */} - {currentReasoning && ( -
- - - - {currentReasoning} - - + {/* Streaming reasoning */} + {currentReasoning && ( +
+ + + + {currentReasoning} + + +
+ )} + + {/* Streaming message */} + {currentAssistantMessage && ( + + + + {currentAssistantMessage} + + + + + )}
- )} + + - {/* Streaming message */} - {currentAssistantMessage && ( - - - - {currentAssistantMessage} - - - - - )} + {/* Input area */} + {isEmptyConversation ? ( +
+
+

+ RowboatX +

+ {renderPromptInput()} +
- - + ) : ( +
+
+ {renderPromptInput()} +
+
+ )} +
- {/* Input area */} - {isEmptyConversation ? ( -
-
-

- RowboatX -

- {renderPromptInput()} -
-
- ) : ( -
-
- {renderPromptInput()} -
+ {selectedResource && ( +
+ + +
+ {artifactTitle} + + {artifactSubtitle || selectedResource.kind} + {artifactReadOnly && ( + + Read-only + + )} + +
+ + {!artifactReadOnly && ( + + {artifactLoading ? ( + + ) : ( + + )} + + )} + setSelectedResource(null)} /> + +
+ + {artifactLoading ? ( +
+ Loading +
+ ) : artifactError ? ( +
+ {artifactError} +
+ ) : ( +
+ {artifactReadOnly ? ( +
+                          {artifactText}
+                        
+ ) : ( +