mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +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
eddf51b807
commit
5df3b1b524
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");
|
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) {
|
export async function GET(req: NextRequest) {
|
||||||
const fileParam = req.nextUrl.searchParams.get("file");
|
const fileParam = req.nextUrl.searchParams.get("file");
|
||||||
if (!fileParam) {
|
if (!fileParam) {
|
||||||
return Response.json({ error: "file param required" }, { status: 400 });
|
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 {
|
try {
|
||||||
|
const { target, relative } = resolveRunPath(fileParam);
|
||||||
const content = await fs.readFile(target, "utf8");
|
const content = await fs.readFile(target, "utf8");
|
||||||
let parsed: any = null;
|
let parsed: unknown = null;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(content);
|
parsed = JSON.parse(content);
|
||||||
} catch {
|
} catch {
|
||||||
parsed = null;
|
parsed = null;
|
||||||
}
|
}
|
||||||
return Response.json({ file: safeName, parsed, raw: content });
|
return Response.json({ file: relative, parsed, raw: content });
|
||||||
} catch (error: any) {
|
} 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);
|
console.error("Failed to read run file", error);
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "Failed to read run file" },
|
{ error: "Failed to read run file" },
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,39 @@
|
||||||
import { NextRequest } from "next/server";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import os from "os";
|
import os from "os";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
|
|
||||||
const ROWBOAT_ROOT = path.join(os.homedir(), ".rowboat");
|
const ROWBOAT_ROOT = path.join(os.homedir(), ".rowboat");
|
||||||
|
|
||||||
async function safeList(dir: string): Promise<string[]> {
|
async function listRecursive(dir: string): Promise<string[]> {
|
||||||
const full = path.join(ROWBOAT_ROOT, dir);
|
const root = path.join(ROWBOAT_ROOT, dir);
|
||||||
try {
|
|
||||||
const entries = await fs.readdir(full, { withFileTypes: true });
|
const walk = async (current: string, prefix = ""): Promise<string[]> => {
|
||||||
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
const results: string[] = [];
|
||||||
} catch {
|
try {
|
||||||
return [];
|
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) {
|
export async function GET() {
|
||||||
const agents = await safeList("agents");
|
const agents = await listRecursive("agents");
|
||||||
const config = await safeList("config");
|
const config = await listRecursive("config");
|
||||||
const runs = await safeList("runs");
|
const runs = await listRecursive("runs");
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
agents,
|
agents,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export async function GET(request: NextRequest) {
|
||||||
reader?.cancel();
|
reader?.cancel();
|
||||||
try {
|
try {
|
||||||
controller.close();
|
controller.close();
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Already closed, ignore
|
// Already closed, ignore
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -43,10 +43,11 @@ export async function GET(request: NextRequest) {
|
||||||
throw new Error(`Failed to connect to backend: ${response.statusText}`);
|
throw new Error(`Failed to connect to backend: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
reader = response.body?.getReader();
|
const body = response.body;
|
||||||
if (!reader) {
|
if (!body) {
|
||||||
throw new Error('No response body');
|
throw new Error('No response body');
|
||||||
}
|
}
|
||||||
|
reader = body.getReader();
|
||||||
|
|
||||||
// Read and forward stream
|
// Read and forward stream
|
||||||
while (!isClosed) {
|
while (!isClosed) {
|
||||||
|
|
@ -60,15 +61,15 @@ export async function GET(request: NextRequest) {
|
||||||
if (!isClosed) {
|
if (!isClosed) {
|
||||||
try {
|
try {
|
||||||
controller.enqueue(value);
|
controller.enqueue(value);
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Controller closed, stop reading
|
// Controller closed, stop reading
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
// Only log non-abort errors
|
// Only log non-abort errors
|
||||||
if (error.name !== 'AbortError') {
|
if ((error as Error).name !== 'AbortError') {
|
||||||
console.error('Stream error:', error);
|
console.error('Stream error:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +78,7 @@ export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const errorMessage = `data: ${JSON.stringify({ type: 'error', error: String(error) })}\n\n`;
|
const errorMessage = `data: ${JSON.stringify({ type: 'error', error: String(error) })}\n\n`;
|
||||||
controller.enqueue(encoder.encode(errorMessage));
|
controller.enqueue(encoder.encode(errorMessage));
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Controller already closed, ignore
|
// Controller already closed, ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +87,7 @@ export async function GET(request: NextRequest) {
|
||||||
if (reader) {
|
if (reader) {
|
||||||
try {
|
try {
|
||||||
await reader.cancel();
|
await reader.cancel();
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Ignore cancel errors
|
// Ignore cancel errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +95,7 @@ export async function GET(request: NextRequest) {
|
||||||
if (!isClosed) {
|
if (!isClosed) {
|
||||||
try {
|
try {
|
||||||
controller.close();
|
controller.close();
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Already closed, ignore
|
// Already closed, ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,4 +111,3 @@ export async function GET(request: NextRequest) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,9 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "RowboatX",
|
||||||
description: "Generated by create next app",
|
description: "RowboatX interface",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|
@ -24,9 +13,7 @@ export default function RootLayout({
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body className="antialiased" suppressHydrationWarning>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,8 @@ import {
|
||||||
ArtifactHeader,
|
ArtifactHeader,
|
||||||
ArtifactTitle,
|
ArtifactTitle,
|
||||||
} from "@/components/ai-elements/artifact";
|
} 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 { MicIcon, Save, Loader2, Lock } from "lucide-react";
|
||||||
import { RunEvent } from "@/lib/cli-client";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -57,6 +56,9 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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 {
|
interface ChatMessage {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -70,8 +72,8 @@ interface ToolCall {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'tool';
|
type: 'tool';
|
||||||
name: string;
|
name: string;
|
||||||
input: any;
|
input: unknown;
|
||||||
result?: any;
|
result?: unknown;
|
||||||
status: 'pending' | 'running' | 'completed' | 'error';
|
status: 'pending' | 'running' | 'completed' | 'error';
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
@ -93,15 +95,28 @@ type SelectedResource = {
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ToolCallContentPart = {
|
||||||
|
type: 'tool-call';
|
||||||
|
toolCallId: string;
|
||||||
|
toolName: string;
|
||||||
|
arguments: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RunEvent = {
|
||||||
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
function PageBody() {
|
function PageBody() {
|
||||||
// Use local proxy to avoid CORS/port mismatches.
|
|
||||||
const apiBase = "/api/cli";
|
const apiBase = "/api/cli";
|
||||||
|
const streamUrl = "/api/stream";
|
||||||
const [text, setText] = useState<string>("");
|
const [text, setText] = useState<string>("");
|
||||||
const [useMicrophone, setUseMicrophone] = useState<boolean>(false);
|
const [useMicrophone, setUseMicrophone] = useState<boolean>(false);
|
||||||
const [status, setStatus] = useState<"submitted" | "streaming" | "ready" | "error">("ready");
|
const [status, setStatus] = useState<"submitted" | "streaming" | "ready" | "error">("ready");
|
||||||
|
|
||||||
// Chat state
|
// Chat state
|
||||||
const [runId, setRunId] = useState<string | null>(null);
|
const [runId, setRunId] = useState<string | null>(null);
|
||||||
|
const [isRunProcessing, setIsRunProcessing] = useState(false);
|
||||||
const [conversation, setConversation] = useState<ConversationItem[]>([]);
|
const [conversation, setConversation] = useState<ConversationItem[]>([]);
|
||||||
const [currentAssistantMessage, setCurrentAssistantMessage] = useState<string>("");
|
const [currentAssistantMessage, setCurrentAssistantMessage] = useState<string>("");
|
||||||
const [currentReasoning, setCurrentReasoning] = useState<string>("");
|
const [currentReasoning, setCurrentReasoning] = useState<string>("");
|
||||||
|
|
@ -117,23 +132,22 @@ function PageBody() {
|
||||||
const [artifactLoading, setArtifactLoading] = useState(false);
|
const [artifactLoading, setArtifactLoading] = useState(false);
|
||||||
const [artifactError, setArtifactError] = useState<string | null>(null);
|
const [artifactError, setArtifactError] = useState<string | null>(null);
|
||||||
const [artifactReadOnly, setArtifactReadOnly] = useState(false);
|
const [artifactReadOnly, setArtifactReadOnly] = useState(false);
|
||||||
|
const [artifactFileType, setArtifactFileType] = useState<"json" | "markdown">("json");
|
||||||
const [agentOptions, setAgentOptions] = useState<string[]>(["copilot"]);
|
const [agentOptions, setAgentOptions] = useState<string[]>(["copilot"]);
|
||||||
const [selectedAgent, setSelectedAgent] = useState<string>("copilot");
|
const [selectedAgent, setSelectedAgent] = useState<string>("copilot");
|
||||||
|
|
||||||
const artifactDirty = !artifactReadOnly && artifactText !== artifactOriginal;
|
const artifactDirty = !artifactReadOnly && artifactText !== artifactOriginal;
|
||||||
const stripExtension = (name: string) => name.replace(/\.[^/.]+$/, "");
|
const stripExtension = (name: string) => name.replace(/\.[^/.]+$/, "");
|
||||||
|
const detectFileType = (name: string): "json" | "markdown" =>
|
||||||
|
name.toLowerCase().match(/\.(md|markdown)$/) ? "markdown" : "json";
|
||||||
|
|
||||||
const requestJson = async (
|
const requestJson = async (
|
||||||
url: string,
|
url: string,
|
||||||
options?: (RequestInit & { allow404?: boolean }) | undefined
|
options?: (RequestInit & { allow404?: boolean }) | undefined
|
||||||
) => {
|
) => {
|
||||||
const isLocalApi = url.startsWith("/api/rowboat");
|
const fullUrl = url.startsWith("/api/")
|
||||||
const fullUrl =
|
? url
|
||||||
url.startsWith("http://") || url.startsWith("https://") || isLocalApi
|
: `${apiBase}${url.startsWith("/") ? url : `/${url}`}`;
|
||||||
? url
|
|
||||||
: apiBase
|
|
||||||
? `${apiBase}${url}`
|
|
||||||
: url;
|
|
||||||
const { allow404, ...rest } = options || {};
|
const { allow404, ...rest } = options || {};
|
||||||
const res = await fetch(fullUrl, {
|
const res = await fetch(fullUrl, {
|
||||||
...rest,
|
...rest,
|
||||||
|
|
@ -241,7 +255,7 @@ function PageBody() {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔌 Creating new EventSource connection');
|
console.log('🔌 Creating new EventSource connection');
|
||||||
const eventSource = new EventSource('/api/stream');
|
const eventSource = new EventSource(streamUrl);
|
||||||
eventSourceRef.current = eventSource;
|
eventSourceRef.current = eventSource;
|
||||||
|
|
||||||
const handleMessage = (e: MessageEvent) => {
|
const handleMessage = (e: MessageEvent) => {
|
||||||
|
|
@ -276,13 +290,23 @@ function PageBody() {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
};
|
};
|
||||||
}, []); // Empty deps - only run once
|
}, [streamUrl]);
|
||||||
|
|
||||||
// Handle different event types from the copilot
|
// Handle different event types from the copilot
|
||||||
const handleEvent = (event: RunEvent) => {
|
const handleEvent = (event: RunEvent) => {
|
||||||
console.log('Event received:', event.type, event);
|
console.log('Event received:', event.type, event);
|
||||||
|
|
||||||
switch (event.type) {
|
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':
|
case 'start':
|
||||||
setStatus('streaming');
|
setStatus('streaming');
|
||||||
setCurrentAssistantMessage('');
|
setCurrentAssistantMessage('');
|
||||||
|
|
@ -290,126 +314,142 @@ function PageBody() {
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'llm-stream-event':
|
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') {
|
if (llmEvent.type === 'reasoning-delta' && llmEvent.delta) {
|
||||||
setCurrentReasoning(prev => prev + event.event.delta);
|
setCurrentReasoning(prev => prev + llmEvent.delta);
|
||||||
} else if (event.event?.type === 'reasoning-end') {
|
} else if (llmEvent.type === 'reasoning-end') {
|
||||||
// Commit reasoning block if we have content
|
// Commit reasoning block if we have content
|
||||||
setCurrentReasoning(reasoning => {
|
setCurrentReasoning(reasoning => {
|
||||||
if (reasoning) {
|
if (reasoning) {
|
||||||
setConversation(prev => [...prev, {
|
setConversation(prev => [...prev, {
|
||||||
id: `reasoning-${Date.now()}`,
|
id: `reasoning-${Date.now()}`,
|
||||||
type: 'reasoning',
|
type: 'reasoning',
|
||||||
content: reasoning,
|
content: reasoning,
|
||||||
isStreaming: false,
|
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,
|
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
}];
|
}]);
|
||||||
});
|
}
|
||||||
}
|
return '';
|
||||||
return '';
|
});
|
||||||
});
|
} else if (llmEvent.type === 'text-delta' && llmEvent.delta) {
|
||||||
setStatus('ready');
|
setCurrentAssistantMessage(prev => prev + llmEvent.delta);
|
||||||
console.log('Status set to ready');
|
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;
|
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':
|
case 'tool-invocation':
|
||||||
setConversation(prev => prev.map(item =>
|
setConversation(prev => prev.map(item =>
|
||||||
item.type === 'tool' && (item.id === event.toolCallId || item.name === event.toolName)
|
item.type === 'tool' && (item.id === event.toolCallId || item.name === event.toolName)
|
||||||
|
|
@ -428,13 +468,17 @@ function PageBody() {
|
||||||
|
|
||||||
case 'error':
|
case 'error':
|
||||||
// Only set error status for actual errors, not connection issues
|
// 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');
|
setStatus('error');
|
||||||
console.error('Agent error:', event.error);
|
console.error('Agent error:', errorMsg);
|
||||||
} else {
|
} else {
|
||||||
console.log('Connection error (will auto-reconnect):', event.error);
|
console.log('Connection error (will auto-reconnect):', errorMsg);
|
||||||
setStatus('ready');
|
setStatus('ready');
|
||||||
}
|
}
|
||||||
|
setIsRunProcessing(false);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
@ -466,28 +510,29 @@ function PageBody() {
|
||||||
setText("");
|
setText("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Send message to backend
|
let nextRunId = runId;
|
||||||
const response = await fetch('/api/chat', {
|
if (!nextRunId) {
|
||||||
method: 'POST',
|
const runData = await requestJson("/runs/new", {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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({
|
body: JSON.stringify({
|
||||||
message: userMessage,
|
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');
|
setStatus('streaming');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send message:', error);
|
console.error('Failed to send message:', error);
|
||||||
|
|
@ -503,34 +548,96 @@ function PageBody() {
|
||||||
setArtifactLoading(true);
|
setArtifactLoading(true);
|
||||||
setArtifactError(null);
|
setArtifactError(null);
|
||||||
try {
|
try {
|
||||||
let title = selectedResource.name;
|
const title = selectedResource.name;
|
||||||
let subtitle = "";
|
let subtitle = "";
|
||||||
let text = "";
|
let text = "";
|
||||||
let readOnly = false;
|
let readOnly = false;
|
||||||
|
const detectedType = detectFileType(selectedResource.name);
|
||||||
|
setArtifactFileType(detectedType);
|
||||||
|
|
||||||
if (selectedResource.kind === "agent") {
|
if (selectedResource.kind === "agent") {
|
||||||
const raw = selectedResource.name;
|
const raw = selectedResource.name;
|
||||||
const id = stripExtension(raw) || raw;
|
const isMarkdown = /\.(md|markdown)$/i.test(raw);
|
||||||
const data = await requestJson(`/agents/${encodeURIComponent(id)}`);
|
|
||||||
|
|
||||||
subtitle = "Agent";
|
if (isMarkdown) {
|
||||||
text = JSON.stringify(data ?? {}, null, 2);
|
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") {
|
} else if (selectedResource.kind === "config") {
|
||||||
const lower = selectedResource.name.toLowerCase();
|
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");
|
const data = await requestJson("/mcp");
|
||||||
subtitle = "MCP config";
|
subtitle = "MCP config";
|
||||||
text = JSON.stringify(data ?? {}, null, 2);
|
text = JSON.stringify(data ?? {}, null, 2);
|
||||||
|
setArtifactFileType("json");
|
||||||
} else if (lower.includes("model")) {
|
} else if (lower.includes("model")) {
|
||||||
const data = await requestJson("/models");
|
const data = await requestJson("/models");
|
||||||
subtitle = "Models config";
|
subtitle = "Models config";
|
||||||
text = JSON.stringify(data ?? {}, null, 2);
|
text = JSON.stringify(data ?? {}, null, 2);
|
||||||
|
setArtifactFileType("json");
|
||||||
} else {
|
} 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") {
|
} else if (selectedResource.kind === "run") {
|
||||||
subtitle = "Run (read-only)";
|
subtitle = "Run (read-only)";
|
||||||
readOnly = true;
|
readOnly = true;
|
||||||
|
setArtifactFileType(detectedType);
|
||||||
|
|
||||||
const local = await requestJson(
|
const local = await requestJson(
|
||||||
`/api/rowboat/run?file=${encodeURIComponent(selectedResource.name)}`
|
`/api/rowboat/run?file=${encodeURIComponent(selectedResource.name)}`
|
||||||
|
|
@ -550,9 +657,10 @@ function PageBody() {
|
||||||
setArtifactText(text);
|
setArtifactText(text);
|
||||||
setArtifactOriginal(text);
|
setArtifactOriginal(text);
|
||||||
setArtifactReadOnly(readOnly);
|
setArtifactReadOnly(readOnly);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setArtifactError(error?.message || "Failed to load resource");
|
const err = error as Error;
|
||||||
|
setArtifactError(err?.message || "Failed to load resource");
|
||||||
setArtifactText("");
|
setArtifactText("");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -565,7 +673,6 @@ function PageBody() {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [selectedResource]);
|
}, [selectedResource]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -592,6 +699,7 @@ function PageBody() {
|
||||||
setConversation([]);
|
setConversation([]);
|
||||||
setCurrentAssistantMessage("");
|
setCurrentAssistantMessage("");
|
||||||
setCurrentReasoning("");
|
setCurrentReasoning("");
|
||||||
|
setIsRunProcessing(false);
|
||||||
}, [selectedAgent]);
|
}, [selectedAgent]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
|
@ -599,67 +707,102 @@ function PageBody() {
|
||||||
setArtifactLoading(true);
|
setArtifactLoading(true);
|
||||||
setArtifactError(null);
|
setArtifactError(null);
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(artifactText);
|
|
||||||
if (selectedResource.kind === "agent") {
|
if (selectedResource.kind === "agent") {
|
||||||
const raw = selectedResource.name;
|
if (artifactFileType === "markdown") {
|
||||||
const targetId = stripExtension(raw) || raw;
|
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)}`, {
|
await requestJson(`/agents/${encodeURIComponent(targetId)}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(parsed),
|
body: JSON.stringify(parsed),
|
||||||
});
|
});
|
||||||
|
setArtifactOriginal(JSON.stringify(parsed, null, 2));
|
||||||
|
}
|
||||||
} else if (selectedResource.kind === "config") {
|
} else if (selectedResource.kind === "config") {
|
||||||
const lower = selectedResource.name.toLowerCase();
|
const lower = selectedResource.name.toLowerCase();
|
||||||
const previous = artifactOriginal ? JSON.parse(artifactOriginal) : {};
|
|
||||||
|
if (lower.endsWith(".md") || lower.endsWith(".markdown")) {
|
||||||
if (lower.includes("model")) {
|
// Save markdown file as plain text via local API
|
||||||
const newProviders = parsed.providers || {};
|
const response = await fetch(
|
||||||
const oldProviders = previous.providers || {};
|
`/api/rowboat/config?file=${encodeURIComponent(selectedResource.name)}`,
|
||||||
const toDelete = Object.keys(oldProviders).filter(
|
{
|
||||||
(name) => !Object.prototype.hasOwnProperty.call(newProviders, name)
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
body: artifactText,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
for (const name of toDelete) {
|
if (!response.ok) {
|
||||||
await requestJson(`/models/providers/${encodeURIComponent(name)}`, {
|
throw new Error("Failed to save markdown file");
|
||||||
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]),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
setArtifactOriginal(artifactText);
|
||||||
} else {
|
} 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
setArtifactOriginal(JSON.stringify(JSON.parse(artifactText), null, 2));
|
const err = error as Error;
|
||||||
} catch (error: any) {
|
setArtifactError(err?.message || "Failed to save changes");
|
||||||
setArtifactError(error?.message || "Failed to save changes");
|
|
||||||
} finally {
|
} finally {
|
||||||
setArtifactLoading(false);
|
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="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">
|
<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 */}
|
{/* Messages area */}
|
||||||
<Conversation className="flex-1 min-h-0 overflow-y-auto">
|
<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" />
|
<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>
|
</Message>
|
||||||
);
|
);
|
||||||
} else if (item.type === 'tool') {
|
} else if (item.type === 'tool') {
|
||||||
const stateMap: Record<string, any> = {
|
const stateMap: Record<ToolCall['status'], 'input-streaming' | 'input-available' | 'output-available' | 'output-error'> = {
|
||||||
'pending': 'input-streaming',
|
pending: 'input-streaming',
|
||||||
'running': 'input-available',
|
running: 'input-available',
|
||||||
'completed': 'output-available',
|
completed: 'output-available',
|
||||||
'error': 'output-error',
|
error: 'output-error',
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="mb-2">
|
<div key={item.id} className="mb-2">
|
||||||
<Tool>
|
<Tool>
|
||||||
<ToolHeader
|
<ToolHeader
|
||||||
title={item.name}
|
title={item.name}
|
||||||
type="tool-call"
|
type="tool-call"
|
||||||
state={stateMap[item.status] || 'input-streaming'}
|
state={stateMap[item.status] || 'input-streaming'}
|
||||||
/>
|
/>
|
||||||
<ToolContent>
|
<ToolContent>
|
||||||
<ToolInput input={item.input} />
|
<ToolInput input={item.input} />
|
||||||
{item.result && (
|
{item.result != null && (
|
||||||
<ToolOutput output={item.result} errorText={undefined} />
|
<ToolOutput
|
||||||
|
output={item.result as ReactNode}
|
||||||
|
errorText={undefined}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</ToolContent>
|
</ToolContent>
|
||||||
</Tool>
|
</Tool>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (item.type === 'reasoning') {
|
} else if (item.type === 'reasoning') {
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="mb-2">
|
<div key={item.id} className="mb-2">
|
||||||
|
|
@ -843,15 +995,25 @@ function PageBody() {
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col gap-2">
|
<div className="flex h-full flex-col gap-2">
|
||||||
{artifactReadOnly ? (
|
{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">
|
artifactFileType === "markdown" ? (
|
||||||
{artifactText}
|
<MarkdownViewer content={artifactText} />
|
||||||
</pre>
|
) : (
|
||||||
|
<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
|
<JsonEditor
|
||||||
value={artifactText}
|
content={artifactText}
|
||||||
onChange={(e) => setArtifactText(e.target.value)}
|
onChange={(newContent) => setArtifactText(newContent)}
|
||||||
readOnly={artifactReadOnly}
|
readOnly={false}
|
||||||
className="h-full min-h-[240px] max-h-[70vh] w-full resize-none rounded-md border bg-background p-4 font-mono text-sm leading-relaxed text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{artifactReadOnly && (
|
{artifactReadOnly && (
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,13 @@ export const CheckpointTrigger = ({
|
||||||
tooltip ? (
|
tooltip ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button size={size} type="button" variant={variant} {...props}>
|
<Button
|
||||||
|
className={cn(className)}
|
||||||
|
size={size}
|
||||||
|
type="button"
|
||||||
|
variant={variant}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -62,7 +68,13 @@ export const CheckpointTrigger = ({
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Button size={size} type="button" variant={variant} {...props}>
|
<Button
|
||||||
|
className={cn(className)}
|
||||||
|
size={size}
|
||||||
|
type="button"
|
||||||
|
variant={variant}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Experimental_GeneratedImage } from "ai";
|
import type { Experimental_GeneratedImage } from "ai";
|
||||||
|
|
||||||
|
|
@ -8,7 +9,6 @@ export type ImageProps = Experimental_GeneratedImage & {
|
||||||
|
|
||||||
export const Image = ({
|
export const Image = ({
|
||||||
base64,
|
base64,
|
||||||
uint8Array,
|
|
||||||
mediaType,
|
mediaType,
|
||||||
...props
|
...props
|
||||||
}: ImageProps) => (
|
}: ImageProps) => (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
|
|
@ -20,7 +21,7 @@ import {
|
||||||
XIcon,
|
XIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
import { createContext, memo, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { Streamdown } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
|
@ -188,7 +189,10 @@ export const MessageBranchContent = ({
|
||||||
...props
|
...props
|
||||||
}: MessageBranchContentProps) => {
|
}: MessageBranchContentProps) => {
|
||||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||||
const childrenArray = Array.isArray(children) ? children : [children];
|
const childrenArray = useMemo(
|
||||||
|
() => (Array.isArray(children) ? children : [children]),
|
||||||
|
[children]
|
||||||
|
);
|
||||||
|
|
||||||
// Use useEffect to update branches when they change
|
// Use useEffect to update branches when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -211,13 +215,10 @@ export const MessageBranchContent = ({
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement>;
|
||||||
from: UIMessage["role"];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MessageBranchSelector = ({
|
export const MessageBranchSelector = ({
|
||||||
className,
|
className,
|
||||||
from,
|
|
||||||
...props
|
...props
|
||||||
}: MessageBranchSelectorProps) => {
|
}: MessageBranchSelectorProps) => {
|
||||||
const { totalBranches } = useMessageBranch();
|
const { totalBranches } = useMessageBranch();
|
||||||
|
|
@ -229,7 +230,10 @@ export const MessageBranchSelector = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ButtonGroup
|
<ButtonGroup
|
||||||
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
|
className={cn(
|
||||||
|
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|
@ -276,6 +280,7 @@ export const MessageBranchNext = ({
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
className={className}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children ?? <ChevronRightIcon size={14} />}
|
{children ?? <ChevronRightIcon size={14} />}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
|
|
@ -666,7 +667,6 @@ export const PromptInput = ({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- cleanup only on unmount; filesRef always current
|
|
||||||
[usingProvider]
|
[usingProvider]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -726,7 +726,7 @@ export const PromptInput = ({
|
||||||
|
|
||||||
// Convert blob URLs to data URLs asynchronously
|
// Convert blob URLs to data URLs asynchronously
|
||||||
Promise.all(
|
Promise.all(
|
||||||
files.map(async ({ id, ...item }) => {
|
files.map(async ({ ...item }) => {
|
||||||
if (item.url && item.url.startsWith("blob:")) {
|
if (item.url && item.url.startsWith("blob:")) {
|
||||||
const dataUrl = await convertBlobUrlToDataUrl(item.url);
|
const dataUrl = await convertBlobUrlToDataUrl(item.url);
|
||||||
// If conversion failed, keep the original blob URL
|
// If conversion failed, keep the original blob URL
|
||||||
|
|
@ -1060,13 +1060,13 @@ interface SpeechRecognition extends EventTarget {
|
||||||
lang: string;
|
lang: string;
|
||||||
start(): void;
|
start(): void;
|
||||||
stop(): void;
|
stop(): void;
|
||||||
onstart: ((this: SpeechRecognition, ev: Event) => any) | null;
|
onstart: ((this: SpeechRecognition, ev: Event) => void) | null;
|
||||||
onend: ((this: SpeechRecognition, ev: Event) => any) | null;
|
onend: ((this: SpeechRecognition, ev: Event) => void) | null;
|
||||||
onresult:
|
onresult:
|
||||||
| ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => any)
|
| ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void)
|
||||||
| null;
|
| null;
|
||||||
onerror:
|
onerror:
|
||||||
| ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => any)
|
| ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void)
|
||||||
| null;
|
| null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/* eslint-disable @next/next/no-img-element */
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { ChevronRight, Clock3, FileText, Folder, Play, Plug, Rocket, Users } fro
|
||||||
|
|
||||||
import { NavUser } from "@/components/nav-user"
|
import { NavUser } from "@/components/nav-user"
|
||||||
import { TeamSwitcher } from "@/components/team-switcher"
|
import { TeamSwitcher } from "@/components/team-switcher"
|
||||||
import { NavMain } from "@/components/nav-main"
|
|
||||||
import { NavProjects } from "@/components/nav-projects"
|
import { NavProjects } from "@/components/nav-projects"
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
|
|
|
||||||
197
apps/rowboatx/components/json-editor.css
Normal file
197
apps/rowboatx/components/json-editor.css
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
.json-editor-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 240px;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--background));
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode wrapper */
|
||||||
|
.dark .json-editor-wrapper {
|
||||||
|
background: hsl(var(--background));
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.json-editor-wrapper {
|
||||||
|
background: hsl(var(--background));
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-line-numbers {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 3.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-right: 1px solid hsl(var(--border) / 0.3);
|
||||||
|
background: hsl(var(--muted) / 0.03);
|
||||||
|
padding: 1rem 0.75rem 1rem 0.5rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-line-number {
|
||||||
|
color: hsl(var(--muted-foreground) / 0.6);
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
height: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode line numbers */
|
||||||
|
.dark .json-editor-line-numbers {
|
||||||
|
background: hsl(var(--muted) / 0.15);
|
||||||
|
border-right-color: hsl(var(--border) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .json-editor-line-number {
|
||||||
|
color: hsl(var(--muted-foreground) / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.json-editor-line-numbers {
|
||||||
|
background: hsl(var(--muted) / 0.15);
|
||||||
|
border-right-color: hsl(var(--border) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-line-number {
|
||||||
|
color: hsl(var(--muted-foreground) / 0.7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-content:focus-visible {
|
||||||
|
outline: 2px solid hsl(var(--ring) / 0.6);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code block styling */
|
||||||
|
.json-editor-content pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem 1rem 1rem 0.5rem;
|
||||||
|
background: transparent !important;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
word-wrap: normal;
|
||||||
|
tab-size: 2;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-content pre code {
|
||||||
|
background: transparent !important;
|
||||||
|
padding: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
white-space: pre;
|
||||||
|
word-wrap: normal;
|
||||||
|
min-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Syntax highlighting for JSON - Light theme */
|
||||||
|
.json-editor-content .hljs-attr {
|
||||||
|
color: #0969da;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-content .hljs-string {
|
||||||
|
color: #0a3069;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-content .hljs-number,
|
||||||
|
.json-editor-content .hljs-literal {
|
||||||
|
color: #0550ae;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-content .hljs-punctuation {
|
||||||
|
color: hsl(var(--foreground) / 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-content .hljs-keyword {
|
||||||
|
color: #cf222e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-content .hljs-comment {
|
||||||
|
color: #6e7781;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support - Class-based */
|
||||||
|
.dark .json-editor-content .hljs-attr {
|
||||||
|
color: #79c0ff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .json-editor-content .hljs-string {
|
||||||
|
color: #a5d6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .json-editor-content .hljs-number,
|
||||||
|
.dark .json-editor-content .hljs-literal {
|
||||||
|
color: #79c0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .json-editor-content .hljs-punctuation {
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .json-editor-content .hljs-keyword {
|
||||||
|
color: #ff7b72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .json-editor-content .hljs-comment {
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support - Media query */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.json-editor-content .hljs-attr {
|
||||||
|
color: #79c0ff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-content .hljs-string {
|
||||||
|
color: #a5d6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-content .hljs-number,
|
||||||
|
.json-editor-content .hljs-literal {
|
||||||
|
color: #79c0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-content .hljs-punctuation {
|
||||||
|
color: #c9d1d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-content .hljs-keyword {
|
||||||
|
color: #ff7b72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.json-editor-content .hljs-comment {
|
||||||
|
color: #8b949e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
92
apps/rowboatx/components/json-editor.tsx
Normal file
92
apps/rowboatx/components/json-editor.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||||
|
import { common, createLowlight } from "lowlight";
|
||||||
|
import "./json-editor.css";
|
||||||
|
|
||||||
|
const lowlight = createLowlight(common);
|
||||||
|
|
||||||
|
interface JsonEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange: (content: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonEditor({ content, onChange, readOnly = false }: JsonEditorProps) {
|
||||||
|
const [lineCount, setLineCount] = useState(1);
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
codeBlock: false, // Disable default code block
|
||||||
|
}),
|
||||||
|
CodeBlockLowlight.configure({
|
||||||
|
lowlight,
|
||||||
|
defaultLanguage: "json",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
immediatelyRender: false,
|
||||||
|
editable: !readOnly,
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: "json-editor-content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
// Extract text content from the code block
|
||||||
|
const text = editor.getText();
|
||||||
|
onChange(text);
|
||||||
|
// Update line count
|
||||||
|
setLineCount(text.split("\n").length || 1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial content and update when content prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const currentText = editor.getText().trim();
|
||||||
|
if (currentText !== content.trim()) {
|
||||||
|
// Set content using ProseMirror JSON structure
|
||||||
|
editor.commands.setContent({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "codeBlock",
|
||||||
|
attrs: {
|
||||||
|
language: "json",
|
||||||
|
},
|
||||||
|
content: content ? [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: content,
|
||||||
|
},
|
||||||
|
] : [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
setLineCount(content.split("\n").length || 1);
|
||||||
|
}
|
||||||
|
}, [editor, content]);
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="json-editor-wrapper">
|
||||||
|
<div className="json-editor-line-numbers">
|
||||||
|
{Array.from({ length: lineCount }, (_, i) => (
|
||||||
|
<div key={i} className="json-editor-line-number">
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
141
apps/rowboatx/components/markdown-viewer.css
Normal file
141
apps/rowboatx/components/markdown-viewer.css
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
.markdown-viewer-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 240px;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--background));
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content p {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content p:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h1,
|
||||||
|
.markdown-content h2,
|
||||||
|
.markdown-content h3,
|
||||||
|
.markdown-content h4,
|
||||||
|
.markdown-content h5,
|
||||||
|
.markdown-content h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h1 {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul,
|
||||||
|
.markdown-content ol {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content li {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content code {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content blockquote {
|
||||||
|
border-left: 4px solid hsl(var(--border));
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content table th,
|
||||||
|
.markdown-content table td {
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content table th {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
21
apps/rowboatx/components/markdown-viewer.tsx
Normal file
21
apps/rowboatx/components/markdown-viewer.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import "./markdown-viewer.css";
|
||||||
|
|
||||||
|
interface MarkdownViewerProps {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownViewer({ content }: MarkdownViewerProps) {
|
||||||
|
return (
|
||||||
|
<div className="markdown-viewer-wrapper markdown-content">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
290
apps/rowboatx/components/tiptap-markdown-editor.css
Normal file
290
apps/rowboatx/components/tiptap-markdown-editor.css
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
.tiptap-markdown-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 14% 20%, hsl(var(--primary) / 0.08), transparent 32%),
|
||||||
|
radial-gradient(circle at 86% 10%, hsl(var(--primary) / 0.06), transparent 30%),
|
||||||
|
hsl(var(--background));
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
min-height: 460px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
background: linear-gradient(120deg, hsl(var(--background)), hsl(var(--muted) / 0.4));
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-toolbar-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-toolbar-separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 28px;
|
||||||
|
background: hsl(var(--border));
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-toolbar-button {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: hsl(var(--muted) / 0.4);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
transition: border-color 0.18s ease, background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-toolbar-button:hover:not(:disabled) {
|
||||||
|
border-color: hsl(var(--primary) / 0.65);
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
box-shadow: 0 4px 16px hsl(var(--primary) / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-toolbar-button.is-active {
|
||||||
|
background: hsl(var(--primary) / 0.12);
|
||||||
|
border-color: hsl(var(--primary));
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-toolbar-button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-toolbar-pill {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--muted) / 0.4);
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor-pane {
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(180deg, hsl(var(--background)), hsl(var(--muted) / 0.2));
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 360px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: 72vh;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor-surface {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-pane-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-pane-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-pane-hint {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-pill {
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--primary) / 0.15);
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
border: 1px solid hsl(var(--primary) / 0.4);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 300px;
|
||||||
|
max-height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.35rem 0.35rem 0.75rem;
|
||||||
|
font-size: 0.96rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
caret-color: hsl(var(--foreground));
|
||||||
|
min-height: 0;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--border)) transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-editor-surface .tiptap-markdown-editor-content,
|
||||||
|
.tiptap-editor-surface .ProseMirror {
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content::selection,
|
||||||
|
.tiptap-markdown-editor-content *::selection {
|
||||||
|
background: rgba(99, 102, 241, 0.55);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--border));
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--border) / 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content p {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content h1,
|
||||||
|
.tiptap-markdown-editor-content h2,
|
||||||
|
.tiptap-markdown-editor-content h3 {
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.25;
|
||||||
|
margin-top: 1.4rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content h3 {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content em {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content ul,
|
||||||
|
.tiptap-markdown-editor-content ol {
|
||||||
|
padding-left: 1.3rem;
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content li {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content code {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
padding: 0.15rem 0.35rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content pre {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1.1rem 0;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content pre code {
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content blockquote {
|
||||||
|
border-left: 4px solid hsl(var(--primary));
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px solid hsl(var(--border));
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content a {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content a:hover {
|
||||||
|
color: hsl(var(--primary) / 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap-markdown-editor-content p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
262
apps/rowboatx/components/tiptap-markdown-editor.tsx
Normal file
262
apps/rowboatx/components/tiptap-markdown-editor.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import Link from "@tiptap/extension-link";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import TurndownService from "turndown";
|
||||||
|
import { marked } from "marked";
|
||||||
|
import {
|
||||||
|
Bold,
|
||||||
|
Code2,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Italic,
|
||||||
|
Link2,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Minus,
|
||||||
|
Quote,
|
||||||
|
Redo2,
|
||||||
|
Strikethrough,
|
||||||
|
Undo2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import "./tiptap-markdown-editor.css";
|
||||||
|
|
||||||
|
interface TiptapMarkdownEditorProps {
|
||||||
|
content: string;
|
||||||
|
onChange: (content: string) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure marked to parse markdown
|
||||||
|
marked.setOptions({
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure turndown to convert HTML back to markdown
|
||||||
|
const turndownService = new TurndownService({
|
||||||
|
headingStyle: "atx",
|
||||||
|
codeBlockStyle: "fenced",
|
||||||
|
});
|
||||||
|
|
||||||
|
type ToolbarButtonProps = {
|
||||||
|
icon: LucideIcon;
|
||||||
|
label: string;
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ToolbarButton({ icon: Icon, label, active, disabled, onClick }: ToolbarButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`tiptap-toolbar-button ${active ? "is-active" : ""}`}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Icon size={15} strokeWidth={2.25} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TiptapMarkdownEditor({
|
||||||
|
content,
|
||||||
|
onChange,
|
||||||
|
readOnly = false,
|
||||||
|
placeholder = "Start typing...",
|
||||||
|
}: TiptapMarkdownEditorProps) {
|
||||||
|
const editor = useEditor({
|
||||||
|
immediatelyRender: false,
|
||||||
|
content: content ? (marked.parse(content) as string) : "",
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
heading: {
|
||||||
|
levels: [1, 2, 3],
|
||||||
|
},
|
||||||
|
codeBlock: {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "code-block",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder,
|
||||||
|
emptyEditorClass: "is-editor-empty",
|
||||||
|
}),
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
linkOnPaste: true,
|
||||||
|
autolink: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: "tiptap-markdown-editor-content",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editable: !readOnly,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
const html = editor.getHTML();
|
||||||
|
const markdown = turndownService.turndown(html);
|
||||||
|
onChange(markdown);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep editor content in sync when a new artifact is selected
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
|
||||||
|
const currentMarkdown = turndownService.turndown(editor.getHTML());
|
||||||
|
if ((currentMarkdown || "").trim() === (content || "").trim()) return;
|
||||||
|
|
||||||
|
editor.commands.setContent(content ? (marked.parse(content) as string) : "");
|
||||||
|
}, [editor, content]);
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLink = () => {
|
||||||
|
const previousUrl = editor.getAttributes("link").href as string | undefined;
|
||||||
|
const url = window.prompt("Paste or type a link", previousUrl ?? "");
|
||||||
|
|
||||||
|
if (url === null) return;
|
||||||
|
if (url === "") {
|
||||||
|
editor.chain().focus().unsetLink().run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tiptap-markdown-editor">
|
||||||
|
{!readOnly && (
|
||||||
|
<div className="tiptap-markdown-toolbar">
|
||||||
|
<div className="tiptap-toolbar-group">
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Undo2}
|
||||||
|
label="Undo"
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
disabled={!editor.can().undo()}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Redo2}
|
||||||
|
label="Redo"
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
disabled={!editor.can().redo()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="tiptap-toolbar-separator" aria-hidden />
|
||||||
|
<div className="tiptap-toolbar-group">
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Bold}
|
||||||
|
label="Bold"
|
||||||
|
active={editor.isActive("bold")}
|
||||||
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Italic}
|
||||||
|
label="Italic"
|
||||||
|
active={editor.isActive("italic")}
|
||||||
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Strikethrough}
|
||||||
|
label="Strike"
|
||||||
|
active={editor.isActive("strike")}
|
||||||
|
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Code2}
|
||||||
|
label="Code"
|
||||||
|
active={editor.isActive("code")}
|
||||||
|
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="tiptap-toolbar-separator" aria-hidden />
|
||||||
|
<div className="tiptap-toolbar-group">
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Heading1}
|
||||||
|
label="Heading 1"
|
||||||
|
active={editor.isActive("heading", { level: 1 })}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Heading2}
|
||||||
|
label="Heading 2"
|
||||||
|
active={editor.isActive("heading", { level: 2 })}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Heading3}
|
||||||
|
label="Heading 3"
|
||||||
|
active={editor.isActive("heading", { level: 3 })}
|
||||||
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="tiptap-toolbar-separator" aria-hidden />
|
||||||
|
<div className="tiptap-toolbar-group">
|
||||||
|
<ToolbarButton
|
||||||
|
icon={List}
|
||||||
|
label="Bullet list"
|
||||||
|
active={editor.isActive("bulletList")}
|
||||||
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={ListOrdered}
|
||||||
|
label="Numbered list"
|
||||||
|
active={editor.isActive("orderedList")}
|
||||||
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Quote}
|
||||||
|
label="Quote"
|
||||||
|
active={editor.isActive("blockquote")}
|
||||||
|
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Code2}
|
||||||
|
label="Code block"
|
||||||
|
active={editor.isActive("codeBlock")}
|
||||||
|
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Minus}
|
||||||
|
label="Divider"
|
||||||
|
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="tiptap-toolbar-separator" aria-hidden />
|
||||||
|
<div className="tiptap-toolbar-group">
|
||||||
|
<ToolbarButton
|
||||||
|
icon={Link2}
|
||||||
|
label="Link"
|
||||||
|
active={editor.isActive("link")}
|
||||||
|
onClick={handleLink}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="tiptap-toolbar-pill">Markdown</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="tiptap-editor-pane">
|
||||||
|
<div className="tiptap-pane-header">
|
||||||
|
<span className="tiptap-pane-title">Editor</span>
|
||||||
|
<span className="tiptap-pane-hint">Markdown + shortcuts</span>
|
||||||
|
</div>
|
||||||
|
<div className="tiptap-editor-surface">
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
/**
|
|
||||||
* Type-safe client for the Rowboat CLI backend
|
|
||||||
*/
|
|
||||||
|
|
||||||
const CLI_BASE_URL = process.env.CLI_BACKEND_URL || 'http://localhost:3000';
|
|
||||||
|
|
||||||
export interface Run {
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
agentId: string;
|
|
||||||
log: RunEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RunEvent {
|
|
||||||
type: string;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateRunOptions {
|
|
||||||
agentId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Agent {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
instructions: string;
|
|
||||||
tools: Record<string, any>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CLI Backend Client
|
|
||||||
*/
|
|
||||||
export class CliClient {
|
|
||||||
private baseUrl: string;
|
|
||||||
|
|
||||||
constructor(baseUrl: string = CLI_BASE_URL) {
|
|
||||||
this.baseUrl = baseUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new run (conversation)
|
|
||||||
*/
|
|
||||||
async createRun(options: CreateRunOptions): Promise<Run> {
|
|
||||||
const response = await fetch(`${this.baseUrl}/runs/new`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(options),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to create run: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a message to an existing run
|
|
||||||
*/
|
|
||||||
async sendMessage(runId: string, message: string): Promise<{ messageId: string }> {
|
|
||||||
const response = await fetch(`${this.baseUrl}/runs/${runId}/messages/new`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ message }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to send message: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a run by ID
|
|
||||||
*/
|
|
||||||
async getRun(runId: string): Promise<Run> {
|
|
||||||
const response = await fetch(`${this.baseUrl}/runs/${runId}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to get run: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all runs
|
|
||||||
*/
|
|
||||||
async listRuns(cursor?: string): Promise<{ runs: Run[]; nextCursor?: string }> {
|
|
||||||
const url = new URL(`${this.baseUrl}/runs`);
|
|
||||||
if (cursor) url.searchParams.set('cursor', cursor);
|
|
||||||
|
|
||||||
const response = await fetch(url.toString());
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to list runs: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get an agent by ID
|
|
||||||
*/
|
|
||||||
async getAgent(agentId: string): Promise<Agent> {
|
|
||||||
const response = await fetch(`${this.baseUrl}/agents/${agentId}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to get agent: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List all agents
|
|
||||||
*/
|
|
||||||
async listAgents(): Promise<Agent[]> {
|
|
||||||
const response = await fetch(`${this.baseUrl}/agents`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to list agents: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an SSE connection to receive real-time events
|
|
||||||
*/
|
|
||||||
createEventStream(onEvent: (event: RunEvent) => void, onError?: (error: Error) => void): () => void {
|
|
||||||
const eventSource = new EventSource(`${this.baseUrl}/stream`);
|
|
||||||
|
|
||||||
eventSource.addEventListener('message', (e) => {
|
|
||||||
try {
|
|
||||||
const event = JSON.parse(e.data) as RunEvent;
|
|
||||||
onEvent(event);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse event:', error);
|
|
||||||
onError?.(error as Error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
eventSource.addEventListener('error', (e) => {
|
|
||||||
console.error('SSE error:', e);
|
|
||||||
onError?.(new Error('SSE connection error'));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return cleanup function
|
|
||||||
return () => {
|
|
||||||
eventSource.close();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Singleton instance
|
|
||||||
export const cliClient = new CliClient();
|
|
||||||
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
turbopack: {
|
||||||
|
// Keep Turbopack scoped to this app instead of inferring a parent workspace root.
|
||||||
|
root: __dirname || path.join(process.cwd()),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|
|
||||||
902
apps/rowboatx/package-lock.json
generated
902
apps/rowboatx/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -3,8 +3,8 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev",
|
||||||
"build": "next build --turbopack",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
|
|
@ -22,22 +22,32 @@
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||||
|
"@tiptap/extension-code-block-lowlight": "^3.13.0",
|
||||||
|
"@tiptap/extension-placeholder": "^3.13.0",
|
||||||
|
"@tiptap/pm": "^3.13.0",
|
||||||
|
"@tiptap/react": "^3.13.0",
|
||||||
|
"@tiptap/starter-kit": "^3.13.0",
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"ai": "^5.0.108",
|
"ai": "^5.0.108",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.556.0",
|
"lucide-react": "^0.556.0",
|
||||||
|
"marked": "^17.0.1",
|
||||||
"motion": "^12.23.25",
|
"motion": "^12.23.25",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"next": "15.5.7",
|
"next": "15.5.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"shiki": "^3.19.0",
|
"shiki": "^3.19.0",
|
||||||
"streamdown": "^1.6.10",
|
"streamdown": "^1.6.10",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tokenlens": "^1.3.1",
|
"tokenlens": "^1.3.1",
|
||||||
|
"turndown": "^7.2.2",
|
||||||
"use-stick-to-bottom": "^1.1.1",
|
"use-stick-to-bottom": "^1.1.1",
|
||||||
"v0-sdk": "^0.15.3"
|
"v0-sdk": "^0.15.3"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
8
apps/rowboatx/types/turndown.d.ts
vendored
Normal file
8
apps/rowboatx/types/turndown.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
declare module "turndown" {
|
||||||
|
export default class TurndownService {
|
||||||
|
constructor(options?: unknown);
|
||||||
|
addRule(name: string, rule: unknown): void;
|
||||||
|
use(plugin: unknown): void;
|
||||||
|
turndown(html: string): string;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue