Initial commit: Product OS platform
- Control Plane API with Gemini integration - Executors: Deploy, Analytics, Marketing - MCP Adapter for Continue integration - VSCode/VSCodium extension - Tool registry and run tracking - In-memory storage for local dev - Terraform infrastructure setup
This commit is contained in:
61
.continue/config.yaml
Normal file
61
.continue/config.yaml
Normal file
@@ -0,0 +1,61 @@
|
||||
# Continue Configuration for Product OS
|
||||
# https://docs.continue.dev/reference/config
|
||||
|
||||
name: Product OS
|
||||
|
||||
# Models - using Gemini via your Control Plane
|
||||
models:
|
||||
- name: Gemini (Product OS)
|
||||
provider: openai # Continue uses OpenAI-compatible API format
|
||||
model: gemini-1.5-flash
|
||||
apiBase: http://localhost:8080 # Your Control Plane
|
||||
apiKey: not-needed # Auth handled by Control Plane
|
||||
|
||||
# Default model for chat
|
||||
model: Gemini (Product OS)
|
||||
|
||||
# MCP Servers - your Product OS tools
|
||||
experimental:
|
||||
modelContextProtocolServers:
|
||||
- name: productos
|
||||
command: npx
|
||||
args:
|
||||
- tsx
|
||||
- /Users/markhenderson/Cursor Projects/Master Biz AI/platform/backend/mcp-adapter/src/index.ts
|
||||
env:
|
||||
CONTROL_PLANE_URL: http://localhost:8080
|
||||
TENANT_ID: t_continue
|
||||
|
||||
# Context providers
|
||||
contextProviders:
|
||||
- name: code
|
||||
params:
|
||||
nFinal: 5
|
||||
nRetrieve: 10
|
||||
- name: docs
|
||||
- name: terminal
|
||||
- name: problems
|
||||
|
||||
# Slash commands
|
||||
slashCommands:
|
||||
- name: deploy
|
||||
description: Deploy a service to Cloud Run
|
||||
- name: analytics
|
||||
description: Get funnel analytics
|
||||
- name: marketing
|
||||
description: Generate marketing content
|
||||
|
||||
# Custom instructions for the AI
|
||||
systemMessage: |
|
||||
You are Product OS, an AI assistant for building and operating SaaS products on Google Cloud.
|
||||
|
||||
You have access to these tools via MCP:
|
||||
- deploy_service: Deploy Cloud Run services
|
||||
- get_service_status: Check deployment health
|
||||
- get_funnel_analytics: Analyze conversion funnels
|
||||
- get_top_drivers: Understand what drives metrics
|
||||
- generate_marketing_posts: Create social media content
|
||||
- chat_with_gemini: General AI conversation
|
||||
|
||||
When users ask to deploy, analyze, or generate content, use the appropriate tool.
|
||||
Always confirm before deploying to production.
|
||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
*.vsix
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Temporary
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Continue local config (may contain API keys)
|
||||
.continue/config.json
|
||||
|
||||
# GCP credentials (never commit these!)
|
||||
*.json
|
||||
!package.json
|
||||
!tsconfig.json
|
||||
!**/package.json
|
||||
!**/tsconfig.json
|
||||
0
1.Generate Control Plane API scaffold.md
Normal file
0
1.Generate Control Plane API scaffold.md
Normal file
0
Google_Cloud_Product_OS.md
Normal file
0
Google_Cloud_Product_OS.md
Normal file
0
architecture.md
Normal file
0
architecture.md
Normal file
30
platform/backend/control-plane/package.json
Normal file
30
platform/backend/control-plane/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@productos/control-plane",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/firestore": "^7.11.0",
|
||||
"@google-cloud/storage": "^7.14.0",
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@fastify/helmet": "^13.0.0",
|
||||
"@fastify/rate-limit": "^10.0.0",
|
||||
"@fastify/sensible": "^6.0.0",
|
||||
"fastify": "^5.0.0",
|
||||
"zod": "^3.23.8",
|
||||
"nanoid": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.4",
|
||||
"eslint": "^9.8.0"
|
||||
}
|
||||
}
|
||||
11
platform/backend/control-plane/src/auth.ts
Normal file
11
platform/backend/control-plane/src/auth.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { FastifyRequest } from "fastify";
|
||||
import { config } from "./config.js";
|
||||
|
||||
/**
|
||||
* V1: dev mode = trust caller.
|
||||
* V2: validate Google OAuth/IAP identity token.
|
||||
*/
|
||||
export async function requireAuth(req: FastifyRequest) {
|
||||
if (config.authMode === "dev") return;
|
||||
throw new Error("AUTH_MODE oauth not yet implemented");
|
||||
}
|
||||
10
platform/backend/control-plane/src/config.ts
Normal file
10
platform/backend/control-plane/src/config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const config = {
|
||||
port: Number(process.env.PORT ?? 8080),
|
||||
projectId: process.env.GCP_PROJECT_ID ?? "productos-local",
|
||||
artifactsBucket: process.env.GCS_BUCKET_ARTIFACTS ?? "productos-artifacts-local",
|
||||
runsCollection: process.env.FIRESTORE_COLLECTION_RUNS ?? "runs",
|
||||
toolsCollection: process.env.FIRESTORE_COLLECTION_TOOLS ?? "tools",
|
||||
authMode: process.env.AUTH_MODE ?? "dev",
|
||||
// Use in-memory storage when STORAGE_MODE=memory or when no GCP project is configured
|
||||
storageMode: process.env.STORAGE_MODE ?? (process.env.GCP_PROJECT_ID ? "gcp" : "memory")
|
||||
};
|
||||
365
platform/backend/control-plane/src/gemini.ts
Normal file
365
platform/backend/control-plane/src/gemini.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* 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"]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// System prompt for Product OS assistant
|
||||
const SYSTEM_PROMPT = `You are Product OS, an AI assistant specialized in helping users launch and operate SaaS products on Google Cloud.
|
||||
|
||||
You can help with:
|
||||
- Deploying services to Cloud Run
|
||||
- Analyzing product metrics and funnels
|
||||
- Generating marketing content
|
||||
- Writing and modifying code
|
||||
- Understanding what drives user behavior
|
||||
|
||||
When users ask you to do something, use the available tools to take action. Be concise and helpful.
|
||||
|
||||
If a user asks about code, analyze their request and either:
|
||||
1. Use generate_code tool for code changes
|
||||
2. Provide explanations directly
|
||||
|
||||
Always confirm before taking destructive actions like deploying to production.`;
|
||||
|
||||
/**
|
||||
* 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"
|
||||
};
|
||||
}
|
||||
29
platform/backend/control-plane/src/index.ts
Normal file
29
platform/backend/control-plane/src/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import helmet from "@fastify/helmet";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { config } from "./config.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { toolRoutes } from "./routes/tools.js";
|
||||
import { runRoutes } from "./routes/runs.js";
|
||||
import { chatRoutes } from "./routes/chat.js";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors, { origin: true });
|
||||
await app.register(helmet);
|
||||
await app.register(sensible);
|
||||
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
|
||||
|
||||
await app.register(healthRoutes);
|
||||
await app.register(toolRoutes);
|
||||
await app.register(runRoutes);
|
||||
await app.register(chatRoutes);
|
||||
|
||||
app.listen({ port: config.port, host: "0.0.0.0" }).then(() => {
|
||||
console.log(`🚀 Control Plane API running on http://localhost:${config.port}`);
|
||||
}).catch((err) => {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
10
platform/backend/control-plane/src/registry.ts
Normal file
10
platform/backend/control-plane/src/registry.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ToolDef } from "./types.js";
|
||||
import { listTools } from "./storage/index.js";
|
||||
|
||||
/**
|
||||
* Simple registry. V2: cache + versioning + per-tenant overrides.
|
||||
*/
|
||||
export async function getRegistry(): Promise<Record<string, ToolDef>> {
|
||||
const tools = await listTools();
|
||||
return Object.fromEntries(tools.map(t => [t.name, t]));
|
||||
}
|
||||
306
platform/backend/control-plane/src/routes/chat.ts
Normal file
306
platform/backend/control-plane/src/routes/chat.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
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 } from "../storage/index.js";
|
||||
import { nanoid } from "nanoid";
|
||||
import type { RunRecord } from "../types.js";
|
||||
|
||||
interface ChatRequest {
|
||||
messages: ChatMessage[];
|
||||
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, context, autoExecuteTools = true } = req.body;
|
||||
|
||||
// Enhance messages with context if provided
|
||||
let enhancedMessages = [...messages];
|
||||
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}` },
|
||||
...messages
|
||||
];
|
||||
}
|
||||
|
||||
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 },
|
||||
...messages
|
||||
];
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
17
platform/backend/control-plane/src/routes/health.ts
Normal file
17
platform/backend/control-plane/src/routes/health.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
// Root route - API info
|
||||
app.get("/", async () => ({
|
||||
name: "Product OS Control Plane",
|
||||
version: "0.1.0",
|
||||
endpoints: {
|
||||
health: "GET /healthz",
|
||||
tools: "GET /tools",
|
||||
invoke: "POST /tools/invoke",
|
||||
runs: "GET /runs/:run_id"
|
||||
}
|
||||
}));
|
||||
|
||||
app.get("/healthz", async () => ({ ok: true }));
|
||||
}
|
||||
18
platform/backend/control-plane/src/routes/runs.ts
Normal file
18
platform/backend/control-plane/src/routes/runs.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requireAuth } from "../auth.js";
|
||||
import { getRun } from "../storage/index.js";
|
||||
|
||||
export async function runRoutes(app: FastifyInstance) {
|
||||
app.get("/runs/:run_id", async (req) => {
|
||||
await requireAuth(req);
|
||||
const runId = (req.params as any).run_id as string;
|
||||
const run = await getRun(runId);
|
||||
if (!run) return app.httpErrors.notFound("Run not found");
|
||||
return run;
|
||||
});
|
||||
|
||||
app.get("/runs/:run_id/logs", async (req) => {
|
||||
await requireAuth(req);
|
||||
return { note: "V1: logs are in GCS artifacts under runs/<run_id>/" };
|
||||
});
|
||||
}
|
||||
91
platform/backend/control-plane/src/routes/tools.ts
Normal file
91
platform/backend/control-plane/src/routes/tools.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { nanoid } from "nanoid";
|
||||
import { requireAuth } from "../auth.js";
|
||||
import { getRegistry } from "../registry.js";
|
||||
import { saveRun, writeArtifactText } from "../storage/index.js";
|
||||
import type { RunRecord, ToolInvokeRequest } from "../types.js";
|
||||
|
||||
async function postJson(url: string, body: unknown) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text();
|
||||
throw new Error(`Executor error ${res.status}: ${txt}`);
|
||||
}
|
||||
return res.json() as Promise<unknown>;
|
||||
}
|
||||
|
||||
export async function toolRoutes(app: FastifyInstance) {
|
||||
app.get("/tools", async (req) => {
|
||||
await requireAuth(req);
|
||||
const registry = await getRegistry();
|
||||
return { tools: Object.values(registry) };
|
||||
});
|
||||
|
||||
app.post<{ Body: ToolInvokeRequest }>("/tools/invoke", async (req) => {
|
||||
await requireAuth(req);
|
||||
|
||||
const body = req.body;
|
||||
const registry = await getRegistry();
|
||||
const tool = registry[body.tool];
|
||||
if (!tool) return app.httpErrors.notFound(`Unknown tool: ${body.tool}`);
|
||||
|
||||
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const run: RunRecord = {
|
||||
run_id: runId,
|
||||
tenant_id: body.tenant_id,
|
||||
tool: body.tool,
|
||||
status: "queued",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
input: body.input,
|
||||
artifacts: { bucket: process.env.GCS_BUCKET_ARTIFACTS ?? "", prefix: `runs/${runId}` }
|
||||
};
|
||||
|
||||
await saveRun(run);
|
||||
await writeArtifactText(`runs/${runId}`, "input.json", JSON.stringify(body, null, 2));
|
||||
|
||||
try {
|
||||
run.status = "running";
|
||||
run.updated_at = new Date().toISOString();
|
||||
await saveRun(run);
|
||||
|
||||
if (body.dry_run) {
|
||||
run.status = "succeeded";
|
||||
run.output = { dry_run: true };
|
||||
run.updated_at = new Date().toISOString();
|
||||
await saveRun(run);
|
||||
await writeArtifactText(`runs/${runId}`, "output.json", JSON.stringify(run.output, null, 2));
|
||||
return { run_id: runId, status: run.status };
|
||||
}
|
||||
|
||||
const execUrl = `${tool.executor.url}${tool.executor.path}`;
|
||||
const output = await postJson(execUrl, {
|
||||
run_id: runId,
|
||||
tenant_id: body.tenant_id,
|
||||
workspace_id: body.workspace_id,
|
||||
input: body.input
|
||||
});
|
||||
|
||||
run.status = "succeeded";
|
||||
run.output = output;
|
||||
run.updated_at = new Date().toISOString();
|
||||
await saveRun(run);
|
||||
await writeArtifactText(`runs/${runId}`, "output.json", JSON.stringify(output, null, 2));
|
||||
|
||||
return { run_id: runId, status: run.status };
|
||||
} catch (e: any) {
|
||||
run.status = "failed";
|
||||
run.error = { message: e?.message ?? "Unknown error" };
|
||||
run.updated_at = new Date().toISOString();
|
||||
await saveRun(run);
|
||||
await writeArtifactText(`runs/${runId}`, "error.json", JSON.stringify(run.error, null, 2));
|
||||
return { run_id: runId, status: run.status };
|
||||
}
|
||||
});
|
||||
}
|
||||
23
platform/backend/control-plane/src/storage/firestore.ts
Normal file
23
platform/backend/control-plane/src/storage/firestore.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Firestore } from "@google-cloud/firestore";
|
||||
import { config } from "../config.js";
|
||||
import type { RunRecord, ToolDef } from "../types.js";
|
||||
|
||||
const db = new Firestore({ projectId: config.projectId });
|
||||
|
||||
export async function saveRun(run: RunRecord): Promise<void> {
|
||||
await db.collection(config.runsCollection).doc(run.run_id).set(run, { merge: true });
|
||||
}
|
||||
|
||||
export async function getRun(runId: string): Promise<RunRecord | null> {
|
||||
const snap = await db.collection(config.runsCollection).doc(runId).get();
|
||||
return snap.exists ? (snap.data() as RunRecord) : null;
|
||||
}
|
||||
|
||||
export async function saveTool(tool: ToolDef): Promise<void> {
|
||||
await db.collection(config.toolsCollection).doc(tool.name).set(tool, { merge: true });
|
||||
}
|
||||
|
||||
export async function listTools(): Promise<ToolDef[]> {
|
||||
const snap = await db.collection(config.toolsCollection).get();
|
||||
return snap.docs.map(d => d.data() as ToolDef);
|
||||
}
|
||||
11
platform/backend/control-plane/src/storage/gcs.ts
Normal file
11
platform/backend/control-plane/src/storage/gcs.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Storage } from "@google-cloud/storage";
|
||||
import { config } from "../config.js";
|
||||
|
||||
const storage = new Storage({ projectId: config.projectId });
|
||||
|
||||
export async function writeArtifactText(prefix: string, filename: string, content: string) {
|
||||
const bucket = storage.bucket(config.artifactsBucket);
|
||||
const file = bucket.file(`${prefix}/${filename}`);
|
||||
await file.save(content, { contentType: "text/plain" });
|
||||
return { bucket: config.artifactsBucket, path: `${prefix}/${filename}` };
|
||||
}
|
||||
23
platform/backend/control-plane/src/storage/index.ts
Normal file
23
platform/backend/control-plane/src/storage/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Storage adapter that switches between GCP (Firestore/GCS) and in-memory
|
||||
*/
|
||||
import { config } from "../config.js";
|
||||
import * as memory from "./memory.js";
|
||||
import * as firestore from "./firestore.js";
|
||||
import * as gcs from "./gcs.js";
|
||||
|
||||
const useMemory = config.storageMode === "memory";
|
||||
|
||||
if (useMemory) {
|
||||
console.log("💾 Using in-memory storage (set GCP_PROJECT_ID for Firestore/GCS)");
|
||||
memory.seedTools();
|
||||
} else {
|
||||
console.log(`☁️ Using GCP storage (project: ${config.projectId})`);
|
||||
}
|
||||
|
||||
// Export unified interface
|
||||
export const saveRun = useMemory ? memory.saveRun : firestore.saveRun;
|
||||
export const getRun = useMemory ? memory.getRun : firestore.getRun;
|
||||
export const saveTool = useMemory ? memory.saveTool : firestore.saveTool;
|
||||
export const listTools = useMemory ? memory.listTools : firestore.listTools;
|
||||
export const writeArtifactText = useMemory ? memory.writeArtifactText : gcs.writeArtifactText;
|
||||
116
platform/backend/control-plane/src/storage/memory.ts
Normal file
116
platform/backend/control-plane/src/storage/memory.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* In-memory storage for local development without Firestore/GCS
|
||||
*/
|
||||
import type { RunRecord, ToolDef } from "../types.js";
|
||||
|
||||
// In-memory stores
|
||||
const runs = new Map<string, RunRecord>();
|
||||
const tools = new Map<string, ToolDef>();
|
||||
const artifacts = new Map<string, string>();
|
||||
|
||||
// Run operations
|
||||
export async function saveRun(run: RunRecord): Promise<void> {
|
||||
runs.set(run.run_id, { ...run });
|
||||
}
|
||||
|
||||
export async function getRun(runId: string): Promise<RunRecord | null> {
|
||||
return runs.get(runId) ?? null;
|
||||
}
|
||||
|
||||
// Tool operations
|
||||
export async function saveTool(tool: ToolDef): Promise<void> {
|
||||
tools.set(tool.name, { ...tool });
|
||||
}
|
||||
|
||||
export async function listTools(): Promise<ToolDef[]> {
|
||||
return Array.from(tools.values());
|
||||
}
|
||||
|
||||
// Artifact operations
|
||||
export async function writeArtifactText(prefix: string, filename: string, content: string) {
|
||||
const path = `${prefix}/${filename}`;
|
||||
artifacts.set(path, content);
|
||||
return { bucket: "memory", path };
|
||||
}
|
||||
|
||||
// Seed some example tools for testing
|
||||
export function seedTools() {
|
||||
const sampleTools: ToolDef[] = [
|
||||
{
|
||||
name: "cloudrun.deploy_service",
|
||||
description: "Build and deploy a Cloud Run service",
|
||||
risk: "medium",
|
||||
executor: { kind: "http", url: "http://localhost:8090", path: "/execute/cloudrun/deploy" },
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["service_name", "repo", "ref", "env"],
|
||||
properties: {
|
||||
service_name: { type: "string" },
|
||||
repo: { type: "string" },
|
||||
ref: { type: "string" },
|
||||
env: { type: "string", enum: ["dev", "staging", "prod"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "cloudrun.get_service_status",
|
||||
description: "Get Cloud Run service status",
|
||||
risk: "low",
|
||||
executor: { kind: "http", url: "http://localhost:8090", path: "/execute/cloudrun/status" },
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["service_name", "region"],
|
||||
properties: {
|
||||
service_name: { type: "string" },
|
||||
region: { type: "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "analytics.funnel_summary",
|
||||
description: "Get funnel metrics for a time window",
|
||||
risk: "low",
|
||||
executor: { kind: "http", url: "http://localhost:8091", path: "/execute/analytics/funnel" },
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["range_days"],
|
||||
properties: {
|
||||
range_days: { type: "integer", minimum: 1, maximum: 365 }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "brand.get_profile",
|
||||
description: "Get tenant brand profile",
|
||||
risk: "low",
|
||||
executor: { kind: "http", url: "http://localhost:8092", path: "/execute/brand/get" },
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["profile_id"],
|
||||
properties: {
|
||||
profile_id: { type: "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "marketing.generate_channel_posts",
|
||||
description: "Generate social posts from a brief",
|
||||
risk: "low",
|
||||
executor: { kind: "http", url: "http://localhost:8093", path: "/execute/marketing/generate" },
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["brief", "channels"],
|
||||
properties: {
|
||||
brief: { type: "object" },
|
||||
channels: { type: "array", items: { type: "string" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
for (const tool of sampleTools) {
|
||||
tools.set(tool.name, tool);
|
||||
}
|
||||
|
||||
console.log(`📦 Seeded ${sampleTools.length} tools in memory`);
|
||||
}
|
||||
37
platform/backend/control-plane/src/types.ts
Normal file
37
platform/backend/control-plane/src/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type ToolRisk = "low" | "medium" | "high";
|
||||
|
||||
export type ToolDef = {
|
||||
name: string;
|
||||
description: string;
|
||||
risk: ToolRisk;
|
||||
executor: {
|
||||
kind: "http";
|
||||
url: string;
|
||||
path: string;
|
||||
};
|
||||
inputSchema: unknown;
|
||||
outputSchema?: unknown;
|
||||
};
|
||||
|
||||
export type ToolInvokeRequest = {
|
||||
tool: string;
|
||||
tenant_id: string;
|
||||
workspace_id?: string;
|
||||
input: unknown;
|
||||
dry_run?: boolean;
|
||||
};
|
||||
|
||||
export type RunStatus = "queued" | "running" | "succeeded" | "failed";
|
||||
|
||||
export type RunRecord = {
|
||||
run_id: string;
|
||||
tenant_id: string;
|
||||
tool: string;
|
||||
status: RunStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
input: unknown;
|
||||
output?: unknown;
|
||||
error?: { message: string; details?: unknown };
|
||||
artifacts?: { bucket: string; prefix: string };
|
||||
};
|
||||
13
platform/backend/control-plane/tsconfig.json
Normal file
13
platform/backend/control-plane/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
22
platform/backend/executors/analytics/package.json
Normal file
22
platform/backend/executors/analytics/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@productos/analytics-executor",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@fastify/sensible": "^6.0.0",
|
||||
"fastify": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
91
platform/backend/executors/analytics/src/index.ts
Normal file
91
platform/backend/executors/analytics/src/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import sensible from "@fastify/sensible";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors, { origin: true });
|
||||
await app.register(sensible);
|
||||
|
||||
app.get("/healthz", async () => ({ ok: true, executor: "analytics" }));
|
||||
|
||||
/**
|
||||
* Get funnel summary
|
||||
* In production: queries BigQuery
|
||||
*/
|
||||
app.post("/execute/analytics/funnel", async (req) => {
|
||||
const body = req.body as any;
|
||||
const { input } = body;
|
||||
|
||||
console.log(`📊 Funnel request:`, input);
|
||||
|
||||
// Mock funnel data
|
||||
const steps = [
|
||||
{ event_name: "page_view", users: 10000, conversion_from_prev: 1.0 },
|
||||
{ event_name: "signup_start", users: 3200, conversion_from_prev: 0.32 },
|
||||
{ event_name: "signup_complete", users: 2100, conversion_from_prev: 0.66 },
|
||||
{ event_name: "first_action", users: 1400, conversion_from_prev: 0.67 },
|
||||
{ event_name: "subscription", users: 420, conversion_from_prev: 0.30 }
|
||||
];
|
||||
|
||||
return {
|
||||
funnel_name: input.funnel?.name ?? "default_funnel",
|
||||
range_days: input.range_days,
|
||||
steps,
|
||||
overall_conversion: 0.042,
|
||||
generated_at: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Get top drivers for a metric
|
||||
*/
|
||||
app.post("/execute/analytics/top_drivers", async (req) => {
|
||||
const body = req.body as any;
|
||||
const { input } = body;
|
||||
|
||||
console.log(`🔍 Top drivers request:`, input);
|
||||
|
||||
// Mock driver analysis
|
||||
const drivers = [
|
||||
{ name: "completed_onboarding", score: 0.85, direction: "positive", evidence: "Users who complete onboarding convert 3.2x more", confidence: 0.92 },
|
||||
{ name: "used_feature_x", score: 0.72, direction: "positive", evidence: "Feature X usage correlates with 2.5x retention", confidence: 0.88 },
|
||||
{ name: "time_to_first_value", score: 0.68, direction: "negative", evidence: "Each additional day reduces conversion by 12%", confidence: 0.85 },
|
||||
{ name: "invited_team_member", score: 0.61, direction: "positive", evidence: "Team invites increase stickiness by 40%", confidence: 0.79 },
|
||||
{ name: "mobile_signup", score: 0.45, direction: "negative", evidence: "Mobile signups have 25% lower activation", confidence: 0.71 }
|
||||
];
|
||||
|
||||
return {
|
||||
target: input.target,
|
||||
range_days: input.range_days,
|
||||
drivers,
|
||||
generated_at: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Write an insight
|
||||
*/
|
||||
app.post("/execute/analytics/write_insight", async (req) => {
|
||||
const body = req.body as any;
|
||||
const { input, run_id } = body;
|
||||
|
||||
console.log(`💡 Write insight:`, input.insight?.title);
|
||||
|
||||
const insightId = `insight_${Date.now().toString(36)}`;
|
||||
|
||||
return {
|
||||
insight_id: insightId,
|
||||
stored: {
|
||||
bigquery: { dataset: "insights", table: "insights_v1" },
|
||||
firestore: { collection: "insights", doc_id: insightId },
|
||||
gcs: { bucket: "productos-artifacts", prefix: `insights/${insightId}` }
|
||||
},
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT ?? 8091);
|
||||
app.listen({ port, host: "0.0.0.0" }).then(() => {
|
||||
console.log(`📈 Analytics Executor running on http://localhost:${port}`);
|
||||
});
|
||||
13
platform/backend/executors/analytics/tsconfig.json
Normal file
13
platform/backend/executors/analytics/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
23
platform/backend/executors/deploy/package.json
Normal file
23
platform/backend/executors/deploy/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@productos/deploy-executor",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@fastify/sensible": "^6.0.0",
|
||||
"fastify": "^5.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
91
platform/backend/executors/deploy/src/index.ts
Normal file
91
platform/backend/executors/deploy/src/index.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import sensible from "@fastify/sensible";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors, { origin: true });
|
||||
await app.register(sensible);
|
||||
|
||||
// Health check
|
||||
app.get("/healthz", async () => ({ ok: true, executor: "deploy" }));
|
||||
|
||||
/**
|
||||
* Deploy a Cloud Run service
|
||||
* In production: triggers Cloud Build, deploys to Cloud Run
|
||||
* In dev: returns mock response
|
||||
*/
|
||||
app.post("/execute/cloudrun/deploy", async (req) => {
|
||||
const body = req.body as any;
|
||||
const { run_id, tenant_id, input } = body;
|
||||
|
||||
console.log(`🚀 Deploy request:`, { run_id, tenant_id, input });
|
||||
|
||||
// Simulate deployment time
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
|
||||
// In production, this would:
|
||||
// 1. Clone the repo
|
||||
// 2. Trigger Cloud Build
|
||||
// 3. Deploy to Cloud Run
|
||||
// 4. Return the service URL
|
||||
|
||||
const mockRevision = `${input.service_name}-${Date.now().toString(36)}`;
|
||||
const mockUrl = `https://${input.service_name}-abc123.a.run.app`;
|
||||
|
||||
console.log(`✅ Deploy complete:`, { revision: mockRevision, url: mockUrl });
|
||||
|
||||
return {
|
||||
service_url: mockUrl,
|
||||
revision: mockRevision,
|
||||
build_id: `build-${Date.now()}`,
|
||||
deployed_at: new Date().toISOString(),
|
||||
region: input.region ?? "us-central1",
|
||||
env: input.env
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Get Cloud Run service status
|
||||
*/
|
||||
app.post("/execute/cloudrun/status", async (req) => {
|
||||
const body = req.body as any;
|
||||
const { input } = body;
|
||||
|
||||
console.log(`📊 Status request:`, input);
|
||||
|
||||
// Mock status response
|
||||
return {
|
||||
service_name: input.service_name,
|
||||
region: input.region,
|
||||
service_url: `https://${input.service_name}-abc123.a.run.app`,
|
||||
latest_ready_revision: `${input.service_name}-v1`,
|
||||
status: "ready",
|
||||
last_deploy_time: new Date().toISOString(),
|
||||
traffic: [{ revision: `${input.service_name}-v1`, percent: 100 }]
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Rollback to a previous revision
|
||||
*/
|
||||
app.post("/execute/cloudrun/rollback", async (req) => {
|
||||
const body = req.body as any;
|
||||
const { input } = body;
|
||||
|
||||
console.log(`⏪ Rollback request:`, input);
|
||||
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
return {
|
||||
service_name: input.service_name,
|
||||
rolled_back_to: input.target_revision ?? "previous",
|
||||
status: "ready",
|
||||
rolled_back_at: new Date().toISOString()
|
||||
};
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT ?? 8090);
|
||||
app.listen({ port, host: "0.0.0.0" }).then(() => {
|
||||
console.log(`🔧 Deploy Executor running on http://localhost:${port}`);
|
||||
});
|
||||
13
platform/backend/executors/deploy/tsconfig.json
Normal file
13
platform/backend/executors/deploy/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
22
platform/backend/executors/marketing/package.json
Normal file
22
platform/backend/executors/marketing/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@productos/marketing-executor",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@fastify/sensible": "^6.0.0",
|
||||
"fastify": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
88
platform/backend/executors/marketing/src/index.ts
Normal file
88
platform/backend/executors/marketing/src/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import sensible from "@fastify/sensible";
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors, { origin: true });
|
||||
await app.register(sensible);
|
||||
|
||||
app.get("/healthz", async () => ({ ok: true, executor: "marketing" }));
|
||||
|
||||
/**
|
||||
* Generate channel posts from a brief
|
||||
* In production: calls Gemini API
|
||||
*/
|
||||
app.post("/execute/marketing/generate", async (req) => {
|
||||
const body = req.body as any;
|
||||
const { input } = body;
|
||||
|
||||
console.log(`✍️ Generate posts:`, input.brief?.goal);
|
||||
|
||||
await new Promise(r => setTimeout(r, 1000)); // Simulate AI generation time
|
||||
|
||||
const channels = (input.channels ?? ["x", "linkedin"]).map((channel: string) => ({
|
||||
channel,
|
||||
posts: [
|
||||
{
|
||||
text: `🚀 Exciting news! ${input.brief?.product ?? "Our product"} just got even better. ${input.brief?.key_points?.[0] ?? "Check it out!"}\n\n${input.brief?.call_to_action ?? "Learn more"} 👇\n${input.brief?.landing_page_url ?? "https://example.com"}`,
|
||||
hashtags: ["#ProductUpdate", "#SaaS", "#Innovation"],
|
||||
media_suggestions: ["product-screenshot.png", "feature-demo.gif"]
|
||||
},
|
||||
{
|
||||
text: `${input.brief?.audience ?? "Teams"} asked, we listened! Introducing ${input.brief?.product ?? "new features"} that will transform how you work.\n\n✨ ${input.brief?.key_points?.join("\n✨ ") ?? "Amazing new capabilities"}\n\nTry it today!`,
|
||||
hashtags: ["#ProductLaunch", "#Productivity"],
|
||||
media_suggestions: ["comparison-chart.png"]
|
||||
},
|
||||
{
|
||||
text: `Did you know? ${input.brief?.key_points?.[0] ?? "Our latest update"} can save you hours every week.\n\nHere's how ${input.brief?.product ?? "it"} works:\n1️⃣ Set it up in minutes\n2️⃣ Let automation do the work\n3️⃣ Focus on what matters\n\n${input.brief?.offer ?? "Start free today!"}`,
|
||||
hashtags: ["#Automation", "#WorkSmarter"],
|
||||
media_suggestions: ["how-it-works.mp4"]
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
return {
|
||||
channels,
|
||||
brief_summary: input.brief?.goal,
|
||||
generated_at: new Date().toISOString(),
|
||||
variations_per_channel: 3
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Get brand profile
|
||||
*/
|
||||
app.post("/execute/brand/get", async (req) => {
|
||||
const body = req.body as any;
|
||||
const { input } = body;
|
||||
|
||||
console.log(`🎨 Get brand profile:`, input.profile_id);
|
||||
|
||||
return {
|
||||
profile_id: input.profile_id ?? "default",
|
||||
brand: {
|
||||
name: "Product OS",
|
||||
voice: {
|
||||
tone: ["professional", "friendly", "innovative"],
|
||||
style_notes: ["Use active voice", "Keep sentences short", "Be specific with benefits"],
|
||||
do: ["Use data and metrics", "Include calls to action", "Highlight customer success"],
|
||||
dont: ["Make unverified claims", "Use jargon", "Be overly salesy"]
|
||||
},
|
||||
audience: {
|
||||
primary: "SaaS founders and product teams",
|
||||
secondary: "Growth marketers and developers"
|
||||
},
|
||||
claims_policy: {
|
||||
forbidden_claims: ["#1 in the market", "Guaranteed results"],
|
||||
required_disclaimers: [],
|
||||
compliance_notes: ["All metrics should be verifiable"]
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT ?? 8093);
|
||||
app.listen({ port, host: "0.0.0.0" }).then(() => {
|
||||
console.log(`📣 Marketing Executor running on http://localhost:${port}`);
|
||||
});
|
||||
13
platform/backend/executors/marketing/tsconfig.json
Normal file
13
platform/backend/executors/marketing/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
103
platform/backend/mcp-adapter/README.md
Normal file
103
platform/backend/mcp-adapter/README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Product OS MCP Adapter
|
||||
|
||||
Exposes Control Plane tools to Continue and other MCP clients.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Continue (MCP client)
|
||||
↓
|
||||
MCP Adapter (this server)
|
||||
↓
|
||||
Control Plane API
|
||||
↓
|
||||
Gemini + Executors
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `deploy_service` | Deploy a Cloud Run service |
|
||||
| `get_service_status` | Check deployment health |
|
||||
| `get_funnel_analytics` | Get conversion metrics |
|
||||
| `get_top_drivers` | Understand metric drivers |
|
||||
| `generate_marketing_posts` | Create social content |
|
||||
| `chat_with_gemini` | General AI conversation |
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install dependencies
|
||||
|
||||
```bash
|
||||
cd platform/backend/mcp-adapter
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Make sure Control Plane is running
|
||||
|
||||
```bash
|
||||
cd platform/backend/control-plane
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. Install Continue Extension
|
||||
|
||||
In VS Code/VSCodium:
|
||||
1. Open Extensions (Cmd+Shift+X)
|
||||
2. Search for "Continue"
|
||||
3. Install
|
||||
|
||||
### 4. Configure Continue
|
||||
|
||||
The configuration is already in `.continue/config.yaml`.
|
||||
|
||||
Or manually add to your Continue config:
|
||||
|
||||
```yaml
|
||||
experimental:
|
||||
modelContextProtocolServers:
|
||||
- name: productos
|
||||
command: npx
|
||||
args:
|
||||
- tsx
|
||||
- /path/to/platform/backend/mcp-adapter/src/index.ts
|
||||
env:
|
||||
CONTROL_PLANE_URL: http://localhost:8080
|
||||
```
|
||||
|
||||
### 5. Use in Continue
|
||||
|
||||
1. Open Continue chat (Cmd+L)
|
||||
2. Enable Agent mode (click the robot icon)
|
||||
3. Ask: "Deploy my service to staging"
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CONTROL_PLANE_URL` | `http://localhost:8080` | Control Plane API URL |
|
||||
| `TENANT_ID` | `t_mcp` | Tenant ID for tool calls |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run directly (for testing)
|
||||
npm run dev
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Run built version
|
||||
npm start
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test tool listing
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | npm run dev
|
||||
|
||||
# Test tool call
|
||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_funnel_analytics","arguments":{}}}' | npm run dev
|
||||
```
|
||||
25
platform/backend/mcp-adapter/package.json
Normal file
25
platform/backend/mcp-adapter/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@productos/mcp-adapter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "MCP Adapter Server - exposes Control Plane tools to Continue and other MCP clients",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"productos-mcp": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
343
platform/backend/mcp-adapter/src/index.ts
Normal file
343
platform/backend/mcp-adapter/src/index.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Product OS MCP Adapter Server
|
||||
*
|
||||
* Exposes Control Plane tools to Continue and other MCP clients.
|
||||
*
|
||||
* Architecture:
|
||||
* Continue (MCP client) → This Adapter → Control Plane → Gemini + Executors
|
||||
*
|
||||
* This keeps:
|
||||
* - Control Plane as the canonical API
|
||||
* - Auth/billing/policies centralized
|
||||
* - MCP as a compatibility layer
|
||||
*/
|
||||
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
ToolSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { z } from "zod";
|
||||
|
||||
// Control Plane URL (configurable via env)
|
||||
const CONTROL_PLANE_URL = process.env.CONTROL_PLANE_URL || "http://localhost:8080";
|
||||
const TENANT_ID = process.env.TENANT_ID || "t_mcp";
|
||||
|
||||
// ============================================================================
|
||||
// Tool Definitions
|
||||
// ============================================================================
|
||||
|
||||
const TOOLS: ToolSchema[] = [
|
||||
{
|
||||
name: "deploy_service",
|
||||
description: "Deploy a Cloud Run service to GCP. Use when the user wants to deploy, ship, or launch code to staging or production.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
service_name: {
|
||||
type: "string",
|
||||
description: "Name of the service to deploy"
|
||||
},
|
||||
repo: {
|
||||
type: "string",
|
||||
description: "Git repository URL (optional, defaults to current workspace)"
|
||||
},
|
||||
ref: {
|
||||
type: "string",
|
||||
description: "Git branch, tag, or commit (optional, defaults to main)"
|
||||
},
|
||||
env: {
|
||||
type: "string",
|
||||
enum: ["dev", "staging", "prod"],
|
||||
description: "Target environment"
|
||||
}
|
||||
},
|
||||
required: ["service_name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_service_status",
|
||||
description: "Check the status of a deployed Cloud Run service. Use when the user asks about service health or deployment status.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
service_name: {
|
||||
type: "string",
|
||||
description: "Name of the service to check"
|
||||
},
|
||||
region: {
|
||||
type: "string",
|
||||
description: "GCP region (defaults to us-central1)"
|
||||
}
|
||||
},
|
||||
required: ["service_name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_funnel_analytics",
|
||||
description: "Get funnel conversion metrics and drop-off analysis. Use when the user asks about funnels, conversions, or user journey.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
funnel_name: {
|
||||
type: "string",
|
||||
description: "Name of the funnel to analyze (optional)"
|
||||
},
|
||||
range_days: {
|
||||
type: "integer",
|
||||
description: "Number of days to analyze (defaults to 30)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_top_drivers",
|
||||
description: "Identify top factors driving a metric. Use when the user asks why something changed or what drives conversions/retention.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
metric: {
|
||||
type: "string",
|
||||
description: "The metric to analyze (e.g., 'conversion', 'retention', 'churn')"
|
||||
},
|
||||
range_days: {
|
||||
type: "integer",
|
||||
description: "Number of days to analyze (defaults to 30)"
|
||||
}
|
||||
},
|
||||
required: ["metric"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "generate_marketing_posts",
|
||||
description: "Generate social media posts for a marketing campaign. Use when the user wants to create content for X, LinkedIn, etc.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
goal: {
|
||||
type: "string",
|
||||
description: "Campaign goal (e.g., 'product launch', 'feature announcement', 'engagement')"
|
||||
},
|
||||
product: {
|
||||
type: "string",
|
||||
description: "Product or feature name"
|
||||
},
|
||||
channels: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Social channels to generate for (e.g., ['x', 'linkedin'])"
|
||||
},
|
||||
tone: {
|
||||
type: "string",
|
||||
description: "Tone of voice (e.g., 'professional', 'casual', 'excited')"
|
||||
}
|
||||
},
|
||||
required: ["goal"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "chat_with_gemini",
|
||||
description: "Have a conversation with Gemini AI about your product, code, or anything else. Use for general questions, code explanation, or ideation.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: {
|
||||
type: "string",
|
||||
description: "The message or question to send to Gemini"
|
||||
},
|
||||
context: {
|
||||
type: "string",
|
||||
description: "Additional context (e.g., code snippet, file contents)"
|
||||
}
|
||||
},
|
||||
required: ["message"]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// Tool Name Mapping (MCP tool name → Control Plane tool name)
|
||||
// ============================================================================
|
||||
|
||||
const TOOL_MAPPING: Record<string, string> = {
|
||||
"deploy_service": "cloudrun.deploy_service",
|
||||
"get_service_status": "cloudrun.get_service_status",
|
||||
"get_funnel_analytics": "analytics.funnel_summary",
|
||||
"get_top_drivers": "analytics.top_drivers",
|
||||
"generate_marketing_posts": "marketing.generate_channel_posts",
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Control Plane Client
|
||||
// ============================================================================
|
||||
|
||||
async function invokeControlPlaneTool(tool: string, input: any): Promise<any> {
|
||||
// Step 1: Invoke the tool
|
||||
const invokeResponse = await fetch(`${CONTROL_PLANE_URL}/tools/invoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tool,
|
||||
tenant_id: TENANT_ID,
|
||||
input,
|
||||
dry_run: false
|
||||
})
|
||||
});
|
||||
|
||||
if (!invokeResponse.ok) {
|
||||
const text = await invokeResponse.text();
|
||||
throw new Error(`Control Plane error: ${invokeResponse.status} - ${text}`);
|
||||
}
|
||||
|
||||
const invokeResult = await invokeResponse.json();
|
||||
|
||||
// Step 2: Fetch the full run to get the output
|
||||
const runResponse = await fetch(`${CONTROL_PLANE_URL}/runs/${invokeResult.run_id}`);
|
||||
|
||||
if (!runResponse.ok) {
|
||||
// Fall back to the invoke result if we can't fetch the run
|
||||
return invokeResult;
|
||||
}
|
||||
|
||||
return runResponse.json();
|
||||
}
|
||||
|
||||
async function chatWithControlPlane(message: string, context?: string): Promise<any> {
|
||||
const messages = [];
|
||||
|
||||
if (context) {
|
||||
messages.push({ role: "user", content: `Context:\n${context}` });
|
||||
}
|
||||
messages.push({ role: "user", content: message });
|
||||
|
||||
const response = await fetch(`${CONTROL_PLANE_URL}/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
autoExecuteTools: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Control Plane chat error: ${response.status} - ${text}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MCP Server
|
||||
// ============================================================================
|
||||
|
||||
const server = new Server(
|
||||
{
|
||||
name: "productos-mcp",
|
||||
version: "0.1.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// List available tools
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return { tools: TOOLS };
|
||||
});
|
||||
|
||||
// Handle tool calls
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
console.error(`[MCP] Tool called: ${name}`, args);
|
||||
|
||||
try {
|
||||
// Special handling for chat
|
||||
if (name === "chat_with_gemini") {
|
||||
const result = await chatWithControlPlane(
|
||||
(args as any).message,
|
||||
(args as any).context
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: result.message || JSON.stringify(result, null, 2)
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// Map MCP tool name to Control Plane tool name
|
||||
const controlPlaneTool = TOOL_MAPPING[name];
|
||||
if (!controlPlaneTool) {
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
// Invoke the Control Plane
|
||||
const run = await invokeControlPlaneTool(controlPlaneTool, args);
|
||||
|
||||
// Format the response
|
||||
let responseText = "";
|
||||
|
||||
if (run.status === "succeeded") {
|
||||
responseText = `✅ **${name}** completed successfully\n\n`;
|
||||
responseText += "**Result:**\n```json\n";
|
||||
responseText += JSON.stringify(run.output, null, 2);
|
||||
responseText += "\n```";
|
||||
} else if (run.status === "failed") {
|
||||
responseText = `❌ **${name}** failed\n\n`;
|
||||
responseText += `**Error:** ${run.error?.message || "Unknown error"}`;
|
||||
} else {
|
||||
responseText = `⏳ **${name}** is ${run.status}\n\n`;
|
||||
responseText += `**Run ID:** ${run.run_id}`;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: responseText
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`[MCP] Error:`, error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `❌ Error executing ${name}: ${error.message}`
|
||||
}
|
||||
],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Start Server
|
||||
// ============================================================================
|
||||
|
||||
async function main() {
|
||||
console.error("[MCP] Product OS MCP Adapter starting...");
|
||||
console.error(`[MCP] Control Plane URL: ${CONTROL_PLANE_URL}`);
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
console.error("[MCP] Server connected and ready");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("[MCP] Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
14
platform/backend/mcp-adapter/tsconfig.json
Normal file
14
platform/backend/mcp-adapter/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 266 B |
97
platform/client-ide/extensions/gcp-productos/package.json
Normal file
97
platform/client-ide/extensions/gcp-productos/package.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "gcp-productos",
|
||||
"displayName": "GCP Product OS",
|
||||
"description": "Product-centric IDE for launching and operating SaaS products on Google Cloud.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "productos",
|
||||
"engines": { "vscode": "^1.90.0" },
|
||||
"categories": ["Other"],
|
||||
"activationEvents": ["onStartupFinished"],
|
||||
"main": "./dist/extension.js",
|
||||
"contributes": {
|
||||
"viewsContainers": {
|
||||
"activitybar": [
|
||||
{
|
||||
"id": "productos",
|
||||
"title": "Product OS",
|
||||
"icon": "media/icon.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
"productos": [
|
||||
{
|
||||
"id": "productos.chat",
|
||||
"type": "webview",
|
||||
"name": "Chat",
|
||||
"icon": "$(comment-discussion)"
|
||||
},
|
||||
{
|
||||
"id": "productos.tools",
|
||||
"name": "Tools",
|
||||
"icon": "media/icon.svg"
|
||||
},
|
||||
{
|
||||
"id": "productos.runs",
|
||||
"name": "Recent Runs"
|
||||
}
|
||||
]
|
||||
},
|
||||
"viewsWelcome": [
|
||||
{
|
||||
"view": "productos.tools",
|
||||
"contents": "Connect to Product OS backend to see available tools.\n[Configure Backend](command:productos.configure)"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{ "command": "productos.chat", "title": "Product OS: Open Chat", "icon": "$(comment-discussion)" },
|
||||
{ "command": "productos.configure", "title": "Product OS: Configure Backend", "icon": "$(gear)" },
|
||||
{ "command": "productos.refresh", "title": "Product OS: Refresh", "icon": "$(refresh)" },
|
||||
{ "command": "productos.tools.list", "title": "Product OS: List Tools" },
|
||||
{ "command": "productos.tools.invoke", "title": "Product OS: Invoke Tool", "icon": "$(play)" },
|
||||
{ "command": "productos.tools.invokeFromTree", "title": "Invoke Tool", "icon": "$(play)" },
|
||||
{ "command": "productos.runs.open", "title": "Product OS: Open Run" },
|
||||
{ "command": "productos.runs.openFromTree", "title": "View Run Details", "icon": "$(eye)" },
|
||||
{ "command": "productos.openPanel", "title": "Product OS: Open Panel" }
|
||||
],
|
||||
"keybindings": [
|
||||
{ "command": "productos.chat", "key": "ctrl+shift+p", "mac": "cmd+shift+p", "when": "editorTextFocus" }
|
||||
],
|
||||
"menus": {
|
||||
"view/title": [
|
||||
{ "command": "productos.refresh", "when": "view == productos.tools", "group": "navigation" },
|
||||
{ "command": "productos.configure", "when": "view == productos.tools" }
|
||||
],
|
||||
"view/item/context": [
|
||||
{ "command": "productos.tools.invokeFromTree", "when": "viewItem == tool", "group": "inline" },
|
||||
{ "command": "productos.runs.openFromTree", "when": "viewItem == run", "group": "inline" }
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Product OS",
|
||||
"properties": {
|
||||
"productos.backendUrl": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:8080",
|
||||
"description": "Control Plane API base URL"
|
||||
},
|
||||
"productos.tenantId": {
|
||||
"type": "string",
|
||||
"default": "t_dev",
|
||||
"description": "Tenant ID for tool calls"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"watch": "tsc -w -p tsconfig.json",
|
||||
"package": "vsce package --allow-missing-repository"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/vscode": "^1.90.0",
|
||||
"@vscode/vsce": "^3.0.0",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
137
platform/client-ide/extensions/gcp-productos/src/api.ts
Normal file
137
platform/client-ide/extensions/gcp-productos/src/api.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import * as vscode from "vscode";
|
||||
|
||||
export interface Tool {
|
||||
name: string;
|
||||
description: string;
|
||||
risk: "low" | "medium" | "high";
|
||||
executor: {
|
||||
kind: string;
|
||||
url: string;
|
||||
path: string;
|
||||
};
|
||||
inputSchema: any;
|
||||
outputSchema?: any;
|
||||
}
|
||||
|
||||
export interface Run {
|
||||
run_id: string;
|
||||
tenant_id: string;
|
||||
tool: string;
|
||||
status: "queued" | "running" | "succeeded" | "failed";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
input: any;
|
||||
output?: any;
|
||||
error?: { message: string; details?: any };
|
||||
}
|
||||
|
||||
function getConfig<T>(key: string): T {
|
||||
return vscode.workspace.getConfiguration("productos").get<T>(key)!;
|
||||
}
|
||||
|
||||
export function getBackendUrl(): string {
|
||||
return getConfig<string>("backendUrl");
|
||||
}
|
||||
|
||||
export function getTenantId(): string {
|
||||
return getConfig<string>("tenantId");
|
||||
}
|
||||
|
||||
export async function checkConnection(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${getBackendUrl()}/healthz`, {
|
||||
signal: AbortSignal.timeout(3000)
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listTools(): Promise<Tool[]> {
|
||||
const res = await fetch(`${getBackendUrl()}/tools`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const json = await res.json();
|
||||
return json.tools ?? [];
|
||||
}
|
||||
|
||||
export async function invokeTool(tool: string, input: any, dryRun = false): Promise<Run> {
|
||||
const res = await fetch(`${getBackendUrl()}/tools/invoke`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
tool,
|
||||
tenant_id: getTenantId(),
|
||||
input,
|
||||
dry_run: dryRun
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getRun(runId: string): Promise<Run> {
|
||||
const res = await fetch(`${getBackendUrl()}/runs/${runId}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Store recent runs in memory
|
||||
const recentRuns: Run[] = [];
|
||||
|
||||
export function addRecentRun(run: Run) {
|
||||
recentRuns.unshift(run);
|
||||
if (recentRuns.length > 20) recentRuns.pop();
|
||||
}
|
||||
|
||||
export function getRecentRuns(): Run[] {
|
||||
return [...recentRuns];
|
||||
}
|
||||
|
||||
// Chat types
|
||||
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[];
|
||||
runs?: Run[];
|
||||
finishReason: "stop" | "tool_calls" | "error";
|
||||
}
|
||||
|
||||
export interface ChatContext {
|
||||
files?: { path: string; content: string }[];
|
||||
selection?: { path: string; text: string; startLine: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat with the AI backend
|
||||
*/
|
||||
export async function chatWithAI(
|
||||
messages: ChatMessage[],
|
||||
context?: ChatContext
|
||||
): Promise<ChatResponse> {
|
||||
const res = await fetch(`${getBackendUrl()}/chat`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
messages,
|
||||
context,
|
||||
autoExecuteTools: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Chat failed: ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
850
platform/client-ide/extensions/gcp-productos/src/chatPanel.ts
Normal file
850
platform/client-ide/extensions/gcp-productos/src/chatPanel.ts
Normal file
@@ -0,0 +1,850 @@
|
||||
import * as vscode from "vscode";
|
||||
import { chatWithAI, ChatMessage, ChatResponse } from "./api";
|
||||
|
||||
/**
|
||||
* Product OS Chat Panel
|
||||
* A Cursor-like conversational AI interface
|
||||
*/
|
||||
export class ChatPanel {
|
||||
public static currentPanel: ChatPanel | undefined;
|
||||
private static readonly viewType = "productosChat";
|
||||
|
||||
private readonly _panel: vscode.WebviewPanel;
|
||||
private readonly _extensionUri: vscode.Uri;
|
||||
private _disposables: vscode.Disposable[] = [];
|
||||
private _messages: ChatMessage[] = [];
|
||||
|
||||
public static createOrShow(extensionUri: vscode.Uri) {
|
||||
const column = vscode.window.activeTextEditor
|
||||
? vscode.window.activeTextEditor.viewColumn
|
||||
: undefined;
|
||||
|
||||
// If we already have a panel, show it
|
||||
if (ChatPanel.currentPanel) {
|
||||
ChatPanel.currentPanel._panel.reveal(column);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, create a new panel
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
ChatPanel.viewType,
|
||||
"Product OS Chat",
|
||||
column || vscode.ViewColumn.One,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [vscode.Uri.joinPath(extensionUri, "media")]
|
||||
}
|
||||
);
|
||||
|
||||
ChatPanel.currentPanel = new ChatPanel(panel, extensionUri);
|
||||
}
|
||||
|
||||
private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) {
|
||||
this._panel = panel;
|
||||
this._extensionUri = extensionUri;
|
||||
|
||||
// Set the webview's initial html content
|
||||
this._update();
|
||||
|
||||
// Listen for when the panel is disposed
|
||||
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
|
||||
|
||||
// Handle messages from the webview
|
||||
this._panel.webview.onDidReceiveMessage(
|
||||
async (message) => {
|
||||
switch (message.command) {
|
||||
case "send":
|
||||
await this._handleChat(message.text);
|
||||
return;
|
||||
case "addContext":
|
||||
await this._handleAddContext();
|
||||
return;
|
||||
case "clear":
|
||||
this._messages = [];
|
||||
this._update();
|
||||
return;
|
||||
}
|
||||
},
|
||||
null,
|
||||
this._disposables
|
||||
);
|
||||
}
|
||||
|
||||
private async _handleChat(text: string) {
|
||||
// Add user message to history (webview already shows it)
|
||||
this._messages.push({ role: "user", content: text });
|
||||
// DON'T call _update() - it would reset the webview and kill the JS state
|
||||
|
||||
// Show loading state
|
||||
this._panel.webview.postMessage({ type: "loading", loading: true });
|
||||
|
||||
try {
|
||||
// Get context from active editor
|
||||
const context = this._getEditorContext();
|
||||
|
||||
console.log("[Product OS Chat] Sending to API:", text);
|
||||
|
||||
// Call the AI
|
||||
const response = await chatWithAI(this._messages, context);
|
||||
|
||||
console.log("[Product OS Chat] Response:", response);
|
||||
|
||||
// Add assistant response to history
|
||||
this._messages.push({ role: "assistant", content: response.message || "" });
|
||||
|
||||
// Send response to webview
|
||||
this._panel.webview.postMessage({
|
||||
type: "response",
|
||||
message: response.message,
|
||||
toolCalls: response.toolCalls,
|
||||
runs: response.runs
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("[Product OS Chat] Error:", error);
|
||||
this._panel.webview.postMessage({
|
||||
type: "error",
|
||||
error: error.message || "Unknown error"
|
||||
});
|
||||
} finally {
|
||||
this._panel.webview.postMessage({ type: "loading", loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleAddContext() {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
vscode.window.showWarningMessage("No active editor");
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = editor.selection;
|
||||
const selectedText = editor.document.getText(selection);
|
||||
|
||||
if (selectedText) {
|
||||
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
|
||||
const startLine = selection.start.line + 1;
|
||||
|
||||
this._panel.webview.postMessage({
|
||||
type: "contextAdded",
|
||||
context: {
|
||||
type: "selection",
|
||||
path: filePath,
|
||||
startLine,
|
||||
text: selectedText
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No selection, add the whole file
|
||||
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
|
||||
const content = editor.document.getText();
|
||||
|
||||
this._panel.webview.postMessage({
|
||||
type: "contextAdded",
|
||||
context: {
|
||||
type: "file",
|
||||
path: filePath,
|
||||
text: content.substring(0, 5000) // Limit to first 5000 chars
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _getEditorContext(): any {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) return undefined;
|
||||
|
||||
const selection = editor.selection;
|
||||
const selectedText = editor.document.getText(selection);
|
||||
|
||||
if (selectedText) {
|
||||
return {
|
||||
selection: {
|
||||
path: vscode.workspace.asRelativePath(editor.document.uri),
|
||||
text: selectedText,
|
||||
startLine: selection.start.line + 1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
ChatPanel.currentPanel = undefined;
|
||||
|
||||
// Clean up resources
|
||||
this._panel.dispose();
|
||||
|
||||
while (this._disposables.length) {
|
||||
const x = this._disposables.pop();
|
||||
if (x) {
|
||||
x.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _update() {
|
||||
this._panel.webview.html = this._getHtmlForWebview();
|
||||
}
|
||||
|
||||
private _getHtmlForWebview() {
|
||||
const nonce = getNonce();
|
||||
|
||||
// Convert messages to HTML
|
||||
const messagesHtml = this._messages.map(m => {
|
||||
const isUser = m.role === "user";
|
||||
const avatarClass = isUser ? "user-avatar" : "ai-avatar";
|
||||
const messageClass = isUser ? "user-message" : "ai-message";
|
||||
const avatar = isUser ? "U" : "✦";
|
||||
|
||||
return `
|
||||
<div class="message ${messageClass}">
|
||||
<div class="avatar ${avatarClass}">${avatar}</div>
|
||||
<div class="content">${escapeHtml(m.content)}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
|
||||
<title>Product OS Chat</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--accent: #58a6ff;
|
||||
--accent-muted: #1f6feb;
|
||||
--text-primary: #e6edf3;
|
||||
--text-secondary: #8b949e;
|
||||
--text-muted: #6e7681;
|
||||
--border: #30363d;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--danger: #f85149;
|
||||
--gradient-start: #0d1117;
|
||||
--gradient-end: #161b22;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
|
||||
background: linear-gradient(180deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
|
||||
color: var(--text-primary);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(13, 17, 23, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ai-avatar {
|
||||
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-message .content {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.ai-message .content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 8px 32px rgba(88, 166, 255, 0.3);
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
max-width: 360px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 24px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.suggestion:hover {
|
||||
background: var(--border);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-muted);
|
||||
}
|
||||
|
||||
.input-area {
|
||||
padding: 16px 20px 24px;
|
||||
background: rgba(13, 17, 23, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.context-badge {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.context-badge.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.context-badge code {
|
||||
color: var(--accent);
|
||||
background: var(--bg-secondary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.context-close {
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.context-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
max-height: 200px;
|
||||
padding: 14px 16px;
|
||||
padding-right: 44px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: var(--accent-muted);
|
||||
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.add-context-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.add-context-btn:hover {
|
||||
background: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
transition: all 0.15s ease;
|
||||
box-shadow: 0 4px 12px rgba(88, 166, 255, 0.3);
|
||||
}
|
||||
|
||||
.send-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(88, 166, 255, 0.4);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.loading.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.loading-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.4s infinite;
|
||||
}
|
||||
|
||||
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.tool-result {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tool-result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-result-status {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tool-result-status.success { background: var(--success); }
|
||||
.tool-result-status.error { background: var(--danger); }
|
||||
|
||||
.tool-result code {
|
||||
color: var(--accent);
|
||||
background: var(--bg-secondary);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-title">
|
||||
<div class="header-icon">✦</div>
|
||||
<h1>Product OS Chat</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="header-btn" onclick="clearChat()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="messages" id="messages">
|
||||
${messagesHtml || `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">✦</div>
|
||||
<h2>Welcome to Product OS</h2>
|
||||
<p>I can help you deploy services, analyze metrics, generate marketing content, and write code—all in one place.</p>
|
||||
<div class="suggestions">
|
||||
<button class="suggestion" onclick="sendSuggestion('Deploy my service to staging')">Deploy to staging</button>
|
||||
<button class="suggestion" onclick="sendSuggestion('Show me funnel analytics')">Funnel analytics</button>
|
||||
<button class="suggestion" onclick="sendSuggestion('Generate launch posts for X and LinkedIn')">Marketing posts</button>
|
||||
<button class="suggestion" onclick="sendSuggestion('What drives my conversion rate?')">Conversion drivers</button>
|
||||
</div>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="loading-dots">
|
||||
<div class="loading-dot"></div>
|
||||
<div class="loading-dot"></div>
|
||||
<div class="loading-dot"></div>
|
||||
</div>
|
||||
<span>Product OS is thinking...</span>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<div class="context-badge" id="context-badge">
|
||||
<span>📎 Context:</span>
|
||||
<code id="context-path"></code>
|
||||
<span class="context-close" onclick="clearContext()">✕</span>
|
||||
</div>
|
||||
<div class="input-wrapper">
|
||||
<div class="input-container">
|
||||
<textarea
|
||||
id="input"
|
||||
placeholder="Ask me to deploy, analyze, or generate..."
|
||||
rows="1"
|
||||
onkeydown="handleKeydown(event)"
|
||||
oninput="autoResize(this)"
|
||||
></textarea>
|
||||
<button class="add-context-btn" onclick="addContext()" title="Add code context">@</button>
|
||||
</div>
|
||||
<button class="send-btn" id="send-btn" onclick="sendMessage()">→</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="${nonce}">
|
||||
const vscode = acquireVsCodeApi();
|
||||
let currentContext = null;
|
||||
|
||||
function sendMessage() {
|
||||
const input = document.getElementById('input');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
// Send to extension
|
||||
vscode.postMessage({ command: 'send', text, context: currentContext });
|
||||
|
||||
// Clear input and context
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
clearContext();
|
||||
|
||||
// Add user message to UI immediately
|
||||
addMessageToUI('user', text);
|
||||
}
|
||||
|
||||
function sendSuggestion(text) {
|
||||
document.getElementById('input').value = text;
|
||||
sendMessage();
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function autoResize(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
||||
}
|
||||
|
||||
function addContext() {
|
||||
vscode.postMessage({ command: 'addContext' });
|
||||
}
|
||||
|
||||
function clearContext() {
|
||||
currentContext = null;
|
||||
document.getElementById('context-badge').classList.remove('active');
|
||||
}
|
||||
|
||||
function clearChat() {
|
||||
vscode.postMessage({ command: 'clear' });
|
||||
}
|
||||
|
||||
function addMessageToUI(role, content) {
|
||||
const messages = document.getElementById('messages');
|
||||
const isEmpty = messages.querySelector('.empty-state');
|
||||
if (isEmpty) isEmpty.remove();
|
||||
|
||||
const isUser = role === 'user';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message ' + (isUser ? 'user-message' : 'ai-message');
|
||||
div.innerHTML = \`
|
||||
<div class="avatar \${isUser ? 'user-avatar' : 'ai-avatar'}">\${isUser ? 'U' : '✦'}</div>
|
||||
<div class="content">\${escapeHtml(content)}</div>
|
||||
\`;
|
||||
messages.appendChild(div);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML.replace(/\\n/g, '<br>');
|
||||
}
|
||||
|
||||
function formatMessage(text) {
|
||||
// Simple markdown-like formatting
|
||||
return text
|
||||
.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
|
||||
.replace(/\`(.+?)\`/g, '<code>$1</code>')
|
||||
.replace(/\\n/g, '<br>');
|
||||
}
|
||||
|
||||
window.addEventListener('message', event => {
|
||||
const message = event.data;
|
||||
|
||||
switch (message.type) {
|
||||
case 'loading':
|
||||
document.getElementById('loading').classList.toggle('active', message.loading);
|
||||
document.getElementById('send-btn').disabled = message.loading;
|
||||
break;
|
||||
|
||||
case 'response':
|
||||
// Add AI response to UI
|
||||
if (message.message) {
|
||||
addMessageToUI('assistant', message.message);
|
||||
}
|
||||
|
||||
// Show tool results if any
|
||||
if (message.runs && message.runs.length > 0) {
|
||||
const messages = document.getElementById('messages');
|
||||
message.runs.forEach(run => {
|
||||
const resultDiv = document.createElement('div');
|
||||
resultDiv.className = 'tool-result';
|
||||
const statusClass = run.status === 'succeeded' ? 'success' : 'error';
|
||||
resultDiv.innerHTML = \`
|
||||
<div class="tool-result-header">
|
||||
<div class="tool-result-status \${statusClass}"></div>
|
||||
<span>Executed <code>\${run.tool}</code></span>
|
||||
</div>
|
||||
\${run.output ? \`<div class="code-block"><pre>\${JSON.stringify(run.output, null, 2)}</pre></div>\` : ''}
|
||||
\`;
|
||||
messages.appendChild(resultDiv);
|
||||
});
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
addMessageToUI('assistant', '❌ Error: ' + message.error);
|
||||
break;
|
||||
|
||||
case 'contextAdded':
|
||||
currentContext = message.context;
|
||||
document.getElementById('context-badge').classList.add('active');
|
||||
document.getElementById('context-path').textContent = message.context.path;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Focus input on load
|
||||
document.getElementById('input').focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getNonce() {
|
||||
let text = "";
|
||||
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
for (let i = 0; i < 32; i++) {
|
||||
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
import * as vscode from "vscode";
|
||||
import { chatWithAI, ChatMessage, ChatResponse } from "./api";
|
||||
|
||||
/**
|
||||
* Sidebar Chat View Provider
|
||||
* Embedded chat experience in the Product OS sidebar
|
||||
*/
|
||||
export class ChatViewProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly viewType = "productos.chat";
|
||||
|
||||
private _view?: vscode.WebviewView;
|
||||
private _messages: ChatMessage[] = [];
|
||||
|
||||
constructor(private readonly _extensionUri: vscode.Uri) {}
|
||||
|
||||
public resolveWebviewView(
|
||||
webviewView: vscode.WebviewView,
|
||||
_context: vscode.WebviewViewResolveContext,
|
||||
_token: vscode.CancellationToken
|
||||
) {
|
||||
this._view = webviewView;
|
||||
|
||||
webviewView.webview.options = {
|
||||
enableScripts: true,
|
||||
localResourceRoots: [this._extensionUri]
|
||||
};
|
||||
|
||||
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
|
||||
|
||||
webviewView.webview.onDidReceiveMessage(async (message) => {
|
||||
switch (message.command) {
|
||||
case "send":
|
||||
await this._handleChat(message.text);
|
||||
return;
|
||||
case "addContext":
|
||||
await this._handleAddContext();
|
||||
return;
|
||||
case "clear":
|
||||
this._messages = [];
|
||||
this._updateView();
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async _handleChat(text: string) {
|
||||
if (!this._view) return;
|
||||
|
||||
// Add user message to internal history (webview already shows it)
|
||||
this._messages.push({ role: "user", content: text });
|
||||
// DON'T call _updateView() - it would reset the webview and kill the JS state
|
||||
|
||||
// Show loading
|
||||
this._view.webview.postMessage({ type: "loading", loading: true });
|
||||
|
||||
try {
|
||||
// Get editor context
|
||||
const context = this._getEditorContext();
|
||||
|
||||
console.log("[Product OS Chat] Sending to API:", text);
|
||||
|
||||
// Call AI
|
||||
const response = await chatWithAI(this._messages, context);
|
||||
|
||||
console.log("[Product OS Chat] Response:", response);
|
||||
|
||||
// Add assistant response to history
|
||||
this._messages.push({ role: "assistant", content: response.message || "" });
|
||||
|
||||
// Send to webview
|
||||
this._view.webview.postMessage({
|
||||
type: "response",
|
||||
message: response.message,
|
||||
toolCalls: response.toolCalls,
|
||||
runs: response.runs
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("[Product OS Chat] Error:", error);
|
||||
this._view.webview.postMessage({
|
||||
type: "error",
|
||||
error: error.message || "Unknown error"
|
||||
});
|
||||
} finally {
|
||||
this._view.webview.postMessage({ type: "loading", loading: false });
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleAddContext() {
|
||||
if (!this._view) return;
|
||||
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
vscode.window.showWarningMessage("No active editor");
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = editor.selection;
|
||||
const selectedText = editor.document.getText(selection);
|
||||
|
||||
if (selectedText) {
|
||||
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
|
||||
this._view.webview.postMessage({
|
||||
type: "contextAdded",
|
||||
context: {
|
||||
type: "selection",
|
||||
path: filePath,
|
||||
startLine: selection.start.line + 1,
|
||||
text: selectedText
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
|
||||
const content = editor.document.getText();
|
||||
this._view.webview.postMessage({
|
||||
type: "contextAdded",
|
||||
context: {
|
||||
type: "file",
|
||||
path: filePath,
|
||||
text: content.substring(0, 5000)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _getEditorContext(): any {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) return undefined;
|
||||
|
||||
const selection = editor.selection;
|
||||
const selectedText = editor.document.getText(selection);
|
||||
|
||||
if (selectedText) {
|
||||
return {
|
||||
selection: {
|
||||
path: vscode.workspace.asRelativePath(editor.document.uri),
|
||||
text: selectedText,
|
||||
startLine: selection.start.line + 1
|
||||
}
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _updateView() {
|
||||
if (this._view) {
|
||||
this._view.webview.html = this._getHtmlForWebview(this._view.webview);
|
||||
}
|
||||
}
|
||||
|
||||
private _getHtmlForWebview(webview: vscode.Webview) {
|
||||
const nonce = getNonce();
|
||||
|
||||
const messagesHtml = this._messages
|
||||
.map((m) => {
|
||||
const isUser = m.role === "user";
|
||||
const avatarClass = isUser ? "user-avatar" : "ai-avatar";
|
||||
const messageClass = isUser ? "user-message" : "ai-message";
|
||||
const avatar = isUser ? "U" : "✦";
|
||||
|
||||
return `
|
||||
<div class="message ${messageClass}">
|
||||
<div class="avatar ${avatarClass}">${avatar}</div>
|
||||
<div class="content">${escapeHtml(m.content)}</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
|
||||
<title>Product OS Chat</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: var(--vscode-editor-background);
|
||||
--bg-secondary: var(--vscode-sideBar-background);
|
||||
--bg-tertiary: var(--vscode-input-background);
|
||||
--accent: var(--vscode-focusBorder);
|
||||
--text-primary: var(--vscode-foreground);
|
||||
--text-secondary: var(--vscode-descriptionForeground);
|
||||
--border: var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: var(--vscode-badge-background);
|
||||
color: var(--vscode-badge-foreground);
|
||||
}
|
||||
|
||||
.ai-avatar {
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.5;
|
||||
font-size: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: var(--vscode-button-background);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.suggestion {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.1s ease;
|
||||
}
|
||||
|
||||
.suggestion:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.input-area {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.context-badge {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.context-badge.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.context-badge code {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.context-close {
|
||||
margin-left: auto;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.context-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
max-height: 120px;
|
||||
padding: 8px 32px 8px 10px;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.add-context-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.add-context-btn:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--vscode-button-background);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--vscode-button-foreground);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.send-btn:hover {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.loading.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.loading-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.4s infinite;
|
||||
}
|
||||
|
||||
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
40% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.tool-result {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tool-result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-result-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.tool-result-status.success { background: var(--vscode-testing-iconPassed); }
|
||||
.tool-result-status.error { background: var(--vscode-testing-iconFailed); }
|
||||
|
||||
.code-block {
|
||||
background: var(--vscode-editor-background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
margin-top: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="messages" id="messages">
|
||||
${
|
||||
messagesHtml ||
|
||||
`
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">✦</div>
|
||||
<h2>Product OS Chat</h2>
|
||||
<p>Deploy, analyze, and create with AI.</p>
|
||||
<div class="suggestions">
|
||||
<button class="suggestion" onclick="sendSuggestion('Deploy to staging')">🚀 Deploy to staging</button>
|
||||
<button class="suggestion" onclick="sendSuggestion('Show funnel analytics')">📊 Funnel analytics</button>
|
||||
<button class="suggestion" onclick="sendSuggestion('Generate marketing posts')">📣 Marketing posts</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="loading" id="loading">
|
||||
<div class="loading-dots">
|
||||
<div class="loading-dot"></div>
|
||||
<div class="loading-dot"></div>
|
||||
<div class="loading-dot"></div>
|
||||
</div>
|
||||
<span>Thinking...</span>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<div class="context-badge" id="context-badge">
|
||||
<span>📎</span>
|
||||
<code id="context-path"></code>
|
||||
<span class="context-close" onclick="clearContext()">✕</span>
|
||||
</div>
|
||||
<div class="input-wrapper">
|
||||
<div class="input-container">
|
||||
<textarea
|
||||
id="input"
|
||||
placeholder="Ask anything..."
|
||||
rows="1"
|
||||
onkeydown="handleKeydown(event)"
|
||||
oninput="autoResize(this)"
|
||||
></textarea>
|
||||
<button class="add-context-btn" onclick="addContext()" title="Add context">@</button>
|
||||
</div>
|
||||
<button class="send-btn" id="send-btn" onclick="sendMessage()">→</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="${nonce}">
|
||||
const vscode = acquireVsCodeApi();
|
||||
let currentContext = null;
|
||||
|
||||
function sendMessage() {
|
||||
const input = document.getElementById('input');
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
vscode.postMessage({ command: 'send', text, context: currentContext });
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
clearContext();
|
||||
addMessageToUI('user', text);
|
||||
}
|
||||
|
||||
function sendSuggestion(text) {
|
||||
document.getElementById('input').value = text;
|
||||
sendMessage();
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function autoResize(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
||||
}
|
||||
|
||||
function addContext() {
|
||||
vscode.postMessage({ command: 'addContext' });
|
||||
}
|
||||
|
||||
function clearContext() {
|
||||
currentContext = null;
|
||||
document.getElementById('context-badge').classList.remove('active');
|
||||
}
|
||||
|
||||
function addMessageToUI(role, content) {
|
||||
const messages = document.getElementById('messages');
|
||||
const isEmpty = messages.querySelector('.empty-state');
|
||||
if (isEmpty) isEmpty.remove();
|
||||
|
||||
const isUser = role === 'user';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message ' + (isUser ? 'user-message' : 'ai-message');
|
||||
div.innerHTML = \`
|
||||
<div class="avatar \${isUser ? 'user-avatar' : 'ai-avatar'}">\${isUser ? 'U' : '✦'}</div>
|
||||
<div class="content">\${escapeHtml(content)}</div>
|
||||
\`;
|
||||
messages.appendChild(div);
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML.replace(/\\n/g, '<br>');
|
||||
}
|
||||
|
||||
window.addEventListener('message', event => {
|
||||
const message = event.data;
|
||||
|
||||
switch (message.type) {
|
||||
case 'loading':
|
||||
document.getElementById('loading').classList.toggle('active', message.loading);
|
||||
document.getElementById('send-btn').disabled = message.loading;
|
||||
break;
|
||||
|
||||
case 'response':
|
||||
if (message.message) {
|
||||
addMessageToUI('assistant', message.message);
|
||||
}
|
||||
|
||||
if (message.runs && message.runs.length > 0) {
|
||||
const messages = document.getElementById('messages');
|
||||
message.runs.forEach(run => {
|
||||
const resultDiv = document.createElement('div');
|
||||
resultDiv.className = 'tool-result';
|
||||
const statusClass = run.status === 'succeeded' ? 'success' : 'error';
|
||||
resultDiv.innerHTML = \`
|
||||
<div class="tool-result-header">
|
||||
<div class="tool-result-status \${statusClass}"></div>
|
||||
<span>\${run.tool}</span>
|
||||
</div>
|
||||
\${run.output ? \`<div class="code-block"><pre>\${JSON.stringify(run.output, null, 2)}</pre></div>\` : ''}
|
||||
\`;
|
||||
messages.appendChild(resultDiv);
|
||||
});
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
addMessageToUI('assistant', '❌ ' + message.error);
|
||||
break;
|
||||
|
||||
case 'contextAdded':
|
||||
currentContext = message.context;
|
||||
document.getElementById('context-badge').classList.add('active');
|
||||
document.getElementById('context-path').textContent = message.context.path;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('input').focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getNonce() {
|
||||
let text = "";
|
||||
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
for (let i = 0; i < 32; i++) {
|
||||
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/\n/g, "<br>");
|
||||
}
|
||||
182
platform/client-ide/extensions/gcp-productos/src/extension.ts
Normal file
182
platform/client-ide/extensions/gcp-productos/src/extension.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import * as vscode from "vscode";
|
||||
import { listTools, invokeTool, getRun, addRecentRun, checkConnection } from "./api";
|
||||
import { ToolsTreeProvider, ToolItem } from "./toolsTreeView";
|
||||
import { RunsTreeProvider, RunItem } from "./runsTreeView";
|
||||
import { createStatusBar, updateConnectionStatus, dispose as disposeStatusBar } from "./statusBar";
|
||||
import { InvokePanel } from "./invokePanel";
|
||||
import { ChatPanel } from "./chatPanel";
|
||||
import { ChatViewProvider } from "./chatViewProvider";
|
||||
import { showJson, openRun, showRunDocument } from "./ui";
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
console.log("Product OS extension activated");
|
||||
|
||||
// Create tree providers
|
||||
const toolsProvider = new ToolsTreeProvider();
|
||||
const runsProvider = new RunsTreeProvider();
|
||||
|
||||
// Register sidebar Chat view provider
|
||||
const chatViewProvider = new ChatViewProvider(context.extensionUri);
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(ChatViewProvider.viewType, chatViewProvider)
|
||||
);
|
||||
|
||||
// Register Chat panel command (opens in editor area)
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("productos.chat", () => {
|
||||
ChatPanel.createOrShow(context.extensionUri);
|
||||
})
|
||||
);
|
||||
|
||||
// Register tree views
|
||||
vscode.window.registerTreeDataProvider("productos.tools", toolsProvider);
|
||||
vscode.window.registerTreeDataProvider("productos.runs", runsProvider);
|
||||
|
||||
// Create status bar
|
||||
createStatusBar(context);
|
||||
|
||||
// Load tools on startup
|
||||
toolsProvider.loadTools();
|
||||
|
||||
// === COMMANDS ===
|
||||
|
||||
// Configure backend URL
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("productos.configure", async () => {
|
||||
const currentUrl = vscode.workspace.getConfiguration("productos").get("backendUrl");
|
||||
const backendUrl = await vscode.window.showInputBox({
|
||||
prompt: "Control Plane backend URL",
|
||||
value: currentUrl as string,
|
||||
placeHolder: "http://localhost:8080"
|
||||
});
|
||||
if (!backendUrl) return;
|
||||
|
||||
await vscode.workspace.getConfiguration("productos").update(
|
||||
"backendUrl",
|
||||
backendUrl,
|
||||
vscode.ConfigurationTarget.Global
|
||||
);
|
||||
|
||||
vscode.window.showInformationMessage(`Product OS backend set: ${backendUrl}`);
|
||||
updateConnectionStatus();
|
||||
toolsProvider.loadTools();
|
||||
})
|
||||
);
|
||||
|
||||
// Refresh tools
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("productos.refresh", async () => {
|
||||
await toolsProvider.loadTools();
|
||||
runsProvider.refresh();
|
||||
updateConnectionStatus();
|
||||
vscode.window.showInformationMessage("Product OS refreshed");
|
||||
})
|
||||
);
|
||||
|
||||
// List tools (JSON view)
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("productos.tools.list", async () => {
|
||||
try {
|
||||
const tools = await listTools();
|
||||
await showJson("Tools", tools);
|
||||
} catch (e: any) {
|
||||
vscode.window.showErrorMessage(`Failed to list tools: ${e.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Invoke tool (quick pick)
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("productos.tools.invoke", async () => {
|
||||
try {
|
||||
const tools = await listTools();
|
||||
if (tools.length === 0) {
|
||||
vscode.window.showWarningMessage("No tools available");
|
||||
return;
|
||||
}
|
||||
|
||||
const pick = await vscode.window.showQuickPick(
|
||||
tools.map(t => ({
|
||||
label: t.name,
|
||||
description: `[${t.risk}] ${t.description}`,
|
||||
tool: t
|
||||
})),
|
||||
{ placeHolder: "Select a tool to invoke" }
|
||||
);
|
||||
|
||||
if (!pick) return;
|
||||
|
||||
// Open invoke panel
|
||||
InvokePanel.createOrShow(
|
||||
context.extensionUri,
|
||||
pick.tool,
|
||||
() => runsProvider.refresh()
|
||||
);
|
||||
} catch (e: any) {
|
||||
vscode.window.showErrorMessage(`Failed: ${e.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Invoke from tree view
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("productos.tools.invokeFromTree", async (item: ToolItem) => {
|
||||
if (!item?.tool) {
|
||||
// No item passed, show quick pick
|
||||
vscode.commands.executeCommand("productos.tools.invoke");
|
||||
return;
|
||||
}
|
||||
|
||||
InvokePanel.createOrShow(
|
||||
context.extensionUri,
|
||||
item.tool,
|
||||
() => runsProvider.refresh()
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Open run by ID
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("productos.runs.open", async () => {
|
||||
const runId = await vscode.window.showInputBox({
|
||||
prompt: "Enter Run ID",
|
||||
placeHolder: "run_20240101..."
|
||||
});
|
||||
if (!runId) return;
|
||||
|
||||
try {
|
||||
await openRun(runId);
|
||||
} catch (e: any) {
|
||||
vscode.window.showErrorMessage(`Failed to open run: ${e.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Open run from tree view
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("productos.runs.openFromTree", async (item: RunItem) => {
|
||||
if (!item?.run) return;
|
||||
|
||||
try {
|
||||
const fullRun = await getRun(item.run.run_id);
|
||||
await showRunDocument(fullRun);
|
||||
} catch (e: any) {
|
||||
vscode.window.showErrorMessage(`Failed to open run: ${e.message}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Watch for config changes
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidChangeConfiguration(e => {
|
||||
if (e.affectsConfiguration("productos")) {
|
||||
updateConnectionStatus();
|
||||
toolsProvider.loadTools();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
disposeStatusBar();
|
||||
}
|
||||
373
platform/client-ide/extensions/gcp-productos/src/invokePanel.ts
Normal file
373
platform/client-ide/extensions/gcp-productos/src/invokePanel.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import * as vscode from "vscode";
|
||||
import { Tool, invokeTool, getRun, addRecentRun } from "./api";
|
||||
|
||||
export class InvokePanel {
|
||||
public static currentPanel: InvokePanel | undefined;
|
||||
private readonly _panel: vscode.WebviewPanel;
|
||||
private readonly _extensionUri: vscode.Uri;
|
||||
private _tool: Tool;
|
||||
private _disposables: vscode.Disposable[] = [];
|
||||
private _onRunComplete: () => void;
|
||||
|
||||
public static createOrShow(
|
||||
extensionUri: vscode.Uri,
|
||||
tool: Tool,
|
||||
onRunComplete: () => void
|
||||
) {
|
||||
const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One;
|
||||
|
||||
if (InvokePanel.currentPanel) {
|
||||
InvokePanel.currentPanel._tool = tool;
|
||||
InvokePanel.currentPanel._onRunComplete = onRunComplete;
|
||||
InvokePanel.currentPanel._update();
|
||||
InvokePanel.currentPanel._panel.reveal(column);
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
"productosInvoke",
|
||||
`Invoke: ${tool.name}`,
|
||||
column,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true
|
||||
}
|
||||
);
|
||||
|
||||
InvokePanel.currentPanel = new InvokePanel(panel, extensionUri, tool, onRunComplete);
|
||||
}
|
||||
|
||||
private constructor(
|
||||
panel: vscode.WebviewPanel,
|
||||
extensionUri: vscode.Uri,
|
||||
tool: Tool,
|
||||
onRunComplete: () => void
|
||||
) {
|
||||
this._panel = panel;
|
||||
this._extensionUri = extensionUri;
|
||||
this._tool = tool;
|
||||
this._onRunComplete = onRunComplete;
|
||||
|
||||
this._update();
|
||||
|
||||
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
|
||||
|
||||
this._panel.webview.onDidReceiveMessage(
|
||||
async (message) => {
|
||||
switch (message.command) {
|
||||
case "invoke":
|
||||
await this._handleInvoke(message.input, message.dryRun);
|
||||
break;
|
||||
case "close":
|
||||
this._panel.dispose();
|
||||
break;
|
||||
}
|
||||
},
|
||||
null,
|
||||
this._disposables
|
||||
);
|
||||
}
|
||||
|
||||
private async _handleInvoke(inputText: string, dryRun: boolean) {
|
||||
try {
|
||||
const input = JSON.parse(inputText);
|
||||
|
||||
this._panel.webview.postMessage({ command: "invoking" });
|
||||
|
||||
const result = await invokeTool(this._tool.name, input, dryRun);
|
||||
|
||||
// Fetch full run details
|
||||
const fullRun = await getRun(result.run_id);
|
||||
addRecentRun(fullRun);
|
||||
this._onRunComplete();
|
||||
|
||||
this._panel.webview.postMessage({
|
||||
command: "result",
|
||||
run: fullRun
|
||||
});
|
||||
|
||||
} catch (e: any) {
|
||||
this._panel.webview.postMessage({
|
||||
command: "error",
|
||||
message: e.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _update() {
|
||||
this._panel.title = `Invoke: ${this._tool.name}`;
|
||||
this._panel.webview.html = this._getHtml();
|
||||
}
|
||||
|
||||
private _getHtml(): string {
|
||||
const tool = this._tool;
|
||||
const schemaStr = JSON.stringify(tool.inputSchema, null, 2);
|
||||
const defaultInput = this._generateDefaultInput(tool.inputSchema);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Invoke ${tool.name}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: var(--vscode-font-family);
|
||||
padding: 20px;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
margin: 0 0 5px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.risk {
|
||||
font-size: 0.7em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.risk-low { background: #2ea043; color: white; }
|
||||
.risk-medium { background: #d29922; color: black; }
|
||||
.risk-high { background: #f85149; color: white; }
|
||||
.description {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
padding: 10px;
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
font-size: var(--vscode-editor-font-size);
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 4px;
|
||||
resize: vertical;
|
||||
}
|
||||
textarea:focus {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
}
|
||||
.buttons {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
.btn-secondary {
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
}
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: normal;
|
||||
margin-left: auto;
|
||||
}
|
||||
.schema {
|
||||
margin-top: 20px;
|
||||
padding: 10px;
|
||||
background: var(--vscode-textBlockQuote-background);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.schema summary {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
.schema pre {
|
||||
margin: 10px 0 0 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.result {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.result-success {
|
||||
background: var(--vscode-inputValidation-infoBackground);
|
||||
border: 1px solid var(--vscode-inputValidation-infoBorder);
|
||||
}
|
||||
.result-error {
|
||||
background: var(--vscode-inputValidation-errorBackground);
|
||||
border: 1px solid var(--vscode-inputValidation-errorBorder);
|
||||
}
|
||||
.result h3 { margin: 0 0 10px 0; }
|
||||
.result pre {
|
||||
background: var(--vscode-editor-background);
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid var(--vscode-foreground);
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-right: 8px;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.hidden { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>
|
||||
${tool.name}
|
||||
<span class="risk risk-${tool.risk}">${tool.risk} risk</span>
|
||||
</h1>
|
||||
<p class="description">${tool.description}</p>
|
||||
|
||||
<label for="input">Input JSON</label>
|
||||
<textarea id="input">${defaultInput}</textarea>
|
||||
|
||||
<div class="buttons">
|
||||
<button class="btn-primary" id="invokeBtn" onclick="invoke(false)">
|
||||
<span id="invokeText">▶ Invoke</span>
|
||||
<span id="invokingText" class="hidden"><span class="spinner"></span>Invoking...</span>
|
||||
</button>
|
||||
<button class="btn-secondary" onclick="invoke(true)">🧪 Dry Run</button>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="autoFormat" checked>
|
||||
Auto-format JSON
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="result"></div>
|
||||
|
||||
<details class="schema">
|
||||
<summary>Input Schema</summary>
|
||||
<pre>${schemaStr}</pre>
|
||||
</details>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const inputEl = document.getElementById('input');
|
||||
const invokeBtn = document.getElementById('invokeBtn');
|
||||
const invokeText = document.getElementById('invokeText');
|
||||
const invokingText = document.getElementById('invokingText');
|
||||
const resultEl = document.getElementById('result');
|
||||
|
||||
inputEl.addEventListener('blur', () => {
|
||||
if (document.getElementById('autoFormat').checked) {
|
||||
try {
|
||||
const parsed = JSON.parse(inputEl.value);
|
||||
inputEl.value = JSON.stringify(parsed, null, 2);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
|
||||
function invoke(dryRun) {
|
||||
vscode.postMessage({
|
||||
command: 'invoke',
|
||||
input: inputEl.value,
|
||||
dryRun
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('message', event => {
|
||||
const message = event.data;
|
||||
switch (message.command) {
|
||||
case 'invoking':
|
||||
invokeBtn.disabled = true;
|
||||
invokeText.classList.add('hidden');
|
||||
invokingText.classList.remove('hidden');
|
||||
resultEl.innerHTML = '';
|
||||
break;
|
||||
case 'result':
|
||||
invokeBtn.disabled = false;
|
||||
invokeText.classList.remove('hidden');
|
||||
invokingText.classList.add('hidden');
|
||||
const run = message.run;
|
||||
const statusEmoji = run.status === 'succeeded' ? '✅' : run.status === 'failed' ? '❌' : '🔄';
|
||||
resultEl.innerHTML = \`
|
||||
<div class="result result-success">
|
||||
<h3>\${statusEmoji} Run \${run.status}</h3>
|
||||
<p><strong>Run ID:</strong> \${run.run_id}</p>
|
||||
<h4>Output:</h4>
|
||||
<pre>\${JSON.stringify(run.output || run.error || {}, null, 2)}</pre>
|
||||
</div>
|
||||
\`;
|
||||
break;
|
||||
case 'error':
|
||||
invokeBtn.disabled = false;
|
||||
invokeText.classList.remove('hidden');
|
||||
invokingText.classList.add('hidden');
|
||||
resultEl.innerHTML = \`
|
||||
<div class="result result-error">
|
||||
<h3>❌ Error</h3>
|
||||
<pre>\${message.message}</pre>
|
||||
</div>
|
||||
\`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private _generateDefaultInput(schema: any): string {
|
||||
if (!schema || schema.type !== "object") return "{}";
|
||||
|
||||
const obj: any = {};
|
||||
const props = schema.properties || {};
|
||||
const required = schema.required || [];
|
||||
|
||||
for (const key of required) {
|
||||
const prop = props[key];
|
||||
if (!prop) continue;
|
||||
|
||||
if (prop.type === "string") {
|
||||
obj[key] = prop.enum ? prop.enum[0] : "";
|
||||
} else if (prop.type === "integer" || prop.type === "number") {
|
||||
obj[key] = prop.minimum ?? 0;
|
||||
} else if (prop.type === "boolean") {
|
||||
obj[key] = false;
|
||||
} else if (prop.type === "array") {
|
||||
obj[key] = [];
|
||||
} else if (prop.type === "object") {
|
||||
obj[key] = {};
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
InvokePanel.currentPanel = undefined;
|
||||
this._panel.dispose();
|
||||
while (this._disposables.length) {
|
||||
const d = this._disposables.pop();
|
||||
if (d) d.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as vscode from "vscode";
|
||||
import { Run, getRecentRuns } from "./api";
|
||||
|
||||
export class RunsTreeProvider implements vscode.TreeDataProvider<RunItem> {
|
||||
private _onDidChangeTreeData = new vscode.EventEmitter<RunItem | undefined>();
|
||||
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
|
||||
|
||||
refresh(): void {
|
||||
this._onDidChangeTreeData.fire(undefined);
|
||||
}
|
||||
|
||||
getTreeItem(element: RunItem): vscode.TreeItem {
|
||||
return element;
|
||||
}
|
||||
|
||||
getChildren(element?: RunItem): RunItem[] {
|
||||
if (element) return [];
|
||||
return getRecentRuns().map(run => new RunItem(run));
|
||||
}
|
||||
}
|
||||
|
||||
export class RunItem extends vscode.TreeItem {
|
||||
constructor(public readonly run: Run) {
|
||||
super(run.tool, vscode.TreeItemCollapsibleState.None);
|
||||
|
||||
const statusIcon = run.status === "succeeded" ? "✅" :
|
||||
run.status === "failed" ? "❌" :
|
||||
run.status === "running" ? "🔄" : "⏳";
|
||||
|
||||
this.description = `${statusIcon} ${run.status}`;
|
||||
|
||||
const time = new Date(run.created_at).toLocaleTimeString();
|
||||
this.tooltip = new vscode.MarkdownString(
|
||||
`**${run.tool}**\n\n` +
|
||||
`Status: ${run.status}\n\n` +
|
||||
`Run ID: \`${run.run_id}\`\n\n` +
|
||||
`Time: ${time}`
|
||||
);
|
||||
|
||||
this.contextValue = "run";
|
||||
|
||||
// Icon based on status
|
||||
const iconId = run.status === "succeeded" ? "pass" :
|
||||
run.status === "failed" ? "error" :
|
||||
run.status === "running" ? "sync~spin" : "clock";
|
||||
this.iconPath = new vscode.ThemeIcon(iconId);
|
||||
|
||||
this.command = {
|
||||
command: "productos.runs.openFromTree",
|
||||
title: "View Run",
|
||||
arguments: [this]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as vscode from "vscode";
|
||||
import { checkConnection, getBackendUrl } from "./api";
|
||||
|
||||
let statusBarItem: vscode.StatusBarItem;
|
||||
let checkInterval: NodeJS.Timeout | undefined;
|
||||
|
||||
export function createStatusBar(context: vscode.ExtensionContext): vscode.StatusBarItem {
|
||||
statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
|
||||
statusBarItem.command = "productos.configure";
|
||||
statusBarItem.text = "$(cloud) Product OS";
|
||||
statusBarItem.tooltip = "Click to configure Product OS";
|
||||
statusBarItem.show();
|
||||
|
||||
context.subscriptions.push(statusBarItem);
|
||||
|
||||
// Check connection periodically
|
||||
updateConnectionStatus();
|
||||
checkInterval = setInterval(updateConnectionStatus, 30000);
|
||||
|
||||
return statusBarItem;
|
||||
}
|
||||
|
||||
export async function updateConnectionStatus(): Promise<void> {
|
||||
const connected = await checkConnection();
|
||||
|
||||
if (connected) {
|
||||
statusBarItem.text = "$(cloud) Product OS";
|
||||
statusBarItem.backgroundColor = undefined;
|
||||
statusBarItem.tooltip = `Connected to ${getBackendUrl()}`;
|
||||
} else {
|
||||
statusBarItem.text = "$(cloud-offline) Product OS";
|
||||
statusBarItem.backgroundColor = new vscode.ThemeColor("statusBarItem.errorBackground");
|
||||
statusBarItem.tooltip = `Disconnected - Click to configure`;
|
||||
}
|
||||
}
|
||||
|
||||
export function dispose(): void {
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import * as vscode from "vscode";
|
||||
import { Tool, listTools } from "./api";
|
||||
|
||||
export class ToolsTreeProvider implements vscode.TreeDataProvider<ToolItem> {
|
||||
private _onDidChangeTreeData = new vscode.EventEmitter<ToolItem | undefined>();
|
||||
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
|
||||
|
||||
private tools: Tool[] = [];
|
||||
|
||||
refresh(): void {
|
||||
this._onDidChangeTreeData.fire(undefined);
|
||||
}
|
||||
|
||||
async loadTools(): Promise<void> {
|
||||
try {
|
||||
this.tools = await listTools();
|
||||
this.refresh();
|
||||
} catch (e) {
|
||||
this.tools = [];
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
getTreeItem(element: ToolItem): vscode.TreeItem {
|
||||
return element;
|
||||
}
|
||||
|
||||
async getChildren(element?: ToolItem): Promise<ToolItem[]> {
|
||||
if (element) return [];
|
||||
|
||||
if (this.tools.length === 0) {
|
||||
await this.loadTools();
|
||||
}
|
||||
|
||||
return this.tools.map(tool => new ToolItem(tool));
|
||||
}
|
||||
}
|
||||
|
||||
export class ToolItem extends vscode.TreeItem {
|
||||
constructor(public readonly tool: Tool) {
|
||||
super(tool.name, vscode.TreeItemCollapsibleState.None);
|
||||
|
||||
this.description = tool.description;
|
||||
this.tooltip = new vscode.MarkdownString(
|
||||
`**${tool.name}**\n\n${tool.description}\n\n` +
|
||||
`Risk: \`${tool.risk}\`\n\n` +
|
||||
`Executor: \`${tool.executor.url}${tool.executor.path}\``
|
||||
);
|
||||
|
||||
this.contextValue = "tool";
|
||||
|
||||
// Icon based on risk level
|
||||
const iconColor = tool.risk === "high" ? "red" : tool.risk === "medium" ? "orange" : "green";
|
||||
this.iconPath = new vscode.ThemeIcon("symbol-method", new vscode.ThemeColor(`charts.${iconColor}`));
|
||||
|
||||
// Click to invoke
|
||||
this.command = {
|
||||
command: "productos.tools.invokeFromTree",
|
||||
title: "Invoke Tool",
|
||||
arguments: [this]
|
||||
};
|
||||
}
|
||||
}
|
||||
40
platform/client-ide/extensions/gcp-productos/src/ui.ts
Normal file
40
platform/client-ide/extensions/gcp-productos/src/ui.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as vscode from "vscode";
|
||||
import { getRun, Run } from "./api";
|
||||
|
||||
export async function showJson(title: string, obj: any) {
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
content: JSON.stringify(obj, null, 2),
|
||||
language: "json"
|
||||
});
|
||||
await vscode.window.showTextDocument(doc, { preview: false });
|
||||
vscode.window.setStatusBarMessage(title, 3000);
|
||||
}
|
||||
|
||||
export async function openRun(runId: string) {
|
||||
const run = await getRun(runId);
|
||||
await showRunDocument(run);
|
||||
}
|
||||
|
||||
export async function showRunDocument(run: Run) {
|
||||
const statusEmoji = run.status === "succeeded" ? "✅" :
|
||||
run.status === "failed" ? "❌" :
|
||||
run.status === "running" ? "🔄" : "⏳";
|
||||
|
||||
const content = `// Run: ${run.run_id}
|
||||
// Tool: ${run.tool}
|
||||
// Status: ${statusEmoji} ${run.status}
|
||||
// Created: ${new Date(run.created_at).toLocaleString()}
|
||||
|
||||
// === INPUT ===
|
||||
${JSON.stringify(run.input, null, 2)}
|
||||
|
||||
// === OUTPUT ===
|
||||
${JSON.stringify(run.output ?? run.error ?? null, null, 2)}
|
||||
`;
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
content,
|
||||
language: "jsonc"
|
||||
});
|
||||
await vscode.window.showTextDocument(doc, { preview: false });
|
||||
}
|
||||
11
platform/client-ide/extensions/gcp-productos/tsconfig.json
Normal file
11
platform/client-ide/extensions/gcp-productos/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
398
platform/contracts/tool-registry.yaml
Normal file
398
platform/contracts/tool-registry.yaml
Normal file
@@ -0,0 +1,398 @@
|
||||
version: 1
|
||||
|
||||
tools:
|
||||
|
||||
# ----------------------------
|
||||
# CODE / DEPLOYMENT
|
||||
# ----------------------------
|
||||
|
||||
cloudrun.deploy_service:
|
||||
description: Build and deploy a Cloud Run service using Cloud Build. Returns the service URL and deployed revision.
|
||||
risk: medium
|
||||
executor:
|
||||
kind: http
|
||||
url: https://deploy-executor-REPLACE.a.run.app
|
||||
path: /execute/cloudrun/deploy
|
||||
inputSchema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [service_name, repo, ref, env, region]
|
||||
properties:
|
||||
service_name:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Cloud Run service name.
|
||||
repo:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Git repo URL (HTTPS).
|
||||
ref:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Git ref (branch/tag/SHA).
|
||||
env:
|
||||
type: string
|
||||
enum: [dev, staging, prod]
|
||||
region:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: GCP region for the Cloud Run service (e.g., us-central1).
|
||||
outputSchema:
|
||||
type: object
|
||||
properties:
|
||||
service_url:
|
||||
type: string
|
||||
revision:
|
||||
type: string
|
||||
|
||||
cloudrun.get_service_status:
|
||||
description: Fetch Cloud Run service status including latest revision and URL.
|
||||
risk: low
|
||||
executor:
|
||||
kind: http
|
||||
url: https://deploy-executor-REPLACE.a.run.app
|
||||
path: /execute/cloudrun/status
|
||||
inputSchema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [service_name, region]
|
||||
properties:
|
||||
service_name:
|
||||
type: string
|
||||
minLength: 1
|
||||
region:
|
||||
type: string
|
||||
minLength: 1
|
||||
outputSchema:
|
||||
type: object
|
||||
properties:
|
||||
service_name:
|
||||
type: string
|
||||
region:
|
||||
type: string
|
||||
service_url:
|
||||
type: string
|
||||
latest_ready_revision:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [ready, deploying, error, unknown]
|
||||
|
||||
logs.tail:
|
||||
description: Tail recent logs for a Cloud Run service or for a specific run_id.
|
||||
risk: low
|
||||
executor:
|
||||
kind: http
|
||||
url: https://observability-executor-REPLACE.a.run.app
|
||||
path: /execute/logs/tail
|
||||
inputSchema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [scope, limit]
|
||||
properties:
|
||||
scope:
|
||||
type: string
|
||||
enum: [service, run]
|
||||
service_name:
|
||||
type: string
|
||||
region:
|
||||
type: string
|
||||
run_id:
|
||||
type: string
|
||||
limit:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 2000
|
||||
default: 200
|
||||
outputSchema:
|
||||
type: object
|
||||
properties:
|
||||
lines:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
timestamp:
|
||||
type: string
|
||||
severity:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
|
||||
# ----------------------------
|
||||
# COMPANY BRAIN (BRAND + STYLE)
|
||||
# ----------------------------
|
||||
|
||||
brand.get_profile:
|
||||
description: Retrieve the tenant's brand profile (voice, tone, positioning, compliance constraints).
|
||||
risk: low
|
||||
executor:
|
||||
kind: http
|
||||
url: https://firestore-executor-REPLACE.a.run.app
|
||||
path: /execute/brand/get_profile
|
||||
inputSchema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [profile_id]
|
||||
properties:
|
||||
profile_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Brand profile identifier (e.g., "default").
|
||||
outputSchema:
|
||||
type: object
|
||||
properties:
|
||||
profile_id:
|
||||
type: string
|
||||
brand:
|
||||
type: object
|
||||
|
||||
brand.update_profile:
|
||||
description: Update the tenant's brand profile. Write operations should be validated and audited.
|
||||
risk: medium
|
||||
executor:
|
||||
kind: http
|
||||
url: https://firestore-executor-REPLACE.a.run.app
|
||||
path: /execute/brand/update_profile
|
||||
inputSchema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [profile_id, patch]
|
||||
properties:
|
||||
profile_id:
|
||||
type: string
|
||||
minLength: 1
|
||||
patch:
|
||||
type: object
|
||||
description: Partial update object; executor must validate allowed fields.
|
||||
outputSchema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
updated_at:
|
||||
type: string
|
||||
|
||||
# ----------------------------
|
||||
# ANALYTICS / CAUSATION
|
||||
# ----------------------------
|
||||
|
||||
analytics.funnel_summary:
|
||||
description: Return funnel metrics for a time window. Uses curated events in BigQuery.
|
||||
risk: low
|
||||
executor:
|
||||
kind: http
|
||||
url: https://analytics-executor-REPLACE.a.run.app
|
||||
path: /execute/analytics/funnel_summary
|
||||
inputSchema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [range_days, funnel]
|
||||
properties:
|
||||
range_days:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 365
|
||||
funnel:
|
||||
type: object
|
||||
required: [name, steps]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
steps:
|
||||
type: array
|
||||
minItems: 2
|
||||
items:
|
||||
type: object
|
||||
required: [event_name]
|
||||
properties:
|
||||
event_name:
|
||||
type: string
|
||||
outputSchema:
|
||||
type: object
|
||||
properties:
|
||||
funnel_name:
|
||||
type: string
|
||||
range_days:
|
||||
type: integer
|
||||
steps:
|
||||
type: array
|
||||
|
||||
analytics.top_drivers:
|
||||
description: Identify top correlated drivers for a target metric/event.
|
||||
risk: low
|
||||
executor:
|
||||
kind: http
|
||||
url: https://analytics-executor-REPLACE.a.run.app
|
||||
path: /execute/analytics/top_drivers
|
||||
inputSchema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [range_days, target]
|
||||
properties:
|
||||
range_days:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 365
|
||||
target:
|
||||
type: object
|
||||
required: [metric]
|
||||
properties:
|
||||
metric:
|
||||
type: string
|
||||
event_name:
|
||||
type: string
|
||||
outputSchema:
|
||||
type: object
|
||||
properties:
|
||||
target:
|
||||
type: object
|
||||
range_days:
|
||||
type: integer
|
||||
drivers:
|
||||
type: array
|
||||
|
||||
analytics.write_insight:
|
||||
description: Persist an insight object (BigQuery table + Firestore pointer + GCS artifact).
|
||||
risk: medium
|
||||
executor:
|
||||
kind: http
|
||||
url: https://analytics-executor-REPLACE.a.run.app
|
||||
path: /execute/analytics/write_insight
|
||||
inputSchema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [insight]
|
||||
properties:
|
||||
insight:
|
||||
type: object
|
||||
required: [type, title, summary, severity, confidence, window, recommendations]
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
enum: [funnel_drop, anomaly, driver, experiment_result, general]
|
||||
title:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
severity:
|
||||
type: string
|
||||
enum: [info, low, medium, high, critical]
|
||||
confidence:
|
||||
type: number
|
||||
minimum: 0
|
||||
maximum: 1
|
||||
window:
|
||||
type: object
|
||||
recommendations:
|
||||
type: array
|
||||
outputSchema:
|
||||
type: object
|
||||
properties:
|
||||
insight_id:
|
||||
type: string
|
||||
stored:
|
||||
type: object
|
||||
|
||||
# ----------------------------
|
||||
# MARKETING (GENERATION + PUBLISH)
|
||||
# ----------------------------
|
||||
|
||||
marketing.generate_channel_posts:
|
||||
description: Generate platform-specific social posts from a campaign brief + brand profile.
|
||||
risk: low
|
||||
executor:
|
||||
kind: http
|
||||
url: https://marketing-executor-REPLACE.a.run.app
|
||||
path: /execute/marketing/generate_channel_posts
|
||||
inputSchema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [brief, channels, brand_profile_id]
|
||||
properties:
|
||||
brand_profile_id:
|
||||
type: string
|
||||
brief:
|
||||
type: object
|
||||
required: [goal, product, audience, key_points]
|
||||
properties:
|
||||
goal:
|
||||
type: string
|
||||
product:
|
||||
type: string
|
||||
audience:
|
||||
type: string
|
||||
key_points:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
channels:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
enum: [x, linkedin, facebook, instagram, tiktok, youtube, pinterest, reddit]
|
||||
variations_per_channel:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 10
|
||||
default: 3
|
||||
outputSchema:
|
||||
type: object
|
||||
properties:
|
||||
channels:
|
||||
type: array
|
||||
|
||||
marketing.publish_missinglettr:
|
||||
description: Publish or schedule a campaign via Missinglettr.
|
||||
risk: medium
|
||||
executor:
|
||||
kind: http
|
||||
url: https://marketing-executor-REPLACE.a.run.app
|
||||
path: /execute/marketing/publish_missinglettr
|
||||
inputSchema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [campaign, schedule]
|
||||
properties:
|
||||
campaign:
|
||||
type: object
|
||||
required: [name, posts]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
posts:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [channel, text]
|
||||
properties:
|
||||
channel:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
media_urls:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
schedule:
|
||||
type: object
|
||||
required: [mode]
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
enum: [now, scheduled]
|
||||
start_time:
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
default: UTC
|
||||
outputSchema:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
campaign_id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [queued, scheduled, published, failed]
|
||||
41
platform/docker-compose.yml
Normal file
41
platform/docker-compose.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Firestore Emulator
|
||||
firestore:
|
||||
image: gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators
|
||||
command: gcloud emulators firestore start --host-port=0.0.0.0:8081
|
||||
ports:
|
||||
- "8081:8081"
|
||||
|
||||
# GCS Emulator (fake-gcs-server)
|
||||
gcs:
|
||||
image: fsouza/fake-gcs-server
|
||||
command: -scheme http -port 4443
|
||||
ports:
|
||||
- "4443:4443"
|
||||
volumes:
|
||||
- gcs-data:/data
|
||||
|
||||
# Control Plane API
|
||||
control-plane:
|
||||
build:
|
||||
context: ./backend/control-plane
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- PORT=8080
|
||||
- GCP_PROJECT_ID=productos-local
|
||||
- GCS_BUCKET_ARTIFACTS=productos-artifacts-local
|
||||
- FIRESTORE_COLLECTION_RUNS=runs
|
||||
- FIRESTORE_COLLECTION_TOOLS=tools
|
||||
- AUTH_MODE=dev
|
||||
- FIRESTORE_EMULATOR_HOST=firestore:8081
|
||||
- STORAGE_EMULATOR_HOST=http://gcs:4443
|
||||
depends_on:
|
||||
- firestore
|
||||
- gcs
|
||||
|
||||
volumes:
|
||||
gcs-data:
|
||||
143
platform/docs/GETTING_STARTED.md
Normal file
143
platform/docs/GETTING_STARTED.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Product OS - Getting Started
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
platform/
|
||||
├── backend/
|
||||
│ └── control-plane/ # Fastify API server
|
||||
├── client-ide/
|
||||
│ └── extensions/
|
||||
│ └── gcp-productos/ # VSCodium/VS Code extension
|
||||
├── contracts/ # Tool registry schemas
|
||||
├── infra/
|
||||
│ └── terraform/ # GCP infrastructure
|
||||
├── docs/ # Documentation
|
||||
└── docker-compose.yml # Local development
|
||||
```
|
||||
|
||||
## Quick Start (Local Development)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 22+
|
||||
- Docker & Docker Compose
|
||||
- (Optional) VS Code or VSCodium for extension development
|
||||
|
||||
### 1. Start Local Services
|
||||
|
||||
```bash
|
||||
cd platform
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
This starts:
|
||||
- Firestore emulator on port 8081
|
||||
- GCS emulator on port 4443
|
||||
- Control Plane API on port 8080
|
||||
|
||||
### 2. Run Control Plane in Dev Mode
|
||||
|
||||
For faster iteration without Docker:
|
||||
|
||||
```bash
|
||||
cd platform/backend/control-plane
|
||||
cp env.example .env
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 3. Test the API
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8080/healthz
|
||||
|
||||
# List tools (empty initially)
|
||||
curl http://localhost:8080/tools
|
||||
|
||||
# Invoke a tool (dry run)
|
||||
curl -X POST http://localhost:8080/tools/invoke \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"tool": "cloudrun.deploy_service",
|
||||
"tenant_id": "t_dev",
|
||||
"input": {"service_name": "test"},
|
||||
"dry_run": true
|
||||
}'
|
||||
```
|
||||
|
||||
### 4. Build & Install the Extension
|
||||
|
||||
```bash
|
||||
cd platform/client-ide/extensions/gcp-productos
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
Then in VS Code / VSCodium:
|
||||
1. Open Command Palette (Cmd+Shift+P)
|
||||
2. Run "Developer: Install Extension from Location..."
|
||||
3. Select the `gcp-productos` folder
|
||||
|
||||
Or use the VSIX package:
|
||||
```bash
|
||||
npx vsce package
|
||||
code --install-extension gcp-productos-0.0.1.vsix
|
||||
```
|
||||
|
||||
## Extension Usage
|
||||
|
||||
Once installed, use the Command Palette:
|
||||
|
||||
- **Product OS: Configure Backend** - Set the Control Plane URL
|
||||
- **Product OS: List Tools** - View available tools
|
||||
- **Product OS: Invoke Tool** - Execute a tool
|
||||
- **Product OS: Open Run** - View run details
|
||||
|
||||
## Deploying to GCP
|
||||
|
||||
### 1. Configure Terraform
|
||||
|
||||
```bash
|
||||
cd platform/infra/terraform
|
||||
cp terraform.tfvars.example terraform.tfvars
|
||||
# Edit terraform.tfvars with your project details
|
||||
```
|
||||
|
||||
### 2. Build & Push Container
|
||||
|
||||
```bash
|
||||
cd platform/backend/control-plane
|
||||
|
||||
# Build
|
||||
docker build -t us-central1-docker.pkg.dev/YOUR_PROJECT/productos/control-plane:latest .
|
||||
|
||||
# Push (requires gcloud auth)
|
||||
docker push us-central1-docker.pkg.dev/YOUR_PROJECT/productos/control-plane:latest
|
||||
```
|
||||
|
||||
### 3. Apply Terraform
|
||||
|
||||
```bash
|
||||
cd platform/infra/terraform
|
||||
terraform init
|
||||
terraform plan
|
||||
terraform apply
|
||||
```
|
||||
|
||||
## Seeding Tools
|
||||
|
||||
To add tools to the registry, you can:
|
||||
|
||||
1. Use the Firestore console to add documents to the `tools` collection
|
||||
2. Create a seed script that loads `contracts/tool-registry.yaml`
|
||||
3. Build an admin endpoint (coming in v2)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Build Deploy Executor
|
||||
- [ ] Build Analytics Executor
|
||||
- [ ] Add Gemini integration
|
||||
- [ ] Add OAuth/IAP authentication
|
||||
- [ ] Create Product-Centric UI panels
|
||||
16
platform/infra/terraform/iam.tf
Normal file
16
platform/infra/terraform/iam.tf
Normal file
@@ -0,0 +1,16 @@
|
||||
# Allow control-plane to write artifacts in GCS
|
||||
resource "google_storage_bucket_iam_member" "control_plane_bucket_writer" {
|
||||
bucket = google_storage_bucket.artifacts.name
|
||||
role = "roles/storage.objectAdmin"
|
||||
member = "serviceAccount:${google_service_account.control_plane_sa.email}"
|
||||
}
|
||||
|
||||
# Firestore access for run/tool metadata
|
||||
resource "google_project_iam_member" "control_plane_firestore" {
|
||||
project = var.project_id
|
||||
role = "roles/datastore.user"
|
||||
member = "serviceAccount:${google_service_account.control_plane_sa.email}"
|
||||
}
|
||||
|
||||
# Placeholder: executor services will each have their own service accounts.
|
||||
# Control-plane should be granted roles/run.invoker on each executor service once created.
|
||||
54
platform/infra/terraform/main.tf
Normal file
54
platform/infra/terraform/main.tf
Normal file
@@ -0,0 +1,54 @@
|
||||
# GCS Bucket for artifacts (logs, AI outputs, patches)
|
||||
resource "google_storage_bucket" "artifacts" {
|
||||
name = var.artifact_bucket_name
|
||||
location = var.region
|
||||
uniform_bucket_level_access = true
|
||||
versioning { enabled = true }
|
||||
}
|
||||
|
||||
# Firestore (Native mode) – requires enabling in console once per project
|
||||
resource "google_firestore_database" "default" {
|
||||
name = "(default)"
|
||||
location_id = var.region
|
||||
type = "FIRESTORE_NATIVE"
|
||||
}
|
||||
|
||||
# Service account for Control Plane
|
||||
resource "google_service_account" "control_plane_sa" {
|
||||
account_id = "sa-control-plane"
|
||||
display_name = "Product OS Control Plane"
|
||||
}
|
||||
|
||||
# Cloud Run service for Control Plane API
|
||||
resource "google_cloud_run_v2_service" "control_plane" {
|
||||
name = "control-plane"
|
||||
location = var.region
|
||||
|
||||
template {
|
||||
service_account = google_service_account.control_plane_sa.email
|
||||
|
||||
containers {
|
||||
image = var.control_plane_image
|
||||
env {
|
||||
name = "GCP_PROJECT_ID"
|
||||
value = var.project_id
|
||||
}
|
||||
env {
|
||||
name = "GCS_BUCKET_ARTIFACTS"
|
||||
value = google_storage_bucket.artifacts.name
|
||||
}
|
||||
env {
|
||||
name = "AUTH_MODE"
|
||||
value = "dev"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Public access for dev; prefer IAM auth in production
|
||||
resource "google_cloud_run_v2_service_iam_member" "control_plane_public" {
|
||||
name = google_cloud_run_v2_service.control_plane.name
|
||||
location = var.region
|
||||
role = "roles/run.invoker"
|
||||
member = "allUsers"
|
||||
}
|
||||
9
platform/infra/terraform/outputs.tf
Normal file
9
platform/infra/terraform/outputs.tf
Normal file
@@ -0,0 +1,9 @@
|
||||
output "control_plane_url" {
|
||||
value = google_cloud_run_v2_service.control_plane.uri
|
||||
description = "URL of the Control Plane API"
|
||||
}
|
||||
|
||||
output "artifact_bucket" {
|
||||
value = google_storage_bucket.artifacts.name
|
||||
description = "GCS bucket for artifacts"
|
||||
}
|
||||
14
platform/infra/terraform/providers.tf
Normal file
14
platform/infra/terraform/providers.tf
Normal file
@@ -0,0 +1,14 @@
|
||||
terraform {
|
||||
required_version = ">= 1.5.0"
|
||||
required_providers {
|
||||
google = {
|
||||
source = "hashicorp/google"
|
||||
version = "~> 5.30"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "google" {
|
||||
project = var.project_id
|
||||
region = var.region
|
||||
}
|
||||
4
platform/infra/terraform/terraform.tfvars.example
Normal file
4
platform/infra/terraform/terraform.tfvars.example
Normal file
@@ -0,0 +1,4 @@
|
||||
project_id = "your-gcp-project-id"
|
||||
region = "us-central1"
|
||||
artifact_bucket_name = "productos-artifacts-dev"
|
||||
control_plane_image = "us-central1-docker.pkg.dev/YOUR_PROJECT/productos/control-plane:latest"
|
||||
20
platform/infra/terraform/variables.tf
Normal file
20
platform/infra/terraform/variables.tf
Normal file
@@ -0,0 +1,20 @@
|
||||
variable "project_id" {
|
||||
type = string
|
||||
description = "GCP Project ID"
|
||||
}
|
||||
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-central1"
|
||||
description = "GCP region for resources"
|
||||
}
|
||||
|
||||
variable "artifact_bucket_name" {
|
||||
type = string
|
||||
description = "Name for the GCS bucket storing artifacts"
|
||||
}
|
||||
|
||||
variable "control_plane_image" {
|
||||
type = string
|
||||
description = "Container image URI for control-plane (Artifact Registry)."
|
||||
}
|
||||
54
platform/scripts/start-all.sh
Normal file
54
platform/scripts/start-all.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Start all Product OS services for local development
|
||||
|
||||
echo "🚀 Starting Product OS services..."
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Start Control Plane
|
||||
echo "Starting Control Plane (port 8080)..."
|
||||
cd backend/control-plane
|
||||
npm run dev &
|
||||
CONTROL_PLANE_PID=$!
|
||||
cd ../..
|
||||
|
||||
sleep 2
|
||||
|
||||
# Start Deploy Executor
|
||||
echo "Starting Deploy Executor (port 8090)..."
|
||||
cd backend/executors/deploy
|
||||
npm run dev &
|
||||
DEPLOY_PID=$!
|
||||
cd ../../..
|
||||
|
||||
# Start Analytics Executor
|
||||
echo "Starting Analytics Executor (port 8091)..."
|
||||
cd backend/executors/analytics
|
||||
npm run dev &
|
||||
ANALYTICS_PID=$!
|
||||
cd ../../..
|
||||
|
||||
# Start Marketing Executor
|
||||
echo "Starting Marketing Executor (port 8093)..."
|
||||
cd backend/executors/marketing
|
||||
npm run dev &
|
||||
MARKETING_PID=$!
|
||||
cd ../../..
|
||||
|
||||
echo ""
|
||||
echo "✅ All services started!"
|
||||
echo ""
|
||||
echo "Services:"
|
||||
echo " - Control Plane: http://localhost:8080"
|
||||
echo " - Deploy Executor: http://localhost:8090"
|
||||
echo " - Analytics Executor: http://localhost:8091"
|
||||
echo " - Marketing Executor: http://localhost:8093"
|
||||
echo ""
|
||||
echo "Press Ctrl+C to stop all services"
|
||||
|
||||
# Wait for any process to exit
|
||||
wait
|
||||
|
||||
# Cleanup
|
||||
kill $CONTROL_PLANE_PID $DEPLOY_PID $ANALYTICS_PID $MARKETING_PID 2>/dev/null
|
||||
0
technical_spec.md
Normal file
0
technical_spec.md
Normal file
0
vision-ext.md
Normal file
0
vision-ext.md
Normal file
Reference in New Issue
Block a user