mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-24 16:06:25 +02:00
wip-electron
This commit is contained in:
parent
c637cb49ac
commit
2491bacea1
17 changed files with 8098 additions and 517 deletions
2
apps/electron/.gitignore
vendored
Normal file
2
apps/electron/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
out/
|
||||
44
apps/electron/forge.config.js
Normal file
44
apps/electron/forge.config.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
|
||||
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
|
||||
|
||||
module.exports = {
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
},
|
||||
rebuildConfig: {},
|
||||
makers: [
|
||||
{
|
||||
name: '@electron-forge/maker-squirrel',
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-zip',
|
||||
platforms: ['darwin'],
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-deb',
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
name: '@electron-forge/maker-rpm',
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
name: '@electron-forge/plugin-auto-unpack-natives',
|
||||
config: {},
|
||||
},
|
||||
// Fuses are used to enable/disable various Electron functionality
|
||||
// at package time, before code signing the application
|
||||
new FusesPlugin({
|
||||
version: FuseVersion.V1,
|
||||
[FuseV1Options.RunAsNode]: false,
|
||||
[FuseV1Options.EnableCookieEncryption]: true,
|
||||
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
|
||||
[FuseV1Options.EnableNodeCliInspectArguments]: false,
|
||||
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
|
||||
[FuseV1Options.OnlyLoadAppFromAsar]: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
18
apps/electron/main.js
Normal file
18
apps/electron/main.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
const { app, BrowserWindow } = require('electron');
|
||||
const path = require('node:path');
|
||||
|
||||
const createWindow = () => {
|
||||
const win = new BrowserWindow({
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js')
|
||||
},
|
||||
});
|
||||
|
||||
win.loadURL('http://localhost:8080');
|
||||
};
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
});
|
||||
7869
apps/electron/package-lock.json
generated
Normal file
7869
apps/electron/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
apps/electron/package.json
Normal file
28
apps/electron/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "electron",
|
||||
"version": "1.0.0",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron-forge start",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"package": "electron-forge package",
|
||||
"make": "electron-forge make"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.10.2",
|
||||
"@electron-forge/maker-deb": "^7.10.2",
|
||||
"@electron-forge/maker-rpm": "^7.10.2",
|
||||
"@electron-forge/maker-squirrel": "^7.10.2",
|
||||
"@electron-forge/maker-zip": "^7.10.2",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.10.2",
|
||||
"@electron-forge/plugin-fuses": "^7.10.2",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"electron": "^39.2.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-squirrel-startup": "^1.0.1"
|
||||
}
|
||||
}
|
||||
5
apps/electron/preload.js
Normal file
5
apps/electron/preload.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const { contextBridge } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('config', {
|
||||
apiBase: process.env.API_BASE,
|
||||
});
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
|
||||
const BACKEND = process.env.CLI_BACKEND_URL || "http://localhost:3000";
|
||||
const CORS_HEADERS = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
|
||||
async function forward(req: NextRequest, method: string, segments?: string[]) {
|
||||
const search = req.nextUrl.search || "";
|
||||
const targetPath = (segments || []).join("/");
|
||||
const target = `${BACKEND}/${targetPath}${search}`;
|
||||
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": req.headers.get("content-type") || "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
init.body = await req.text();
|
||||
}
|
||||
|
||||
const res = await fetch(target, init);
|
||||
const body = await res.text();
|
||||
return new Response(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"content-type": res.headers.get("content-type") || "application/json",
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await context.params;
|
||||
return forward(req, "GET", path);
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await context.params;
|
||||
return forward(req, "POST", path);
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await context.params;
|
||||
return forward(req, "PUT", path);
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ path?: string[] }> }
|
||||
) {
|
||||
const { path } = await context.params;
|
||||
return forward(req, "DELETE", path);
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new Response(null, { status: 204, headers: CORS_HEADERS });
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { NextRequest } from "next/server";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
|
||||
const ROWBOAT_ROOT = path.join(os.homedir(), ".rowboat", "runs");
|
||||
|
||||
function resolveRunPath(fileParam: string): { target: string; relative: string } {
|
||||
const normalized = path.normalize(fileParam).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
const target = path.join(ROWBOAT_ROOT, normalized);
|
||||
if (!target.startsWith(ROWBOAT_ROOT)) {
|
||||
throw new Error("Invalid path");
|
||||
}
|
||||
return { target, relative: normalized };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const fileParam = req.nextUrl.searchParams.get("file");
|
||||
if (!fileParam) {
|
||||
return Response.json({ error: "file param required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { target, relative } = resolveRunPath(fileParam);
|
||||
const content = await fs.readFile(target, "utf8");
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
return Response.json({ file: relative, parsed, raw: content });
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.message === "Invalid path") {
|
||||
return Response.json(
|
||||
{ error: "Invalid file path" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
console.error("Failed to read run file", error);
|
||||
return Response.json(
|
||||
{ error: "Failed to read run file" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import path from "path";
|
||||
import os from "os";
|
||||
import { promises as fs } from "fs";
|
||||
|
||||
const ROWBOAT_ROOT = path.join(os.homedir(), ".rowboat");
|
||||
|
||||
async function listRecursive(dir: string): Promise<string[]> {
|
||||
const root = path.join(ROWBOAT_ROOT, dir);
|
||||
|
||||
const walk = async (current: string, prefix = ""): Promise<string[]> => {
|
||||
const results: string[] = [];
|
||||
try {
|
||||
const entries = await fs.readdir(current, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...(await walk(path.join(current, entry.name), relPath)));
|
||||
} else if (entry.isFile()) {
|
||||
results.push(relPath);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return results;
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
return walk(root);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const agents = await listRecursive("agents");
|
||||
const config = await listRecursive("config");
|
||||
const runs = await listRecursive("runs");
|
||||
|
||||
return Response.json({
|
||||
agents,
|
||||
config,
|
||||
runs,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const CLI_BASE_URL = process.env.CLI_BACKEND_URL || 'http://localhost:3000';
|
||||
|
||||
/**
|
||||
* GET /api/stream
|
||||
* Proxy SSE stream from CLI backend to frontend
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const customReadable = new ReadableStream({
|
||||
async start(controller) {
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
let isClosed = false;
|
||||
|
||||
// Handle client disconnect
|
||||
request.signal.addEventListener('abort', () => {
|
||||
isClosed = true;
|
||||
reader?.cancel();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed, ignore
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// Connect to CLI backend SSE stream
|
||||
const response = await fetch(`${CLI_BASE_URL}/stream`, {
|
||||
headers: {
|
||||
'Accept': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
signal: request.signal, // Forward abort signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to connect to backend: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
reader = body.getReader();
|
||||
|
||||
// Read and forward stream
|
||||
while (!isClosed) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Only enqueue if controller is still open
|
||||
if (!isClosed) {
|
||||
try {
|
||||
controller.enqueue(value);
|
||||
} catch {
|
||||
// Controller closed, stop reading
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Only log non-abort errors
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
console.error('Stream error:', error);
|
||||
}
|
||||
|
||||
// Try to send error message if controller is still open
|
||||
if (!isClosed) {
|
||||
try {
|
||||
const errorMessage = `data: ${JSON.stringify({ type: 'error', error: String(error) })}\n\n`;
|
||||
controller.enqueue(encoder.encode(errorMessage));
|
||||
} catch {
|
||||
// Controller already closed, ignore
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Clean up
|
||||
if (reader) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
}
|
||||
|
||||
if (!isClosed) {
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(customReadable, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ import {
|
|||
ArtifactHeader,
|
||||
ArtifactTitle,
|
||||
} from "@/components/ai-elements/artifact";
|
||||
import { useState, useEffect, useRef, type ReactNode } from "react";
|
||||
import { useState, useEffect, useRef, type ReactNode, useCallback } from "react";
|
||||
import { MicIcon, Save, Loader2, Lock } from "lucide-react";
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -108,7 +108,7 @@ type RunEvent = {
|
|||
};
|
||||
|
||||
function PageBody() {
|
||||
const apiBase = "/api/cli";
|
||||
const [apiBase, setApiBase] = useState<string>("http://localhost:3000")
|
||||
const streamUrl = "/api/stream";
|
||||
const [text, setText] = useState<string>("");
|
||||
const [useMicrophone, setUseMicrophone] = useState<boolean>(false);
|
||||
|
|
@ -140,14 +140,17 @@ function PageBody() {
|
|||
const stripExtension = (name: string) => name.replace(/\.[^/.]+$/, "");
|
||||
const detectFileType = (name: string): "json" | "markdown" =>
|
||||
name.toLowerCase().match(/\.(md|markdown)$/) ? "markdown" : "json";
|
||||
|
||||
useEffect(() => {
|
||||
setApiBase(window.config.apiBase);
|
||||
}, []);
|
||||
|
||||
const requestJson = async (
|
||||
const requestJson = useCallback(async (
|
||||
url: string,
|
||||
options?: (RequestInit & { allow404?: boolean }) | undefined
|
||||
) => {
|
||||
const fullUrl = url.startsWith("/api/")
|
||||
? url
|
||||
: `${apiBase}${url.startsWith("/") ? url : `/${url}`}`;
|
||||
const fullUrl = new URL(url, apiBase).toString();
|
||||
console.log('fullUrl', fullUrl);
|
||||
const { allow404, ...rest } = options || {};
|
||||
const res = await fetch(fullUrl, {
|
||||
...rest,
|
||||
|
|
@ -188,7 +191,7 @@ function PageBody() {
|
|||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}, [apiBase]);
|
||||
|
||||
const renderPromptInput = () => (
|
||||
<PromptInput globalDrop multiple onSubmit={handleSubmit}>
|
||||
|
|
@ -269,7 +272,7 @@ function PageBody() {
|
|||
|
||||
const handleError = (e: Event) => {
|
||||
const target = e.target as EventSource;
|
||||
|
||||
|
||||
// Only log if it's not a normal close
|
||||
if (target.readyState === EventSource.CLOSED) {
|
||||
console.log('SSE connection closed, will reconnect on next message');
|
||||
|
|
@ -323,7 +326,7 @@ function PageBody() {
|
|||
input?: unknown;
|
||||
}) || {};
|
||||
console.log('LLM stream event type:', llmEvent.type);
|
||||
|
||||
|
||||
if (llmEvent.type === 'reasoning-delta' && llmEvent.delta) {
|
||||
setCurrentReasoning(prev => prev + llmEvent.delta);
|
||||
} else if (llmEvent.type === 'reasoning-end') {
|
||||
|
|
@ -382,11 +385,11 @@ function PageBody() {
|
|||
);
|
||||
return match
|
||||
? {
|
||||
...item,
|
||||
name: match.toolName,
|
||||
input: match.arguments,
|
||||
status: 'pending',
|
||||
}
|
||||
...item,
|
||||
name: match.toolName,
|
||||
input: match.arguments,
|
||||
status: 'pending',
|
||||
}
|
||||
: item;
|
||||
});
|
||||
|
||||
|
|
@ -417,14 +420,14 @@ function PageBody() {
|
|||
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) {
|
||||
|
|
@ -471,16 +474,16 @@ function PageBody() {
|
|||
{
|
||||
const errorMsg = typeof event.error === "string" ? event.error : "";
|
||||
if (errorMsg && !errorMsg.includes('terminated')) {
|
||||
setStatus('error');
|
||||
console.error('Agent error:', errorMsg);
|
||||
} else {
|
||||
console.log('Connection error (will auto-reconnect):', errorMsg);
|
||||
setStatus('ready');
|
||||
}
|
||||
setIsRunProcessing(false);
|
||||
setStatus('error');
|
||||
console.error('Agent error:', errorMsg);
|
||||
} else {
|
||||
console.log('Connection error (will auto-reconnect):', errorMsg);
|
||||
setStatus('ready');
|
||||
}
|
||||
setIsRunProcessing(false);
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
default:
|
||||
console.log('Unhandled event type:', event.type);
|
||||
}
|
||||
|
|
@ -673,7 +676,7 @@ function PageBody() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedResource]);
|
||||
}, [selectedResource, requestJson]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadAgents = async () => {
|
||||
|
|
@ -734,7 +737,7 @@ function PageBody() {
|
|||
}
|
||||
} else if (selectedResource.kind === "config") {
|
||||
const lower = selectedResource.name.toLowerCase();
|
||||
|
||||
|
||||
if (lower.endsWith(".md") || lower.endsWith(".markdown")) {
|
||||
// Save markdown file as plain text via local API
|
||||
const response = await fetch(
|
||||
|
|
@ -847,87 +850,87 @@ function PageBody() {
|
|||
<ConversationContent className="!flex !flex-col !items-center !gap-8 !p-4 pt-4 pb-32">
|
||||
<div className="w-full max-w-3xl mx-auto space-y-4">
|
||||
|
||||
{/* Render conversation items in order */}
|
||||
{conversation.map((item) => {
|
||||
if (item.type === 'message') {
|
||||
return (
|
||||
<Message
|
||||
key={item.id}
|
||||
from={item.role}
|
||||
>
|
||||
<MessageContent>
|
||||
<MessageResponse>
|
||||
{item.content}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
} else if (item.type === 'tool') {
|
||||
const stateMap: Record<ToolCall['status'], 'input-streaming' | 'input-available' | 'output-available' | 'output-error'> = {
|
||||
pending: 'input-streaming',
|
||||
running: 'input-available',
|
||||
completed: 'output-available',
|
||||
error: 'output-error',
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={item.id} className="mb-2">
|
||||
<Tool>
|
||||
<ToolHeader
|
||||
title={item.name}
|
||||
type="tool-call"
|
||||
state={stateMap[item.status] || 'input-streaming'}
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolInput input={item.input} />
|
||||
{item.result != null && (
|
||||
<ToolOutput
|
||||
output={item.result as ReactNode}
|
||||
errorText={undefined}
|
||||
{/* Render conversation items in order */}
|
||||
{conversation.map((item) => {
|
||||
if (item.type === 'message') {
|
||||
return (
|
||||
<Message
|
||||
key={item.id}
|
||||
from={item.role}
|
||||
>
|
||||
<MessageContent>
|
||||
<MessageResponse>
|
||||
{item.content}
|
||||
</MessageResponse>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
} else if (item.type === 'tool') {
|
||||
const stateMap: Record<ToolCall['status'], 'input-streaming' | 'input-available' | 'output-available' | 'output-error'> = {
|
||||
pending: 'input-streaming',
|
||||
running: 'input-available',
|
||||
completed: 'output-available',
|
||||
error: 'output-error',
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={item.id} className="mb-2">
|
||||
<Tool>
|
||||
<ToolHeader
|
||||
title={item.name}
|
||||
type="tool-call"
|
||||
state={stateMap[item.status] || 'input-streaming'}
|
||||
/>
|
||||
)}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
<ToolContent>
|
||||
<ToolInput input={item.input} />
|
||||
{item.result != null && (
|
||||
<ToolOutput
|
||||
output={item.result as ReactNode}
|
||||
errorText={undefined}
|
||||
/>
|
||||
)}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
} else if (item.type === 'reasoning') {
|
||||
return (
|
||||
<div key={item.id} className="mb-2">
|
||||
<Reasoning isStreaming={item.isStreaming}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
{item.content}
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* Streaming reasoning */}
|
||||
{currentReasoning && (
|
||||
<div className="mb-2">
|
||||
<Reasoning isStreaming={true}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
{currentReasoning}
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
</div>
|
||||
);
|
||||
} else if (item.type === 'reasoning') {
|
||||
return (
|
||||
<div key={item.id} className="mb-2">
|
||||
<Reasoning isStreaming={item.isStreaming}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
{item.content}
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
)}
|
||||
|
||||
{/* Streaming reasoning */}
|
||||
{currentReasoning && (
|
||||
<div className="mb-2">
|
||||
<Reasoning isStreaming={true}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
{currentReasoning}
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming message */}
|
||||
{currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<MessageResponse>
|
||||
{currentAssistantMessage}
|
||||
</MessageResponse>
|
||||
<span className="inline-block w-2 h-4 ml-1 bg-current animate-pulse" />
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
{/* Streaming message */}
|
||||
{currentAssistantMessage && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
<MessageResponse>
|
||||
{currentAssistantMessage}
|
||||
</MessageResponse>
|
||||
<span className="inline-block w-2 h-4 ml-1 bg-current animate-pulse" />
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
</div>
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
|
|
|
|||
10
apps/rowboatx/global.d.ts
vendored
Normal file
10
apps/rowboatx/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
config: {
|
||||
apiBase: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2,6 +2,7 @@ import type { NextConfig } from "next";
|
|||
import path from "path";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
turbopack: {
|
||||
// Keep Turbopack scoped to this app instead of inferring a parent workspace root.
|
||||
root: __dirname || path.join(process.cwd()),
|
||||
|
|
|
|||
2
apps/rowboatx/package-lock.json
generated
2
apps/rowboatx/package-lock.json
generated
|
|
@ -21,7 +21,6 @@
|
|||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@tiptap/extension-bubble-menu": "^3.13.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.13.0",
|
||||
"@tiptap/extension-placeholder": "^3.13.0",
|
||||
"@tiptap/pm": "^3.13.0",
|
||||
|
|
@ -2732,6 +2731,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.13.0.tgz",
|
||||
"integrity": "sha512-qZ3j2DBsqP9DjG2UlExQ+tHMRhAnWlCKNreKddKocb/nAFrPdBCtvkqIEu+68zPlbLD4ukpoyjUklRJg+NipFg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.0.0"
|
||||
},
|
||||
|
|
|
|||
12
build-electron.sh
Normal file
12
build-electron.sh
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# build rowboatx next.js app
|
||||
(cd apps/rowboatx && \
|
||||
npm install && \
|
||||
npm run build)
|
||||
|
||||
# build rowboat server
|
||||
(cd apps/cli && \
|
||||
npm install && \
|
||||
npm run build)
|
||||
Loading…
Add table
Add a link
Reference in a new issue