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