- 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>
411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
/**
|
|
* Gemini Integration for Product OS
|
|
*
|
|
* Supports:
|
|
* - Chat completions with streaming
|
|
* - Tool/function calling
|
|
* - Context-aware responses
|
|
*
|
|
* Set GOOGLE_CLOUD_PROJECT and optionally GEMINI_MODEL env vars.
|
|
* For local dev without Vertex AI, set GEMINI_API_KEY for AI Studio.
|
|
*/
|
|
|
|
import { config } from "./config.js";
|
|
|
|
export interface ChatMessage {
|
|
role: "user" | "assistant" | "system";
|
|
content: string;
|
|
}
|
|
|
|
export interface ToolCall {
|
|
name: string;
|
|
arguments: Record<string, any>;
|
|
}
|
|
|
|
export interface ChatResponse {
|
|
message: string;
|
|
toolCalls?: ToolCall[];
|
|
finishReason: "stop" | "tool_calls" | "error";
|
|
}
|
|
|
|
// Tool definitions that Gemini can call
|
|
export const PRODUCT_OS_TOOLS = [
|
|
{
|
|
name: "deploy_service",
|
|
description: "Deploy a Cloud Run service. Use when user wants to deploy, ship, or launch code.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
service_name: { type: "string", description: "Name of the service to deploy" },
|
|
repo: { type: "string", description: "Git repository URL" },
|
|
ref: { type: "string", description: "Git branch, tag, or commit" },
|
|
env: { type: "string", enum: ["dev", "staging", "prod"], description: "Target environment" }
|
|
},
|
|
required: ["service_name"]
|
|
}
|
|
},
|
|
{
|
|
name: "get_funnel_analytics",
|
|
description: "Get funnel conversion metrics. Use when user asks about funnels, conversions, or drop-offs.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
range_days: { type: "integer", description: "Number of days to analyze", default: 30 }
|
|
}
|
|
}
|
|
},
|
|
{
|
|
name: "get_top_drivers",
|
|
description: "Identify top factors driving a metric. Use when user asks why something changed or what drives conversions.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
metric: { type: "string", description: "The metric to analyze (e.g., 'conversion', 'retention')" },
|
|
range_days: { type: "integer", description: "Number of days to analyze", default: 30 }
|
|
},
|
|
required: ["metric"]
|
|
}
|
|
},
|
|
{
|
|
name: "generate_marketing_posts",
|
|
description: "Generate social media posts for a campaign. Use when user wants to create marketing content.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
goal: { type: "string", description: "Campaign goal (e.g., 'launch announcement')" },
|
|
product: { type: "string", description: "Product or feature name" },
|
|
channels: {
|
|
type: "array",
|
|
items: { type: "string" },
|
|
description: "Social channels (e.g., ['x', 'linkedin'])"
|
|
}
|
|
},
|
|
required: ["goal"]
|
|
}
|
|
},
|
|
{
|
|
name: "get_service_status",
|
|
description: "Check the status of a deployed service. Use when user asks about service health or deployment status.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
service_name: { type: "string", description: "Name of the service" },
|
|
region: { type: "string", description: "GCP region", default: "us-central1" }
|
|
},
|
|
required: ["service_name"]
|
|
}
|
|
},
|
|
{
|
|
name: "generate_code",
|
|
description: "Generate or modify code. Use when user asks to write, fix, refactor, or change code.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
task: { type: "string", description: "What code change to make" },
|
|
file_path: { type: "string", description: "Target file path (if known)" },
|
|
language: { type: "string", description: "Programming language" },
|
|
context: { type: "string", description: "Additional context about the codebase" }
|
|
},
|
|
required: ["task"]
|
|
}
|
|
},
|
|
{
|
|
name: "deploy_app",
|
|
description: "Deploy a specific app from the project monorepo. Use when user wants to deploy or ship one of their apps (product, website, admin, storybook).",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
project_id: { type: "string", description: "The project ID" },
|
|
app_name: {
|
|
type: "string",
|
|
enum: ["product", "website", "admin", "storybook"],
|
|
description: "Which app to deploy"
|
|
},
|
|
env: {
|
|
type: "string",
|
|
enum: ["dev", "staging", "prod"],
|
|
description: "Target environment"
|
|
}
|
|
},
|
|
required: ["project_id", "app_name"]
|
|
}
|
|
},
|
|
{
|
|
name: "scaffold_app",
|
|
description: "Add a new app to the project monorepo. Use when user wants to add a new application beyond the defaults.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
project_id: { type: "string", description: "The project ID" },
|
|
app_name: { type: "string", description: "Name for the new app (e.g. 'mobile', 'api', 'dashboard')" },
|
|
framework: {
|
|
type: "string",
|
|
enum: ["nextjs", "astro", "express", "fastify"],
|
|
description: "Framework to scaffold"
|
|
}
|
|
},
|
|
required: ["project_id", "app_name"]
|
|
}
|
|
}
|
|
];
|
|
|
|
// System prompt for Product OS assistant
|
|
const SYSTEM_PROMPT = `You are the AI for a software platform where every project is a Turborepo monorepo containing multiple apps: product, website, admin, and storybook. You have full visibility and control over the entire project.
|
|
|
|
Each project has:
|
|
- apps/product — the core user-facing application
|
|
- apps/website — the marketing and landing site
|
|
- apps/admin — internal admin tooling
|
|
- apps/storybook — component browser and design system
|
|
- packages/ui — shared React component library
|
|
- packages/tokens — shared design tokens (colors, spacing, typography)
|
|
- packages/types — shared TypeScript types
|
|
- packages/config — shared eslint and tsconfig
|
|
|
|
You can help with:
|
|
- Deploying any app using turbo run build --filter=<app>
|
|
- Writing and modifying code across any app or package in the monorepo
|
|
- Adding new apps or packages to the project
|
|
- Analyzing product metrics and funnels
|
|
- Generating marketing content
|
|
- Understanding what drives user behavior
|
|
|
|
When a user says "deploy" without specifying an app, ask which one or default to "product".
|
|
When a user asks to change something visual, consider whether it belongs in packages/ui or packages/tokens.
|
|
When users ask you to do something, use the available tools to take action. Be concise and specific about which app or package you are working in.`;
|
|
|
|
/**
|
|
* Chat with Gemini
|
|
* Uses Vertex AI in production, or AI Studio API key for local dev
|
|
*/
|
|
export async function chat(
|
|
messages: ChatMessage[],
|
|
options: { stream?: boolean } = {}
|
|
): Promise<ChatResponse> {
|
|
const apiKey = process.env.GEMINI_API_KEY;
|
|
const projectId = config.projectId;
|
|
const model = process.env.GEMINI_MODEL ?? "gemini-1.5-flash";
|
|
|
|
// Build the request
|
|
const contents = [
|
|
{ role: "user", parts: [{ text: SYSTEM_PROMPT }] },
|
|
{ role: "model", parts: [{ text: "Understood. I'm Product OS, ready to help you build and operate your SaaS product. How can I help?" }] },
|
|
...messages.map(m => ({
|
|
role: m.role === "assistant" ? "model" : "user",
|
|
parts: [{ text: m.content }]
|
|
}))
|
|
];
|
|
|
|
const tools = [{
|
|
functionDeclarations: PRODUCT_OS_TOOLS
|
|
}];
|
|
|
|
// Use AI Studio API if API key is set (local dev)
|
|
if (apiKey) {
|
|
return chatWithAIStudio(apiKey, model, contents, tools);
|
|
}
|
|
|
|
// Use Vertex AI if project is set (production)
|
|
if (projectId && projectId !== "productos-local") {
|
|
return chatWithVertexAI(projectId, model, contents, tools);
|
|
}
|
|
|
|
// Mock response for local dev without credentials
|
|
return mockChat(messages);
|
|
}
|
|
|
|
async function chatWithAIStudio(
|
|
apiKey: string,
|
|
model: string,
|
|
contents: any[],
|
|
tools: any[]
|
|
): Promise<ChatResponse> {
|
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
|
|
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
contents,
|
|
tools,
|
|
generationConfig: {
|
|
temperature: 0.7,
|
|
maxOutputTokens: 2048
|
|
}
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
console.error("Gemini API error:", error);
|
|
throw new Error(`Gemini API error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return parseGeminiResponse(data);
|
|
}
|
|
|
|
async function chatWithVertexAI(
|
|
projectId: string,
|
|
model: string,
|
|
contents: any[],
|
|
tools: any[]
|
|
): Promise<ChatResponse> {
|
|
// Vertex AI endpoint
|
|
const location = process.env.VERTEX_LOCATION ?? "us-central1";
|
|
const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${model}:generateContent`;
|
|
|
|
// Get access token (requires gcloud auth)
|
|
const { GoogleAuth } = await import("google-auth-library");
|
|
const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] });
|
|
const client = await auth.getClient();
|
|
const token = await client.getAccessToken();
|
|
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Authorization": `Bearer ${token.token}`
|
|
},
|
|
body: JSON.stringify({
|
|
contents,
|
|
tools,
|
|
generationConfig: {
|
|
temperature: 0.7,
|
|
maxOutputTokens: 2048
|
|
}
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
console.error("Vertex AI error:", error);
|
|
throw new Error(`Vertex AI error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return parseGeminiResponse(data);
|
|
}
|
|
|
|
function parseGeminiResponse(data: any): ChatResponse {
|
|
const candidate = data.candidates?.[0];
|
|
if (!candidate) {
|
|
return { message: "No response from Gemini", finishReason: "error" };
|
|
}
|
|
|
|
const content = candidate.content;
|
|
const parts = content?.parts ?? [];
|
|
|
|
// Check for function calls
|
|
const functionCalls = parts.filter((p: any) => p.functionCall);
|
|
if (functionCalls.length > 0) {
|
|
const toolCalls = functionCalls.map((p: any) => ({
|
|
name: p.functionCall.name,
|
|
arguments: p.functionCall.args ?? {}
|
|
}));
|
|
return {
|
|
message: "",
|
|
toolCalls,
|
|
finishReason: "tool_calls"
|
|
};
|
|
}
|
|
|
|
// Regular text response
|
|
const text = parts.map((p: any) => p.text ?? "").join("");
|
|
return {
|
|
message: text,
|
|
finishReason: "stop"
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mock chat for local development without Gemini credentials
|
|
*/
|
|
function mockChat(messages: ChatMessage[]): ChatResponse {
|
|
const lastMessage = messages[messages.length - 1]?.content.toLowerCase() ?? "";
|
|
|
|
// Check marketing/campaign FIRST (before deploy) since "launch" can be ambiguous
|
|
if (lastMessage.includes("marketing") || lastMessage.includes("campaign") || lastMessage.includes("post") ||
|
|
(lastMessage.includes("launch") && !lastMessage.includes("deploy"))) {
|
|
return {
|
|
message: "",
|
|
toolCalls: [{
|
|
name: "generate_marketing_posts",
|
|
arguments: { goal: "product launch", channels: ["x", "linkedin"] }
|
|
}],
|
|
finishReason: "tool_calls"
|
|
};
|
|
}
|
|
|
|
// Simple keyword matching to simulate tool calls
|
|
if (lastMessage.includes("deploy") || lastMessage.includes("ship") || lastMessage.includes("staging") || lastMessage.includes("production")) {
|
|
return {
|
|
message: "",
|
|
toolCalls: [{
|
|
name: "deploy_service",
|
|
arguments: { service_name: "my-service", env: lastMessage.includes("staging") ? "staging" : lastMessage.includes("prod") ? "prod" : "dev" }
|
|
}],
|
|
finishReason: "tool_calls"
|
|
};
|
|
}
|
|
|
|
if (lastMessage.includes("funnel") || lastMessage.includes("conversion") || lastMessage.includes("analytics")) {
|
|
return {
|
|
message: "",
|
|
toolCalls: [{
|
|
name: "get_funnel_analytics",
|
|
arguments: { range_days: 30 }
|
|
}],
|
|
finishReason: "tool_calls"
|
|
};
|
|
}
|
|
|
|
if (lastMessage.includes("why") || lastMessage.includes("driver") || lastMessage.includes("cause")) {
|
|
return {
|
|
message: "",
|
|
toolCalls: [{
|
|
name: "get_top_drivers",
|
|
arguments: { metric: "conversion", range_days: 30 }
|
|
}],
|
|
finishReason: "tool_calls"
|
|
};
|
|
}
|
|
|
|
if (lastMessage.includes("status") || lastMessage.includes("health")) {
|
|
return {
|
|
message: "",
|
|
toolCalls: [{
|
|
name: "get_service_status",
|
|
arguments: { service_name: "my-service" }
|
|
}],
|
|
finishReason: "tool_calls"
|
|
};
|
|
}
|
|
|
|
if (lastMessage.includes("code") || lastMessage.includes("function") || lastMessage.includes("write") || lastMessage.includes("create")) {
|
|
return {
|
|
message: "",
|
|
toolCalls: [{
|
|
name: "generate_code",
|
|
arguments: { task: lastMessage, language: "typescript" }
|
|
}],
|
|
finishReason: "tool_calls"
|
|
};
|
|
}
|
|
|
|
// Default response
|
|
return {
|
|
message: `I'm Product OS, your AI assistant for building and operating SaaS products. I can help you:
|
|
|
|
• **Deploy** services to Cloud Run
|
|
• **Analyze** funnel metrics and conversions
|
|
• **Generate** marketing content
|
|
• **Understand** what drives user behavior
|
|
|
|
What would you like to do?
|
|
|
|
_(Note: Running in mock mode - set GEMINI_API_KEY for real AI responses)_`,
|
|
finishReason: "stop"
|
|
};
|
|
}
|