diff --git a/apps/rowboatx/app/api/chat/route.ts b/apps/rowboatx/app/api/chat/route.ts deleted file mode 100644 index bf9a295c..00000000 --- a/apps/rowboatx/app/api/chat/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { cliClient, RunEvent } from '@/lib/cli-client'; -import { NextRequest } from 'next/server'; - -export const runtime = 'nodejs'; -export const dynamic = 'force-dynamic'; - -/** - * POST /api/chat - * Creates a new conversation or sends a message to existing one - */ -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { message, runId, agentId } = body; - - if (!message || typeof message !== 'string') { - return Response.json( - { error: 'Message is required' }, - { status: 400 } - ); - } - - let currentRunId = runId; - - // Create new run if no runId provided - if (!currentRunId) { - const run = await cliClient.createRun({ - agentId: agentId || 'copilot', - }); - currentRunId = run.id; - } - - // Always send the message (this triggers the agent runtime) - await cliClient.sendMessage(currentRunId, message); - - // Return the run ID - return Response.json({ runId: currentRunId }); - } catch (error) { - console.error('Chat API error:', error); - return Response.json( - { error: 'Failed to process message' }, - { status: 500 } - ); - } -} - -/** - * GET /api/chat?runId=xxx - * Get a specific run's details - */ -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const runId = searchParams.get('runId'); - - if (!runId) { - // List all runs - const result = await cliClient.listRuns(); - return Response.json(result); - } - - // Get specific run - const run = await cliClient.getRun(runId); - return Response.json(run); - } catch (error) { - console.error('Chat API error:', error); - return Response.json( - { error: 'Failed to fetch run' }, - { status: 500 } - ); - } -} diff --git a/apps/rowboatx/app/api/rowboat/agent/route.ts b/apps/rowboatx/app/api/rowboat/agent/route.ts new file mode 100644 index 00000000..3aa01a3b --- /dev/null +++ b/apps/rowboatx/app/api/rowboat/agent/route.ts @@ -0,0 +1,64 @@ +import { NextRequest } from "next/server"; +import os from "os"; +import path from "path"; +import { promises as fs } from "fs"; + +const AGENTS_ROOT = path.join(os.homedir(), ".rowboat", "agents"); + +function resolveAgentPath(fileParam: string): { target: string; relative: string } { + // Normalize and strip any attempted path traversal. + const normalized = path.normalize(fileParam).replace(/^(\.\.(\/|\\|$))+/, ""); + const target = path.join(AGENTS_ROOT, normalized); + if (!target.startsWith(AGENTS_ROOT)) { + throw new Error("Invalid path"); + } + return { target, relative: normalized }; +} + +export async function GET(req: NextRequest) { + const fileParam = req.nextUrl.searchParams.get("file"); + if (!fileParam) { + return Response.json({ error: "file param required" }, { status: 400 }); + } + + try { + const { target, relative } = resolveAgentPath(fileParam); + const content = await fs.readFile(target, "utf8"); + return Response.json({ file: relative, content, raw: content }); + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + if (err?.code === "ENOENT") { + return Response.json({ error: "File not found" }, { status: 404 }); + } + if (err instanceof Error && err.message === "Invalid path") { + return Response.json({ error: "Invalid file path" }, { status: 400 }); + } + console.error("Failed to read agent file", error); + return Response.json({ error: "Failed to read agent file" }, { status: 500 }); + } +} + +export async function PUT(req: NextRequest) { + const fileParam = req.nextUrl.searchParams.get("file"); + if (!fileParam) { + return Response.json({ error: "file param required" }, { status: 400 }); + } + + try { + const { target, relative } = resolveAgentPath(fileParam); + const content = await req.text(); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, content, "utf8"); + return Response.json({ file: relative, success: true }); + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + if (err?.code === "ENOENT") { + return Response.json({ error: "File not found" }, { status: 404 }); + } + if (err instanceof Error && err.message === "Invalid path") { + return Response.json({ error: "Invalid file path" }, { status: 400 }); + } + console.error("Failed to write agent file", error); + return Response.json({ error: "Failed to write agent file" }, { status: 500 }); + } +} diff --git a/apps/rowboatx/app/api/rowboat/config/route.ts b/apps/rowboatx/app/api/rowboat/config/route.ts new file mode 100644 index 00000000..d3ca62f9 --- /dev/null +++ b/apps/rowboatx/app/api/rowboat/config/route.ts @@ -0,0 +1,74 @@ +import { NextRequest } from "next/server"; +import os from "os"; +import path from "path"; +import { promises as fs } from "fs"; + +const CONFIG_ROOT = path.join(os.homedir(), ".rowboat", "config"); + +function resolveConfigPath(fileParam: string): { target: string; relative: string } { + const normalized = path.normalize(fileParam).replace(/^(\.\.(\/|\\|$))+/, ""); + const target = path.join(CONFIG_ROOT, normalized); + if (!target.startsWith(CONFIG_ROOT)) { + throw new Error("Invalid path"); + } + return { target, relative: normalized }; +} + +export async function GET(req: NextRequest) { + const fileParam = req.nextUrl.searchParams.get("file"); + if (!fileParam) { + return Response.json({ error: "file param required" }, { status: 400 }); + } + + try { + const { target, relative } = resolveConfigPath(fileParam); + const content = await fs.readFile(target, "utf8"); + return Response.json({ file: relative, content, raw: content }); + } catch (error: unknown) { + const err = error as NodeJS.ErrnoException; + if (err?.code === "ENOENT") { + return Response.json( + { error: "File not found" }, + { status: 404 } + ); + } + if (error instanceof Error && error.message === "Invalid path") { + return Response.json( + { error: "Invalid file path" }, + { status: 400 } + ); + } + console.error("Failed to read config file", error); + return Response.json( + { error: "Failed to read config file" }, + { status: 500 } + ); + } +} + +export async function PUT(req: NextRequest) { + const fileParam = req.nextUrl.searchParams.get("file"); + if (!fileParam) { + return Response.json({ error: "file param required" }, { status: 400 }); + } + + try { + const { target, relative } = resolveConfigPath(fileParam); + const content = await req.text(); + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, content, "utf8"); + return Response.json({ file: relative, success: true }); + } catch (error: unknown) { + if (error instanceof Error && error.message === "Invalid path") { + return Response.json( + { error: "Invalid file path" }, + { status: 400 } + ); + } + console.error("Failed to write config file", error); + return Response.json( + { error: "Failed to write config file" }, + { status: 500 } + ); + } +} diff --git a/apps/rowboatx/app/api/rowboat/run/route.ts b/apps/rowboatx/app/api/rowboat/run/route.ts index 77a9455e..5b870c5b 100644 --- a/apps/rowboatx/app/api/rowboat/run/route.ts +++ b/apps/rowboatx/app/api/rowboat/run/route.ts @@ -5,26 +5,38 @@ import { promises as fs } from "fs"; const ROWBOAT_ROOT = path.join(os.homedir(), ".rowboat", "runs"); +function resolveRunPath(fileParam: string): { target: string; relative: string } { + const normalized = path.normalize(fileParam).replace(/^(\.\.(\/|\\|$))+/, ""); + const target = path.join(ROWBOAT_ROOT, normalized); + if (!target.startsWith(ROWBOAT_ROOT)) { + throw new Error("Invalid path"); + } + return { target, relative: normalized }; +} + 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 { target, relative } = resolveRunPath(fileParam); const content = await fs.readFile(target, "utf8"); - let parsed: any = null; + let parsed: unknown = null; try { parsed = JSON.parse(content); } catch { parsed = null; } - return Response.json({ file: safeName, parsed, raw: content }); - } catch (error: any) { + return Response.json({ file: relative, parsed, raw: content }); + } catch (error: unknown) { + if (error instanceof Error && error.message === "Invalid path") { + return Response.json( + { error: "Invalid file path" }, + { status: 400 } + ); + } console.error("Failed to read run file", error); return Response.json( { error: "Failed to read run file" }, diff --git a/apps/rowboatx/app/api/rowboat/summary/route.ts b/apps/rowboatx/app/api/rowboat/summary/route.ts index a6dc70ad..0c806b0e 100644 --- a/apps/rowboatx/app/api/rowboat/summary/route.ts +++ b/apps/rowboatx/app/api/rowboat/summary/route.ts @@ -1,24 +1,39 @@ -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 []; - } +async function listRecursive(dir: string): Promise { + const root = path.join(ROWBOAT_ROOT, dir); + + const walk = async (current: string, prefix = ""): Promise => { + const results: string[] = []; + try { + const entries = await fs.readdir(current, { withFileTypes: true }); + + for (const entry of entries) { + const relPath = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + results.push(...(await walk(path.join(current, entry.name), relPath))); + } else if (entry.isFile()) { + results.push(relPath); + } + } + } catch { + return results; + } + + return results; + }; + + return walk(root); } -export async function GET(_req: NextRequest) { - const agents = await safeList("agents"); - const config = await safeList("config"); - const runs = await safeList("runs"); +export async function GET() { + const agents = await listRecursive("agents"); + const config = await listRecursive("config"); + const runs = await listRecursive("runs"); return Response.json({ agents, diff --git a/apps/rowboatx/app/api/stream/route.ts b/apps/rowboatx/app/api/stream/route.ts index 58cae2b9..48477621 100644 --- a/apps/rowboatx/app/api/stream/route.ts +++ b/apps/rowboatx/app/api/stream/route.ts @@ -23,7 +23,7 @@ export async function GET(request: NextRequest) { reader?.cancel(); try { controller.close(); - } catch (e) { + } catch { // Already closed, ignore } }); @@ -43,10 +43,11 @@ export async function GET(request: NextRequest) { throw new Error(`Failed to connect to backend: ${response.statusText}`); } - reader = response.body?.getReader(); - if (!reader) { + const body = response.body; + if (!body) { throw new Error('No response body'); } + reader = body.getReader(); // Read and forward stream while (!isClosed) { @@ -60,15 +61,15 @@ export async function GET(request: NextRequest) { if (!isClosed) { try { controller.enqueue(value); - } catch (e) { + } catch { // Controller closed, stop reading break; } } } - } catch (error: any) { + } catch (error: unknown) { // Only log non-abort errors - if (error.name !== 'AbortError') { + if ((error as Error).name !== 'AbortError') { console.error('Stream error:', error); } @@ -77,7 +78,7 @@ export async function GET(request: NextRequest) { try { const errorMessage = `data: ${JSON.stringify({ type: 'error', error: String(error) })}\n\n`; controller.enqueue(encoder.encode(errorMessage)); - } catch (e) { + } catch { // Controller already closed, ignore } } @@ -86,7 +87,7 @@ export async function GET(request: NextRequest) { if (reader) { try { await reader.cancel(); - } catch (e) { + } catch { // Ignore cancel errors } } @@ -94,7 +95,7 @@ export async function GET(request: NextRequest) { if (!isClosed) { try { controller.close(); - } catch (e) { + } catch { // Already closed, ignore } } @@ -110,4 +111,3 @@ export async function GET(request: NextRequest) { }, }); } - diff --git a/apps/rowboatx/app/layout.tsx b/apps/rowboatx/app/layout.tsx index f7fa87eb..7241a206 100644 --- a/apps/rowboatx/app/layout.tsx +++ b/apps/rowboatx/app/layout.tsx @@ -1,20 +1,9 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "RowboatX", + description: "RowboatX interface", }; export default function RootLayout({ @@ -24,9 +13,7 @@ export default function RootLayout({ }>) { return ( - + {children} diff --git a/apps/rowboatx/app/page.tsx b/apps/rowboatx/app/page.tsx index 050929dc..4c30d9b3 100644 --- a/apps/rowboatx/app/page.tsx +++ b/apps/rowboatx/app/page.tsx @@ -46,9 +46,8 @@ import { ArtifactHeader, ArtifactTitle, } from "@/components/ai-elements/artifact"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, type ReactNode } from "react"; import { MicIcon, Save, Loader2, Lock } from "lucide-react"; -import { RunEvent } from "@/lib/cli-client"; import { Select, SelectContent, @@ -57,6 +56,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { JsonEditor } from "@/components/json-editor"; +import { TiptapMarkdownEditor } from "@/components/tiptap-markdown-editor"; +import { MarkdownViewer } from "@/components/markdown-viewer"; interface ChatMessage { id: string; @@ -70,8 +72,8 @@ interface ToolCall { id: string; type: 'tool'; name: string; - input: any; - result?: any; + input: unknown; + result?: unknown; status: 'pending' | 'running' | 'completed' | 'error'; timestamp: number; } @@ -93,15 +95,28 @@ type SelectedResource = { name: string; }; +type ToolCallContentPart = { + type: 'tool-call'; + toolCallId: string; + toolName: string; + arguments: unknown; +}; + +type RunEvent = { + type: string; + [key: string]: unknown; +}; + function PageBody() { - // Use local proxy to avoid CORS/port mismatches. const apiBase = "/api/cli"; + const streamUrl = "/api/stream"; const [text, setText] = useState(""); const [useMicrophone, setUseMicrophone] = useState(false); const [status, setStatus] = useState<"submitted" | "streaming" | "ready" | "error">("ready"); // Chat state const [runId, setRunId] = useState(null); + const [isRunProcessing, setIsRunProcessing] = useState(false); const [conversation, setConversation] = useState([]); const [currentAssistantMessage, setCurrentAssistantMessage] = useState(""); const [currentReasoning, setCurrentReasoning] = useState(""); @@ -117,23 +132,22 @@ function PageBody() { const [artifactLoading, setArtifactLoading] = useState(false); const [artifactError, setArtifactError] = useState(null); const [artifactReadOnly, setArtifactReadOnly] = useState(false); + const [artifactFileType, setArtifactFileType] = useState<"json" | "markdown">("json"); const [agentOptions, setAgentOptions] = useState(["copilot"]); const [selectedAgent, setSelectedAgent] = useState("copilot"); const artifactDirty = !artifactReadOnly && artifactText !== artifactOriginal; const stripExtension = (name: string) => name.replace(/\.[^/.]+$/, ""); + const detectFileType = (name: string): "json" | "markdown" => + name.toLowerCase().match(/\.(md|markdown)$/) ? "markdown" : "json"; 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 fullUrl = url.startsWith("/api/") + ? url + : `${apiBase}${url.startsWith("/") ? url : `/${url}`}`; const { allow404, ...rest } = options || {}; const res = await fetch(fullUrl, { ...rest, @@ -241,7 +255,7 @@ function PageBody() { } console.log('🔌 Creating new EventSource connection'); - const eventSource = new EventSource('/api/stream'); + const eventSource = new EventSource(streamUrl); eventSourceRef.current = eventSource; const handleMessage = (e: MessageEvent) => { @@ -276,13 +290,23 @@ function PageBody() { eventSource.close(); eventSourceRef.current = null; }; - }, []); // Empty deps - only run once + }, [streamUrl]); // Handle different event types from the copilot const handleEvent = (event: RunEvent) => { console.log('Event received:', event.type, event); switch (event.type) { + case 'run-processing-start': + setIsRunProcessing(true); + setStatus((prev) => (prev === 'error' ? prev : 'streaming')); + break; + + case 'run-processing-end': + setIsRunProcessing(false); + setStatus('ready'); + break; + case 'start': setStatus('streaming'); setCurrentAssistantMessage(''); @@ -290,126 +314,142 @@ function PageBody() { break; case 'llm-stream-event': - console.log('LLM stream event type:', event.event?.type); + { + const llmEvent = (event.event as { + type?: string; + delta?: string; + toolCallId?: string; + toolName?: string; + input?: unknown; + }) || {}; + console.log('LLM stream event type:', llmEvent.type); - if (event.event?.type === 'reasoning-delta') { - setCurrentReasoning(prev => prev + event.event.delta); - } else if (event.event?.type === 'reasoning-end') { - // Commit reasoning block if we have content - setCurrentReasoning(reasoning => { - if (reasoning) { - setConversation(prev => [...prev, { - id: `reasoning-${Date.now()}`, - type: 'reasoning', - content: reasoning, - isStreaming: false, - timestamp: Date.now(), - }]); - } - return ''; - }); - } else if (event.event?.type === 'text-delta') { - setCurrentAssistantMessage(prev => prev + event.event.delta); - setStatus('streaming'); - } else if (event.event?.type === 'text-end') { - console.log('TEXT END received - waiting for message event'); - } else if (event.event?.type === 'tool-call') { - // Add tool call to conversation immediately - setConversation(prev => [...prev, { - id: event.event.toolCallId, - type: 'tool', - name: event.event.toolName, - input: event.event.input, - status: 'running', - timestamp: Date.now(), - }]); - } else if (event.event?.type === 'finish-step') { - console.log('FINISH STEP received - waiting for message event'); - } - break; - - case 'message': - console.log('MESSAGE event received:', event); - if (event.message?.role === 'assistant') { - // If the final assistant message contains tool calls, sync them to conversation - if (Array.isArray(event.message.content)) { - const toolCalls = event.message.content.filter( - (part: any) => part?.type === 'tool-call' - ); - if (toolCalls.length) { - setConversation((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 exists = updated.some( - (item) => item.type === 'tool' && item.id === part.toolCallId - ); - if (!exists) { - updated = [ - ...updated, - { - id: part.toolCallId, - type: 'tool', - name: part.toolName, - input: part.arguments, - status: 'pending', - timestamp: Date.now(), - }, - ]; - } - } - return updated; - }); - } - } - - const messageId = event.messageId || `assistant-${Date.now()}`; - - if (committedMessageIds.current.has(messageId)) { - console.log('⚠️ Message already committed, skipping:', messageId); - return; - } - - committedMessageIds.current.add(messageId); - - setCurrentAssistantMessage(currentMsg => { - console.log('✅ Committing message:', messageId, currentMsg); - if (currentMsg) { - setConversation(prev => { - const exists = prev.some(m => m.id === messageId); - if (exists) { - console.log('⚠️ Message ID already in array, skipping:', messageId); - return prev; - } - return [...prev, { - id: messageId, - type: 'message', - role: 'assistant', - content: currentMsg, + if (llmEvent.type === 'reasoning-delta' && llmEvent.delta) { + setCurrentReasoning(prev => prev + llmEvent.delta); + } else if (llmEvent.type === 'reasoning-end') { + // Commit reasoning block if we have content + setCurrentReasoning(reasoning => { + if (reasoning) { + setConversation(prev => [...prev, { + id: `reasoning-${Date.now()}`, + type: 'reasoning', + content: reasoning, + isStreaming: false, timestamp: Date.now(), - }]; - }); - } - return ''; - }); - setStatus('ready'); - console.log('Status set to ready'); + }]); + } + return ''; + }); + } else if (llmEvent.type === 'text-delta' && llmEvent.delta) { + setCurrentAssistantMessage(prev => prev + llmEvent.delta); + setStatus('streaming'); + } else if (llmEvent.type === 'text-end') { + console.log('TEXT END received - waiting for message event'); + } else if (llmEvent.type === 'tool-call') { + // Add tool call to conversation immediately + setConversation(prev => [...prev, { + id: llmEvent.toolCallId || `tool-${Date.now()}`, + type: 'tool', + name: llmEvent.toolName || 'tool', + input: llmEvent.input, + status: 'running', + timestamp: Date.now(), + }]); + } else if (llmEvent.type === 'finish-step') { + console.log('FINISH STEP received - waiting for message event'); + } } break; + case 'message': { + console.log('MESSAGE event received:', event); + const message = (event.message as { role?: string; content?: unknown }) || {}; + if (message.role !== 'assistant') { + break; + } + + if (Array.isArray(message.content)) { + const toolCalls = message.content.filter( + (part): part is ToolCallContentPart => + (part as ToolCallContentPart)?.type === 'tool-call' + ); + if (toolCalls.length) { + setConversation((prev) => { + let updated: ConversationItem[] = prev.map((item) => { + if (item.type !== 'tool') return item; + const match = toolCalls.find( + (part) => part.toolCallId === item.id + ); + return match + ? { + ...item, + name: match.toolName, + input: match.arguments, + status: 'pending', + } + : item; + }); + + for (const part of toolCalls) { + const exists = updated.some( + (item) => item.type === 'tool' && item.id === part.toolCallId + ); + if (!exists) { + updated = [ + ...updated, + { + id: part.toolCallId, + type: 'tool', + name: part.toolName, + input: part.arguments, + status: 'pending', + timestamp: Date.now(), + }, + ]; + } + } + return updated; + }); + } + } + + const messageId = + typeof event.messageId === "string" + ? event.messageId + : `assistant-${Date.now()}`; + + if (committedMessageIds.current.has(messageId)) { + console.log('⚠️ Message already committed, skipping:', messageId); + break; + } + + committedMessageIds.current.add(messageId); + + setCurrentAssistantMessage(currentMsg => { + console.log('✅ Committing message:', messageId, currentMsg); + if (currentMsg) { + setConversation(prev => { + const exists = prev.some(m => m.id === messageId); + if (exists) { + console.log('⚠️ Message ID already in array, skipping:', messageId); + return prev; + } + return [...prev, { + id: messageId, + type: 'message', + role: 'assistant', + content: currentMsg, + timestamp: Date.now(), + }]; + }); + } + return ''; + }); + setStatus('ready'); + console.log('Status set to ready'); + break; + } + case 'tool-invocation': setConversation(prev => prev.map(item => item.type === 'tool' && (item.id === event.toolCallId || item.name === event.toolName) @@ -428,13 +468,17 @@ function PageBody() { case 'error': // Only set error status for actual errors, not connection issues - if (event.error && !event.error.includes('terminated')) { + { + const errorMsg = typeof event.error === "string" ? event.error : ""; + if (errorMsg && !errorMsg.includes('terminated')) { setStatus('error'); - console.error('Agent error:', event.error); + console.error('Agent error:', errorMsg); } else { - console.log('Connection error (will auto-reconnect):', event.error); + console.log('Connection error (will auto-reconnect):', errorMsg); setStatus('ready'); } + setIsRunProcessing(false); + } break; default: @@ -466,28 +510,29 @@ function PageBody() { setText(""); try { - // Send message to backend - const response = await fetch('/api/chat', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + let nextRunId = runId; + if (!nextRunId) { + const runData = await requestJson("/runs/new", { + method: "POST", + body: JSON.stringify({ + agentId: selectedAgent, + }), + }); + nextRunId = runData?.id; + setRunId(nextRunId); + } + + if (!nextRunId) { + throw new Error("Run ID unavailable after creation"); + } + + await requestJson(`/runs/${encodeURIComponent(nextRunId)}/messages/new`, { + method: "POST", body: JSON.stringify({ message: userMessage, - agentId: selectedAgent, - runId: runId, }), }); - if (!response.ok) { - throw new Error('Failed to send message'); - } - - const data = await response.json(); - - // Store runId for subsequent messages - if (data.runId && !runId) { - setRunId(data.runId); - } - setStatus('streaming'); } catch (error) { console.error('Failed to send message:', error); @@ -503,34 +548,96 @@ function PageBody() { setArtifactLoading(true); setArtifactError(null); try { - let title = selectedResource.name; + const title = selectedResource.name; let subtitle = ""; let text = ""; let readOnly = false; + const detectedType = detectFileType(selectedResource.name); + setArtifactFileType(detectedType); if (selectedResource.kind === "agent") { const raw = selectedResource.name; - const id = stripExtension(raw) || raw; - const data = await requestJson(`/agents/${encodeURIComponent(id)}`); + const isMarkdown = /\.(md|markdown)$/i.test(raw); - subtitle = "Agent"; - text = JSON.stringify(data ?? {}, null, 2); + if (isMarkdown) { + subtitle = "Agent (Markdown)"; + const response = await fetch( + `/api/rowboat/agent?file=${encodeURIComponent(raw)}` + ); + if (!response.ok) { + if (response.status === 404) { + text = ""; + } else { + throw new Error(`Failed to load agent file: ${response.status}`); + } + } else { + const data = await response.json(); + text = data?.content || data?.raw || ""; + } + setArtifactFileType("markdown"); + } else { + const id = stripExtension(raw) || raw; + const data = await requestJson(`/agents/${encodeURIComponent(id)}`); + + subtitle = "Agent"; + text = JSON.stringify(data ?? {}, null, 2); + setArtifactFileType("json"); + } } else if (selectedResource.kind === "config") { const lower = selectedResource.name.toLowerCase(); - if (lower.includes("mcp")) { + if (lower.endsWith(".md") || lower.endsWith(".markdown")) { + // Load markdown file as plain text from local API + try { + const response = await fetch( + `/api/rowboat/config?file=${encodeURIComponent(selectedResource.name)}` + ); + if (!response.ok) { + if (response.status === 404) { + // File doesn't exist, start with empty content + text = ""; + } else { + throw new Error(`Failed to load markdown file: ${response.status}`); + } + } else { + const data = await response.json(); + text = data.content || data.raw || ""; + } + subtitle = "Markdown"; + setArtifactFileType("markdown"); + } catch (error: unknown) { + const err = error as Error; + console.error("Error loading markdown file:", error); + // Show error but still allow editing + setArtifactError(err?.message || "Failed to load markdown file"); + text = ""; + subtitle = "Markdown"; + setArtifactFileType("markdown"); + } + } else if (lower.includes("mcp")) { const data = await requestJson("/mcp"); subtitle = "MCP config"; text = JSON.stringify(data ?? {}, null, 2); + setArtifactFileType("json"); } else if (lower.includes("model")) { const data = await requestJson("/models"); subtitle = "Models config"; text = JSON.stringify(data ?? {}, null, 2); + setArtifactFileType("json"); } else { - throw new Error("Unsupported config file"); + // Try to load as JSON by default + try { + const data = await requestJson(`/config/${encodeURIComponent(selectedResource.name)}`); + subtitle = "Config"; + text = JSON.stringify(data ?? {}, null, 2); + setArtifactFileType("json"); + } catch { + throw new Error("Unsupported config file"); + } } } else if (selectedResource.kind === "run") { subtitle = "Run (read-only)"; readOnly = true; + setArtifactFileType(detectedType); const local = await requestJson( `/api/rowboat/run?file=${encodeURIComponent(selectedResource.name)}` @@ -550,9 +657,10 @@ function PageBody() { setArtifactText(text); setArtifactOriginal(text); setArtifactReadOnly(readOnly); - } catch (error: any) { + } catch (error: unknown) { if (!cancelled) { - setArtifactError(error?.message || "Failed to load resource"); + const err = error as Error; + setArtifactError(err?.message || "Failed to load resource"); setArtifactText(""); } } finally { @@ -565,7 +673,6 @@ function PageBody() { return () => { cancelled = true; }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedResource]); useEffect(() => { @@ -592,6 +699,7 @@ function PageBody() { setConversation([]); setCurrentAssistantMessage(""); setCurrentReasoning(""); + setIsRunProcessing(false); }, [selectedAgent]); const handleSave = async () => { @@ -599,67 +707,102 @@ function PageBody() { setArtifactLoading(true); setArtifactError(null); try { - const parsed = JSON.parse(artifactText); if (selectedResource.kind === "agent") { - const raw = selectedResource.name; - const targetId = stripExtension(raw) || raw; + if (artifactFileType === "markdown") { + const response = await fetch( + `/api/rowboat/agent?file=${encodeURIComponent(selectedResource.name)}`, + { + method: "PUT", + headers: { "Content-Type": "text/plain" }, + body: artifactText, + } + ); + if (!response.ok) { + throw new Error("Failed to save agent file"); + } + setArtifactOriginal(artifactText); + } else { + const parsed = JSON.parse(artifactText); + const raw = selectedResource.name; + const targetId = stripExtension(raw) || raw; - await requestJson(`/agents/${encodeURIComponent(targetId)}`, { - method: "PUT", - body: JSON.stringify(parsed), - }); + await requestJson(`/agents/${encodeURIComponent(targetId)}`, { + method: "PUT", + body: JSON.stringify(parsed), + }); + setArtifactOriginal(JSON.stringify(parsed, null, 2)); + } } 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) + + if (lower.endsWith(".md") || lower.endsWith(".markdown")) { + // Save markdown file as plain text via local API + const response = await fetch( + `/api/rowboat/config?file=${encodeURIComponent(selectedResource.name)}`, + { + method: "PUT", + headers: { "Content-Type": "text/plain" }, + body: artifactText, + } ); - 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]), - }); + if (!response.ok) { + throw new Error("Failed to save markdown file"); } + setArtifactOriginal(artifactText); } else { - throw new Error("Unsupported config file"); + // Handle JSON config files + const parsed = JSON.parse(artifactText); + 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(parsed, null, 2)); } } - - setArtifactOriginal(JSON.stringify(JSON.parse(artifactText), null, 2)); - } catch (error: any) { - setArtifactError(error?.message || "Failed to save changes"); + } catch (error: unknown) { + const err = error as Error; + setArtifactError(err?.message || "Failed to save changes"); } finally { setArtifactLoading(false); } @@ -692,6 +835,12 @@ function PageBody() {
+ {isRunProcessing && ( +
+ + Working... +
+ )} {/* Messages area */}
@@ -714,30 +863,33 @@ function PageBody() { ); } else if (item.type === 'tool') { - const stateMap: Record = { - 'pending': 'input-streaming', - 'running': 'input-available', - 'completed': 'output-available', - 'error': 'output-error', + const stateMap: Record = { + pending: 'input-streaming', + running: 'input-available', + completed: 'output-available', + error: 'output-error', }; return (
- - + + - {item.result && ( - + {item.result != null && ( + )} - -
- ); + +
+ ); } else if (item.type === 'reasoning') { return (
@@ -843,15 +995,25 @@ function PageBody() { ) : (
{artifactReadOnly ? ( -
-                          {artifactText}
-                        
+ artifactFileType === "markdown" ? ( + + ) : ( +
+                            {artifactText}
+                          
+ ) + ) : artifactFileType === "markdown" ? ( + setArtifactText(newContent)} + readOnly={false} + placeholder="Start writing your markdown..." + /> ) : ( -