mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 16:36:22 +02:00
First version copilot:
- basic llm call that can perform CRUD actions over dummy workflow json files
This commit is contained in:
parent
055dda35b9
commit
4310b1d45d
11 changed files with 327 additions and 4 deletions
6
apps/cli/src/application/assistant/README.md
Normal file
6
apps/cli/src/application/assistant/README.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
Rowboat Copilot (demo)
|
||||
|
||||
- Entry point: `npm run copilot` (runs `src/x.ts` after building)
|
||||
- Natural language interface to list/create/update/delete workflow JSON under `.rowboat/workflows`
|
||||
- Uses existing zod schemas for validation; errors bubble up plainly for easy debugging
|
||||
- Data folders ensured automatically: `.rowboat/workflows`, `.rowboat/agents`, `.rowboat/mcp`
|
||||
12
apps/cli/src/application/assistant/USAGE.md
Normal file
12
apps/cli/src/application/assistant/USAGE.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
Quick start
|
||||
|
||||
1. `cd rowboat-V2/apps/cli`
|
||||
2. `export OPENAI_API_KEY=...`
|
||||
3. `npm run copilot`
|
||||
|
||||
Example prompts once running:
|
||||
- `list my workflows`
|
||||
- `show workflow example_workflow`
|
||||
- `create a workflow demo that calls function get_date`
|
||||
- `add an agent step default_assistant to demo`
|
||||
- `delete the demo workflow`
|
||||
37
apps/cli/src/application/assistant/agents/service.ts
Normal file
37
apps/cli/src/application/assistant/agents/service.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { z } from "zod";
|
||||
import { Agent } from "../../entities/agent.js";
|
||||
import { deleteJson, listJson, readJson, writeJson } from "../services/storage.js";
|
||||
|
||||
export type AgentId = string;
|
||||
|
||||
export function listAgents(): AgentId[] {
|
||||
return listJson("agents");
|
||||
}
|
||||
|
||||
export function getAgent(id: AgentId): z.infer<typeof Agent> | undefined {
|
||||
const raw = readJson<unknown>("agents", id);
|
||||
if (!raw) return undefined;
|
||||
return Agent.parse(raw);
|
||||
}
|
||||
|
||||
export function upsertAgent(
|
||||
id: AgentId,
|
||||
value: Partial<z.infer<typeof Agent>>
|
||||
): z.infer<typeof Agent> {
|
||||
const existing = readJson<unknown>("agents", id) as Partial<z.infer<typeof Agent>> | undefined;
|
||||
const merged = {
|
||||
name: id,
|
||||
model: "openai:gpt-4o-mini",
|
||||
description: "",
|
||||
instructions: "",
|
||||
...(existing ?? {}),
|
||||
...value,
|
||||
} satisfies Partial<z.infer<typeof Agent>>;
|
||||
const parsed = Agent.parse(merged);
|
||||
writeJson("agents", id, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function deleteAgent(id: AgentId): boolean {
|
||||
return deleteJson("agents", id);
|
||||
}
|
||||
105
apps/cli/src/application/assistant/chat.ts
Normal file
105
apps/cli/src/application/assistant/chat.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import readline from "readline";
|
||||
import { z } from "zod";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { generateObject } from "ai";
|
||||
import { Workflow } from "../../application/entities/workflow.js";
|
||||
import { listWorkflows, getWorkflow, upsertWorkflow, deleteWorkflow } from "./workflows/service.js";
|
||||
|
||||
const ChatCommand = z.object({
|
||||
action: z.enum([
|
||||
"help",
|
||||
"list_workflows",
|
||||
"get_workflow",
|
||||
"create_workflow",
|
||||
"update_workflow",
|
||||
"delete_workflow",
|
||||
"unknown",
|
||||
]),
|
||||
id: z.string().optional(),
|
||||
updates: Workflow.partial().optional(),
|
||||
});
|
||||
|
||||
type ChatCommandT = z.infer<typeof ChatCommand>;
|
||||
|
||||
const systemPrompt = `
|
||||
You are a CLI assistant that converts the user's natural language into a JSON command for managing workflows.
|
||||
|
||||
Rules:
|
||||
- Only output JSON matching the provided schema. No extra commentary.
|
||||
- Choose the most appropriate action from: help, list_workflows, get_workflow, create_workflow, update_workflow, delete_workflow, unknown.
|
||||
- For actions that need an id (get/update/delete/create), set "id" to the workflow identifier (e.g. "example_workflow").
|
||||
- For create/update, include only provided fields in "updates". If not provided, omit.
|
||||
- Workflow shape reminder: { name: string, description: string, steps: Step[] } where Step is either { type: "function", id: string } or { type: "agent", id: string }.
|
||||
- If the request is ambiguous, set action to "unknown".
|
||||
`;
|
||||
|
||||
async function interpret(input: string): Promise<ChatCommandT> {
|
||||
const { object } = await generateObject({
|
||||
model: openai("gpt-4.1"),
|
||||
system: systemPrompt,
|
||||
prompt: input,
|
||||
schema: ChatCommand,
|
||||
});
|
||||
return object;
|
||||
}
|
||||
|
||||
async function execute(cmd: ChatCommandT): Promise<unknown> {
|
||||
switch (cmd.action) {
|
||||
case "help":
|
||||
return {
|
||||
usage: [
|
||||
"Examples:",
|
||||
"- list workflows",
|
||||
"- show workflow example_workflow",
|
||||
"- create workflow demo with one step calling function get_date",
|
||||
"- update workflow demo: add agent step default_assistant",
|
||||
"- delete workflow demo",
|
||||
],
|
||||
};
|
||||
case "list_workflows":
|
||||
return { items: listWorkflows() };
|
||||
case "get_workflow":
|
||||
if (!cmd.id) return { error: "id required" };
|
||||
return getWorkflow(cmd.id) ?? null;
|
||||
case "create_workflow":
|
||||
if (!cmd.id) return { error: "id required" };
|
||||
return upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) });
|
||||
case "update_workflow":
|
||||
if (!cmd.id) return { error: "id required" };
|
||||
return upsertWorkflow(cmd.id, { ...(cmd.updates ?? {}) });
|
||||
case "delete_workflow":
|
||||
if (!cmd.id) return { error: "id required" };
|
||||
return { deleted: deleteWorkflow(cmd.id) };
|
||||
case "unknown":
|
||||
return { error: "Could not determine intent. Try again or ask for help." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function startCopilot(): Promise<void> {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
console.error("OPENAI_API_KEY is not set. Please export it to use chat.");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
console.log("Rowboat Copilot (type 'exit' to quit)");
|
||||
|
||||
const ask = () => rl.question("> ", async (line) => {
|
||||
if (!line || line.trim().toLowerCase() === "exit") {
|
||||
rl.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cmd = await interpret(line);
|
||||
console.log("\n=== Parsed Command ===\n" + JSON.stringify(cmd, null, 2));
|
||||
const result = await execute(cmd);
|
||||
console.log("\n=== Result ===\n" + JSON.stringify(result, null, 2) + "\n");
|
||||
} catch (err) {
|
||||
console.error("Error:", (err as Error).message);
|
||||
}
|
||||
ask();
|
||||
});
|
||||
|
||||
ask();
|
||||
}
|
||||
24
apps/cli/src/application/assistant/mcp/service.ts
Normal file
24
apps/cli/src/application/assistant/mcp/service.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import { McpServerConfig } from "../../entities/mcp.js";
|
||||
import { ensureBaseDirs, getStoragePaths } from "../services/storage.js";
|
||||
|
||||
export function mcpConfigPath(): string {
|
||||
const base = getStoragePaths();
|
||||
ensureBaseDirs(base);
|
||||
return path.join(base.workDir, "mcp", "servers.json");
|
||||
}
|
||||
|
||||
export function readMcpConfig(): z.infer<typeof McpServerConfig> {
|
||||
const p = mcpConfigPath();
|
||||
if (!fs.existsSync(p)) return { mcpServers: [] };
|
||||
const raw = fs.readFileSync(p, "utf8");
|
||||
return McpServerConfig.parse(JSON.parse(raw));
|
||||
}
|
||||
|
||||
export function writeMcpConfig(value: z.infer<typeof McpServerConfig>): void {
|
||||
const p = mcpConfigPath();
|
||||
const parsed = McpServerConfig.parse(value);
|
||||
fs.writeFileSync(p, JSON.stringify(parsed, null, 2) + "\n", "utf8");
|
||||
}
|
||||
70
apps/cli/src/application/assistant/services/storage.ts
Normal file
70
apps/cli/src/application/assistant/services/storage.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
export type DirKind = "workflows" | "agents" | "mcp";
|
||||
|
||||
export interface StoragePaths {
|
||||
appRoot: string;
|
||||
workDir: string; // .rowboat
|
||||
}
|
||||
|
||||
const defaultPaths: StoragePaths = (() => {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const appRoot = path.resolve(__dirname, "../../../../");
|
||||
const workDir = path.join(appRoot, ".rowboat");
|
||||
return { appRoot, workDir };
|
||||
})();
|
||||
|
||||
export function getStoragePaths(): StoragePaths {
|
||||
return defaultPaths;
|
||||
}
|
||||
|
||||
export function ensureBaseDirs(base: StoragePaths = defaultPaths) {
|
||||
const ensure = (p: string) => {
|
||||
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
||||
};
|
||||
ensure(base.workDir);
|
||||
ensure(path.join(base.workDir, "workflows"));
|
||||
ensure(path.join(base.workDir, "agents"));
|
||||
ensure(path.join(base.workDir, "mcp"));
|
||||
}
|
||||
|
||||
export function dirFor(kind: DirKind, base: StoragePaths = defaultPaths): string {
|
||||
switch (kind) {
|
||||
case "workflows":
|
||||
return path.join(base.workDir, "workflows");
|
||||
case "agents":
|
||||
return path.join(base.workDir, "agents");
|
||||
case "mcp":
|
||||
return path.join(base.workDir, "mcp");
|
||||
}
|
||||
}
|
||||
|
||||
export function listJson(kind: DirKind, base: StoragePaths = defaultPaths): string[] {
|
||||
const d = dirFor(kind, base);
|
||||
if (!fs.existsSync(d)) return [];
|
||||
return fs
|
||||
.readdirSync(d)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => f.replace(/\.json$/, ""));
|
||||
}
|
||||
|
||||
export function readJson<T>(kind: DirKind, id: string, base: StoragePaths = defaultPaths): T | undefined {
|
||||
const p = path.join(dirFor(kind, base), `${id}.json`);
|
||||
if (!fs.existsSync(p)) return undefined;
|
||||
const raw = fs.readFileSync(p, "utf8");
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
export function writeJson(kind: DirKind, id: string, value: unknown, base: StoragePaths = defaultPaths): void {
|
||||
const p = path.join(dirFor(kind, base), `${id}.json`);
|
||||
fs.writeFileSync(p, JSON.stringify(value, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
export function deleteJson(kind: DirKind, id: string, base: StoragePaths = defaultPaths): boolean {
|
||||
const p = path.join(dirFor(kind, base), `${id}.json`);
|
||||
if (!fs.existsSync(p)) return false;
|
||||
fs.rmSync(p);
|
||||
return true;
|
||||
}
|
||||
44
apps/cli/src/application/assistant/workflows/service.ts
Normal file
44
apps/cli/src/application/assistant/workflows/service.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { z } from "zod";
|
||||
import { Workflow } from "../../entities/workflow.js";
|
||||
import { deleteJson, listJson, readJson, writeJson } from "../services/storage.js";
|
||||
|
||||
export type WorkflowId = string;
|
||||
|
||||
export function listWorkflows(): WorkflowId[] {
|
||||
return listJson("workflows");
|
||||
}
|
||||
|
||||
export function getWorkflow(id: WorkflowId): z.infer<typeof Workflow> | undefined {
|
||||
const raw = readJson<unknown>("workflows", id);
|
||||
if (!raw) return undefined;
|
||||
return Workflow.parse(raw);
|
||||
}
|
||||
|
||||
export function upsertWorkflow(
|
||||
id: WorkflowId,
|
||||
value: Partial<z.infer<typeof Workflow>>
|
||||
): z.infer<typeof Workflow> {
|
||||
const existing = readJson<unknown>("workflows", id) as Partial<z.infer<typeof Workflow>> | undefined;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const defaults: Partial<z.infer<typeof Workflow>> = {
|
||||
name: id,
|
||||
description: "",
|
||||
steps: [],
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
};
|
||||
const merged = {
|
||||
...defaults,
|
||||
...(existing ?? {}),
|
||||
...value,
|
||||
updatedAt: now,
|
||||
} satisfies Partial<z.infer<typeof Workflow>>;
|
||||
|
||||
const parsed = Workflow.parse(merged);
|
||||
writeJson("workflows", id, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function deleteWorkflow(id: WorkflowId): boolean {
|
||||
return deleteJson("workflows", id);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue