- Add Turborepo scaffold templates (apps: product, website, admin, storybook; packages: ui, tokens, types, config) - Add ProjectRecord and AppRecord types to control plane - Add Gitea integration service (repo creation, scaffold push, webhooks) - Add Coolify integration service (project + per-app service provisioning with turbo --filter) - Add project routes: GET/POST /projects, GET /projects/:id/apps, POST /projects/:id/deploy - Update chat route to inject project/monorepo context into AI requests - Add deploy_app and scaffold_app tools to Gemini tool set - Update deploy executor with monorepo-aware /execute/deploy endpoint - Add TURBOREPO_MIGRATION_PLAN.md documenting rationale and scope Co-authored-by: Cursor <cursoragent@cursor.com>
332 lines
10 KiB
TypeScript
332 lines
10 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
|
import { requireAuth } from "../auth.js";
|
|
import { chat, ChatMessage, ChatResponse, ToolCall } from "../gemini.js";
|
|
import { getRegistry } from "../registry.js";
|
|
import { saveRun, writeArtifactText, getProject } from "../storage/index.js";
|
|
import { nanoid } from "nanoid";
|
|
import type { RunRecord } from "../types.js";
|
|
|
|
interface ChatRequest {
|
|
messages: ChatMessage[];
|
|
project_id?: string;
|
|
context?: {
|
|
files?: { path: string; content: string }[];
|
|
selection?: { path: string; text: string; startLine: number };
|
|
};
|
|
autoExecuteTools?: boolean;
|
|
}
|
|
|
|
interface ChatResponseWithRuns extends ChatResponse {
|
|
runs?: RunRecord[];
|
|
}
|
|
|
|
export async function chatRoutes(app: FastifyInstance) {
|
|
/**
|
|
* Chat endpoint - proxies to Gemini with tool calling support
|
|
*/
|
|
app.post<{ Body: ChatRequest }>("/chat", async (req): Promise<ChatResponseWithRuns> => {
|
|
await requireAuth(req);
|
|
|
|
const { messages, project_id, context, autoExecuteTools = true } = req.body;
|
|
|
|
let enhancedMessages = [...messages];
|
|
|
|
// Inject project context so the AI understands the full monorepo structure
|
|
if (project_id) {
|
|
const project = await getProject(project_id);
|
|
if (project) {
|
|
const appList = project.apps.map(a => ` - ${a.name} (${a.path})${a.domain ? ` → ${a.domain}` : ""}`).join("\n");
|
|
const projectContext = [
|
|
`Project: ${project.name} (${project.slug})`,
|
|
`Repo: ${project.repo || "provisioning..."}`,
|
|
`Status: ${project.status}`,
|
|
`Apps in this monorepo:`,
|
|
appList,
|
|
`Shared packages: ui, tokens, types, config`,
|
|
`Build system: Turborepo ${project.turboVersion}`,
|
|
`Build command: turbo run build --filter=<app-name>`,
|
|
].join("\n");
|
|
|
|
enhancedMessages = [
|
|
{ role: "user" as const, content: `Project context:\n${projectContext}` },
|
|
...messages,
|
|
];
|
|
}
|
|
}
|
|
|
|
// Enhance messages with file/selection context if provided
|
|
if (context?.files?.length) {
|
|
const fileContext = context.files
|
|
.map(f => `File: ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
|
|
.join("\n\n");
|
|
|
|
enhancedMessages = [
|
|
{ role: "user" as const, content: `Context:\n${fileContext}` },
|
|
...enhancedMessages,
|
|
];
|
|
}
|
|
|
|
if (context?.selection) {
|
|
const selectionContext = `Selected code in ${context.selection.path} (line ${context.selection.startLine}):\n\`\`\`\n${context.selection.text}\n\`\`\``;
|
|
enhancedMessages = [
|
|
{ role: "user" as const, content: selectionContext },
|
|
...enhancedMessages,
|
|
];
|
|
}
|
|
|
|
// Call Gemini
|
|
const response = await chat(enhancedMessages);
|
|
|
|
// If tool calls and auto-execute is enabled, run them
|
|
if (response.toolCalls && response.toolCalls.length > 0 && autoExecuteTools) {
|
|
const runs = await executeToolCalls(response.toolCalls, req.body);
|
|
|
|
// Generate a summary of what was done
|
|
const summary = generateToolSummary(response.toolCalls, runs);
|
|
|
|
return {
|
|
message: summary,
|
|
toolCalls: response.toolCalls,
|
|
runs,
|
|
finishReason: "tool_calls"
|
|
};
|
|
}
|
|
|
|
return response;
|
|
});
|
|
|
|
/**
|
|
* Streaming chat endpoint (SSE)
|
|
*/
|
|
app.get("/chat/stream", async (req, reply) => {
|
|
await requireAuth(req);
|
|
|
|
reply.raw.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
"Connection": "keep-alive"
|
|
});
|
|
|
|
// For now, return a message that streaming is not yet implemented
|
|
reply.raw.write(`data: ${JSON.stringify({ message: "Streaming not yet implemented", finishReason: "stop" })}\n\n`);
|
|
reply.raw.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Execute tool calls by routing to the appropriate executor
|
|
*/
|
|
async function executeToolCalls(
|
|
toolCalls: ToolCall[],
|
|
request: ChatRequest
|
|
): Promise<RunRecord[]> {
|
|
const runs: RunRecord[] = [];
|
|
const registry = await getRegistry();
|
|
|
|
for (const toolCall of toolCalls) {
|
|
// Map tool call names to actual tools
|
|
const toolMapping: Record<string, string> = {
|
|
"deploy_service": "cloudrun.deploy_service",
|
|
"get_funnel_analytics": "analytics.funnel_summary",
|
|
"get_top_drivers": "analytics.top_drivers",
|
|
"generate_marketing_posts": "marketing.generate_channel_posts",
|
|
"get_service_status": "cloudrun.get_service_status",
|
|
"generate_code": "code.generate" // This one is special - handled inline
|
|
};
|
|
|
|
const actualToolName = toolMapping[toolCall.name];
|
|
|
|
// Special handling for code generation
|
|
if (toolCall.name === "generate_code") {
|
|
const codeRun = await handleCodeGeneration(toolCall.arguments);
|
|
runs.push(codeRun);
|
|
continue;
|
|
}
|
|
|
|
const tool = actualToolName ? registry[actualToolName] : null;
|
|
|
|
if (!tool) {
|
|
console.log(`Tool not found: ${toolCall.name} (mapped to ${actualToolName})`);
|
|
continue;
|
|
}
|
|
|
|
// Create a run
|
|
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
|
|
const now = new Date().toISOString();
|
|
|
|
const run: RunRecord = {
|
|
run_id: runId,
|
|
tenant_id: "t_chat",
|
|
tool: actualToolName,
|
|
status: "queued",
|
|
created_at: now,
|
|
updated_at: now,
|
|
input: toolCall.arguments,
|
|
artifacts: { bucket: "", prefix: `runs/${runId}` }
|
|
};
|
|
|
|
await saveRun(run);
|
|
|
|
// Execute the tool
|
|
try {
|
|
run.status = "running";
|
|
run.updated_at = new Date().toISOString();
|
|
await saveRun(run);
|
|
|
|
const execUrl = `${tool.executor.url}${tool.executor.path}`;
|
|
const response = await fetch(execUrl, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
run_id: runId,
|
|
tenant_id: "t_chat",
|
|
input: toolCall.arguments
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Executor error: ${response.status}`);
|
|
}
|
|
|
|
const output = await response.json();
|
|
run.status = "succeeded";
|
|
run.output = output;
|
|
run.updated_at = new Date().toISOString();
|
|
await saveRun(run);
|
|
|
|
} catch (e: any) {
|
|
run.status = "failed";
|
|
run.error = { message: e.message };
|
|
run.updated_at = new Date().toISOString();
|
|
await saveRun(run);
|
|
}
|
|
|
|
runs.push(run);
|
|
}
|
|
|
|
return runs;
|
|
}
|
|
|
|
/**
|
|
* Handle code generation specially
|
|
*/
|
|
async function handleCodeGeneration(args: any): Promise<RunRecord> {
|
|
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
|
|
const now = new Date().toISOString();
|
|
|
|
// For now, return a mock code generation result
|
|
// In production, this would call Gemini again with a code-specific prompt
|
|
const mockDiff = `--- a/${args.file_path || "src/example.ts"}
|
|
+++ b/${args.file_path || "src/example.ts"}
|
|
@@ -1,3 +1,10 @@
|
|
+// Generated by Product OS
|
|
+// Task: ${args.task}
|
|
+
|
|
export function example() {
|
|
- return "hello";
|
|
+ // TODO: Implement ${args.task}
|
|
+ return {
|
|
+ status: "generated",
|
|
+ task: "${args.task}"
|
|
+ };
|
|
}`;
|
|
|
|
const run: RunRecord = {
|
|
run_id: runId,
|
|
tenant_id: "t_chat",
|
|
tool: "code.generate",
|
|
status: "succeeded",
|
|
created_at: now,
|
|
updated_at: now,
|
|
input: args,
|
|
output: {
|
|
type: "code_generation",
|
|
diff: mockDiff,
|
|
file_path: args.file_path || "src/example.ts",
|
|
language: args.language || "typescript",
|
|
description: `Generated code for: ${args.task}`
|
|
}
|
|
};
|
|
|
|
await saveRun(run);
|
|
await writeArtifactText(`runs/${runId}`, "diff.patch", mockDiff);
|
|
|
|
return run;
|
|
}
|
|
|
|
/**
|
|
* Generate a human-readable summary of tool executions
|
|
*/
|
|
function generateToolSummary(toolCalls: ToolCall[], runs: RunRecord[]): string {
|
|
const parts: string[] = [];
|
|
|
|
for (let i = 0; i < toolCalls.length; i++) {
|
|
const tool = toolCalls[i];
|
|
const run = runs[i];
|
|
|
|
if (!run) continue;
|
|
|
|
const status = run.status === "succeeded" ? "✅" : "❌";
|
|
|
|
switch (tool.name) {
|
|
case "deploy_service":
|
|
if (run.status === "succeeded") {
|
|
const output = run.output as any;
|
|
parts.push(`${status} **Deployed** \`${tool.arguments.service_name}\` to ${tool.arguments.env || "dev"}\n URL: ${output?.service_url || "pending"}`);
|
|
} else {
|
|
parts.push(`${status} **Deploy failed** for \`${tool.arguments.service_name}\`: ${run.error?.message}`);
|
|
}
|
|
break;
|
|
|
|
case "get_funnel_analytics":
|
|
if (run.status === "succeeded") {
|
|
const output = run.output as any;
|
|
const steps = output?.steps || [];
|
|
const conversion = ((output?.overall_conversion || 0) * 100).toFixed(1);
|
|
parts.push(`${status} **Funnel Analysis** (${tool.arguments.range_days || 30} days)\n Overall conversion: ${conversion}%\n Steps: ${steps.length}`);
|
|
}
|
|
break;
|
|
|
|
case "get_top_drivers":
|
|
if (run.status === "succeeded") {
|
|
const output = run.output as any;
|
|
const drivers = output?.drivers || [];
|
|
const topDrivers = drivers.slice(0, 3).map((d: any) => d.name).join(", ");
|
|
parts.push(`${status} **Top Drivers** for ${tool.arguments.metric}\n ${topDrivers}`);
|
|
}
|
|
break;
|
|
|
|
case "generate_marketing_posts":
|
|
if (run.status === "succeeded") {
|
|
const output = run.output as any;
|
|
const channels = output?.channels || [];
|
|
const postCount = channels.reduce((sum: number, c: any) => sum + (c.posts?.length || 0), 0);
|
|
parts.push(`${status} **Generated** ${postCount} marketing posts for ${channels.map((c: any) => c.channel).join(", ")}`);
|
|
}
|
|
break;
|
|
|
|
case "get_service_status":
|
|
if (run.status === "succeeded") {
|
|
const output = run.output as any;
|
|
parts.push(`${status} **Service Status**: \`${tool.arguments.service_name}\` is ${output?.status || "unknown"}`);
|
|
}
|
|
break;
|
|
|
|
case "generate_code":
|
|
if (run.status === "succeeded") {
|
|
parts.push(`${status} **Generated code** for: ${tool.arguments.task}\n File: \`${(run.output as any)?.file_path}\``);
|
|
}
|
|
break;
|
|
|
|
default:
|
|
parts.push(`${status} **${tool.name}** completed`);
|
|
}
|
|
}
|
|
|
|
if (parts.length === 0) {
|
|
return "I processed your request but no actions were taken.";
|
|
}
|
|
|
|
return parts.join("\n\n");
|
|
}
|