mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-02 20:03:21 +02:00
Refactor RowboatX configuration and enhance editor features
- Updated `next.config.ts` to scope Turbopack to the app's directory. - Modified `package.json` and `package-lock.json` to include new dependencies for Tiptap and markdown processing. - Removed deprecated chat API and added new agent and config routes for file management. - Introduced `JsonEditor` and `MarkdownViewer` components for improved content editing and display. - Enhanced `TiptapMarkdownEditor` with additional toolbar options and markdown parsing capabilities. - Updated layout and page components to integrate new editors and improve user experience.
This commit is contained in:
parent
da20e280f4
commit
c637cb49ac
26 changed files with 2552 additions and 540 deletions
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
64
apps/rowboatx/app/api/rowboat/agent/route.ts
Normal file
64
apps/rowboatx/app/api/rowboat/agent/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
74
apps/rowboatx/app/api/rowboat/config/route.ts
Normal file
74
apps/rowboatx/app/api/rowboat/config/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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<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 [];
|
||||
}
|
||||
async function listRecursive(dir: string): Promise<string[]> {
|
||||
const root = path.join(ROWBOAT_ROOT, dir);
|
||||
|
||||
const walk = async (current: string, prefix = ""): Promise<string[]> => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<body className="antialiased" suppressHydrationWarning>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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<string>("");
|
||||
const [useMicrophone, setUseMicrophone] = useState<boolean>(false);
|
||||
const [status, setStatus] = useState<"submitted" | "streaming" | "ready" | "error">("ready");
|
||||
|
||||
// Chat state
|
||||
const [runId, setRunId] = useState<string | null>(null);
|
||||
const [isRunProcessing, setIsRunProcessing] = useState(false);
|
||||
const [conversation, setConversation] = useState<ConversationItem[]>([]);
|
||||
const [currentAssistantMessage, setCurrentAssistantMessage] = useState<string>("");
|
||||
const [currentReasoning, setCurrentReasoning] = useState<string>("");
|
||||
|
|
@ -117,23 +132,22 @@ function PageBody() {
|
|||
const [artifactLoading, setArtifactLoading] = useState(false);
|
||||
const [artifactError, setArtifactError] = useState<string | null>(null);
|
||||
const [artifactReadOnly, setArtifactReadOnly] = useState(false);
|
||||
const [artifactFileType, setArtifactFileType] = useState<"json" | "markdown">("json");
|
||||
const [agentOptions, setAgentOptions] = useState<string[]>(["copilot"]);
|
||||
const [selectedAgent, setSelectedAgent] = useState<string>("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() {
|
|||
|
||||
<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">
|
||||
{isRunProcessing && (
|
||||
<div className="pointer-events-none absolute left-1/2 top-4 z-20 flex -translate-x-1/2 items-center gap-2 rounded-full bg-muted/80 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm backdrop-blur">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>Working...</span>
|
||||
</div>
|
||||
)}
|
||||
{/* 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" />
|
||||
|
|
@ -714,30 +863,33 @@ function PageBody() {
|
|||
</Message>
|
||||
);
|
||||
} else if (item.type === 'tool') {
|
||||
const stateMap: Record<string, any> = {
|
||||
'pending': 'input-streaming',
|
||||
'running': 'input-available',
|
||||
'completed': 'output-available',
|
||||
'error': 'output-error',
|
||||
const stateMap: Record<ToolCall['status'], 'input-streaming' | 'input-available' | 'output-available' | 'output-error'> = {
|
||||
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'}
|
||||
/>
|
||||
<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} />
|
||||
{item.result != null && (
|
||||
<ToolOutput
|
||||
output={item.result as ReactNode}
|
||||
errorText={undefined}
|
||||
/>
|
||||
)}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
} else if (item.type === 'reasoning') {
|
||||
return (
|
||||
<div key={item.id} className="mb-2">
|
||||
|
|
@ -843,15 +995,25 @@ function PageBody() {
|
|||
) : (
|
||||
<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>
|
||||
artifactFileType === "markdown" ? (
|
||||
<MarkdownViewer content={artifactText} />
|
||||
) : (
|
||||
<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>
|
||||
)
|
||||
) : artifactFileType === "markdown" ? (
|
||||
<TiptapMarkdownEditor
|
||||
content={artifactText}
|
||||
onChange={(newContent) => setArtifactText(newContent)}
|
||||
readOnly={false}
|
||||
placeholder="Start writing your markdown..."
|
||||
/>
|
||||
) : (
|
||||
<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"
|
||||
<JsonEditor
|
||||
content={artifactText}
|
||||
onChange={(newContent) => setArtifactText(newContent)}
|
||||
readOnly={false}
|
||||
/>
|
||||
)}
|
||||
{artifactReadOnly && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue