mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 16:36:22 +02:00
initial version of tui
This commit is contained in:
parent
89a2fc583e
commit
d0d0a3612e
14 changed files with 2079 additions and 70 deletions
|
|
@ -9,8 +9,7 @@ import { RunEvent } from "./entities/run-events.js";
|
|||
import { createInterface, Interface } from "node:readline/promises";
|
||||
import { ToolCallPart } from "./entities/message.js";
|
||||
import { Agent } from "./agents/agents.js";
|
||||
import { McpServerConfig } from "./mcp/mcp.js";
|
||||
import { McpServerDefinition } from "./mcp/mcp.js";
|
||||
import { McpServerConfig, McpServerDefinition } from "./mcp/schema.js";
|
||||
import { Example } from "./entities/example.js";
|
||||
import { z } from "zod";
|
||||
import { Flavor } from "./models/models.js";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
|
|||
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
|
||||
import container from "../../di/container.js";
|
||||
import { IMcpConfigRepo } from "../..//mcp/repo.js";
|
||||
import { McpServerDefinition } from "../../mcp/mcp.js";
|
||||
import { McpServerDefinition } from "../../mcp/schema.js";
|
||||
|
||||
const BuiltinToolsSchema = z.record(z.string(), z.object({
|
||||
description: z.string(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import z from "zod"
|
||||
import { Agent } from "../agents/agents.js"
|
||||
import { McpServerDefinition } from "../mcp/mcp.js";
|
||||
import { McpServerDefinition } from "../mcp/schema.js";
|
||||
|
||||
export const Example = z.object({
|
||||
id: z.string(),
|
||||
|
|
@ -9,4 +9,4 @@ export const Example = z.object({
|
|||
entryAgent: z.string().optional(),
|
||||
agents: z.array(Agent).optional(),
|
||||
mcpServers: z.record(z.string(), McpServerDefinition).optional(),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,63 +6,12 @@ import z from "zod";
|
|||
import { IMcpConfigRepo } from "./repo.js";
|
||||
import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
|
||||
export const StdioMcpServerConfig = z.object({
|
||||
type: z.literal("stdio").optional(),
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const HttpMcpServerConfig = z.object({
|
||||
type: z.literal("http").optional(),
|
||||
url: z.string(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const McpServerDefinition = z.union([StdioMcpServerConfig, HttpMcpServerConfig]);
|
||||
|
||||
export const McpServerConfig = z.object({
|
||||
mcpServers: z.record(z.string(), McpServerDefinition),
|
||||
});
|
||||
|
||||
const connectionState = z.enum(["disconnected", "connected", "error"]);
|
||||
|
||||
export const McpServerList = z.object({
|
||||
mcpServers: z.record(z.string(), z.object({
|
||||
config: McpServerDefinition,
|
||||
state: connectionState,
|
||||
error: z.string().nullable(),
|
||||
})),
|
||||
});
|
||||
|
||||
/*
|
||||
inputSchema: {
|
||||
[x: string]: unknown;
|
||||
type: "object";
|
||||
properties?: Record<string, object> | undefined;
|
||||
required?: string[] | undefined;
|
||||
};
|
||||
*/
|
||||
export const Tool = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
inputSchema: z.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.record(z.string(), z.any()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
}),
|
||||
outputSchema: z.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.record(z.string(), z.any()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
})
|
||||
|
||||
export const ListToolsResponse = z.object({
|
||||
tools: z.array(Tool),
|
||||
nextCursor: z.string().optional(),
|
||||
});
|
||||
import {
|
||||
connectionState,
|
||||
ListToolsResponse,
|
||||
McpServerDefinition,
|
||||
McpServerList,
|
||||
} from "./schema.js";
|
||||
|
||||
type mcpState = {
|
||||
state: z.infer<typeof connectionState>,
|
||||
|
|
@ -171,4 +120,4 @@ export async function executeTool(serverName: string, toolName: string, input: a
|
|||
arguments: input,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { WorkDir } from "../config/config.js";
|
||||
import { McpServerConfig } from "./mcp.js";
|
||||
import { McpServerDefinition } from "./mcp.js";
|
||||
import { McpServerConfig, McpServerDefinition } from "./schema.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
|
|
@ -42,4 +41,4 @@ export class FSMcpConfigRepo implements IMcpConfigRepo {
|
|||
delete conf.mcpServers[serverName];
|
||||
await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
apps/cli/src/mcp/schema.ts
Normal file
50
apps/cli/src/mcp/schema.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import z from "zod";
|
||||
|
||||
export const StdioMcpServerConfig = z.object({
|
||||
type: z.literal("stdio").optional(),
|
||||
command: z.string(),
|
||||
args: z.array(z.string()).optional(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const HttpMcpServerConfig = z.object({
|
||||
type: z.literal("http").optional(),
|
||||
url: z.string(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const McpServerDefinition = z.union([StdioMcpServerConfig, HttpMcpServerConfig]);
|
||||
|
||||
export const McpServerConfig = z.object({
|
||||
mcpServers: z.record(z.string(), McpServerDefinition),
|
||||
});
|
||||
|
||||
export const connectionState = z.enum(["disconnected", "connected", "error"]);
|
||||
|
||||
export const McpServerList = z.object({
|
||||
mcpServers: z.record(z.string(), z.object({
|
||||
config: McpServerDefinition,
|
||||
state: connectionState,
|
||||
error: z.string().nullable(),
|
||||
})),
|
||||
});
|
||||
|
||||
export const Tool = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
inputSchema: z.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.record(z.string(), z.any()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
}),
|
||||
outputSchema: z.object({
|
||||
type: z.literal("object"),
|
||||
properties: z.record(z.string(), z.any()).optional(),
|
||||
required: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const ListToolsResponse = z.object({
|
||||
tools: z.array(Tool),
|
||||
nextCursor: z.string().optional(),
|
||||
});
|
||||
|
|
@ -4,8 +4,8 @@ import { streamSSE } from 'hono/streaming'
|
|||
import { describeRoute, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||
import z from 'zod';
|
||||
import container from './di/container.js';
|
||||
import { executeTool, listServers, listTools, ListToolsResponse, McpServerList } from "./mcp/mcp.js";
|
||||
import { McpServerDefinition } from "./mcp/mcp.js";
|
||||
import { executeTool, listServers, listTools } from "./mcp/mcp.js";
|
||||
import { ListToolsResponse, McpServerDefinition, McpServerList } from "./mcp/schema.js";
|
||||
import { IMcpConfigRepo } from './mcp/repo.js';
|
||||
import { IModelConfigRepo } from './models/repo.js';
|
||||
import { ModelConfig, Provider } from "./models/models.js";
|
||||
|
|
@ -665,4 +665,4 @@ serve({
|
|||
// PUT /skills/<id>
|
||||
// DELETE /skills/<id>
|
||||
|
||||
// GET /sse
|
||||
// GET /sse
|
||||
|
|
|
|||
190
apps/cli/src/tui/api.ts
Normal file
190
apps/cli/src/tui/api.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import { createParser } from "eventsource-parser";
|
||||
import { Agent } from "../agents/agents.js";
|
||||
import { AskHumanResponsePayload, Run, ToolPermissionAuthorizePayload } from "../runs/runs.js";
|
||||
import { ListRunsResponse } from "../runs/repo.js";
|
||||
import { ModelConfig } from "../models/models.js";
|
||||
import { RunEvent } from "../entities/run-events.js";
|
||||
import z from "zod";
|
||||
|
||||
const HealthSchema = z.object({
|
||||
status: z.literal("ok"),
|
||||
});
|
||||
|
||||
const MessageResponse = z.object({
|
||||
messageId: z.string(),
|
||||
});
|
||||
|
||||
const SuccessSchema = z.object({
|
||||
success: z.literal(true),
|
||||
});
|
||||
|
||||
type RunEventType = z.infer<typeof RunEvent>;
|
||||
|
||||
export interface RowboatApiOptions {
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
export class RowboatApi {
|
||||
readonly baseUrl: string;
|
||||
constructor({ baseUrl }: RowboatApiOptions = {}) {
|
||||
this.baseUrl = baseUrl ?? process.env.ROWBOATX_SERVER_URL ?? "http://127.0.0.1:3000";
|
||||
}
|
||||
|
||||
private buildUrl(pathname: string): string {
|
||||
return new URL(pathname, this.baseUrl).toString();
|
||||
}
|
||||
|
||||
private async request<T>(pathname: string, init?: RequestInit): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (init?.headers instanceof Headers) {
|
||||
init.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
} else if (Array.isArray(init?.headers)) {
|
||||
for (const [key, value] of init.headers) {
|
||||
headers[key] = value;
|
||||
}
|
||||
} else if (init?.headers) {
|
||||
Object.assign(headers, init.headers as Record<string, string>);
|
||||
}
|
||||
if (init?.body && !headers["Content-Type"]) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
const response = await fetch(this.buildUrl(pathname), {
|
||||
method: "GET",
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
throw new Error(`Request to ${pathname} failed (${response.status}): ${text || response.statusText}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (!text) {
|
||||
return undefined as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
async getHealth(): Promise<z.infer<typeof HealthSchema>> {
|
||||
const payload = await this.request("/health");
|
||||
return HealthSchema.parse(payload);
|
||||
}
|
||||
|
||||
async getModelConfig(): Promise<z.infer<typeof ModelConfig>> {
|
||||
const payload = await this.request("/models");
|
||||
return ModelConfig.parse(payload);
|
||||
}
|
||||
|
||||
async listAgents(): Promise<z.infer<typeof Agent>[]> {
|
||||
const payload = await this.request("/agents");
|
||||
return Agent.array().parse(payload);
|
||||
}
|
||||
|
||||
async listRuns(cursor?: string): Promise<z.infer<typeof ListRunsResponse>> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (cursor) {
|
||||
searchParams.set("cursor", cursor);
|
||||
}
|
||||
const payload = await this.request(`/runs${searchParams.size ? `?${searchParams.toString()}` : ""}`);
|
||||
return ListRunsResponse.parse(payload);
|
||||
}
|
||||
|
||||
async getRun(runId: string): Promise<z.infer<typeof Run>> {
|
||||
const payload = await this.request(`/runs/${encodeURIComponent(runId)}`);
|
||||
return Run.parse(payload);
|
||||
}
|
||||
|
||||
async createRun(agentId: string): Promise<z.infer<typeof Run>> {
|
||||
const payload = await this.request("/runs/new", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ agentId }),
|
||||
});
|
||||
return Run.parse(payload);
|
||||
}
|
||||
|
||||
async sendMessage(runId: string, message: string): Promise<z.infer<typeof MessageResponse>> {
|
||||
const payload = await this.request(`/runs/${encodeURIComponent(runId)}/messages/new`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
return MessageResponse.parse(payload);
|
||||
}
|
||||
|
||||
async authorizeTool(runId: string, payload: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void> {
|
||||
const response = await this.request(`/runs/${encodeURIComponent(runId)}/permissions/authorize`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
SuccessSchema.parse(response);
|
||||
}
|
||||
|
||||
async replyToHuman(runId: string, requestId: string, payload: z.infer<typeof AskHumanResponsePayload>): Promise<void> {
|
||||
const response = await this.request(`/runs/${encodeURIComponent(runId)}/human-input-requests/${encodeURIComponent(requestId)}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
SuccessSchema.parse(response);
|
||||
}
|
||||
|
||||
async stopRun(runId: string): Promise<void> {
|
||||
const response = await this.request(`/runs/${encodeURIComponent(runId)}/stop`, {
|
||||
method: "POST",
|
||||
});
|
||||
SuccessSchema.parse(response);
|
||||
}
|
||||
|
||||
async subscribeToEvents(onEvent: (event: RunEventType) => void, onError?: (error: Error) => void): Promise<() => void> {
|
||||
const controller = new AbortController();
|
||||
const response = await fetch(this.buildUrl("/stream"), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Failed to subscribe to event stream (${response.status})`);
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
const parser = createParser((event) => {
|
||||
if (event.type !== "event" || !event.data) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = RunEvent.parse(JSON.parse(event.data));
|
||||
onEvent(parsed);
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
parser.feed(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
onError?.(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
reader.cancel().catch(() => undefined);
|
||||
};
|
||||
}
|
||||
}
|
||||
8
apps/cli/src/tui/index.tsx
Normal file
8
apps/cli/src/tui/index.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import React from "react";
|
||||
import { render } from "ink";
|
||||
import { RowboatTui } from "./ui.js";
|
||||
|
||||
export function runTui({ serverUrl }: { serverUrl?: string }) {
|
||||
const baseUrl = serverUrl ?? process.env.ROWBOATX_SERVER_URL ?? "http://127.0.0.1:3000";
|
||||
render(<RowboatTui serverUrl={baseUrl} />);
|
||||
}
|
||||
1174
apps/cli/src/tui/ui.tsx
Normal file
1174
apps/cli/src/tui/ui.tsx
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue