chore: convert submodules to standard directories for true monorepo structure
This commit is contained in:
180
vibn-frontend/lib/ai/chat-extraction-types.ts
Normal file
180
vibn-frontend/lib/ai/chat-extraction-types.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const evidenceArray = z.array(z.string()).default([]);
|
||||
const confidenceValue = z.number().min(0).max(1).default(0);
|
||||
const completionScore = z.number().min(0).max(1).default(0);
|
||||
|
||||
const defaultWeightedString = {
|
||||
description: null as string | null,
|
||||
confidence: 0,
|
||||
evidence: [] as string[],
|
||||
};
|
||||
|
||||
const weightedStringField = z
|
||||
.object({
|
||||
description: z.union([z.string(), z.null()]).default(null),
|
||||
confidence: confidenceValue.default(0),
|
||||
evidence: evidenceArray.default([]),
|
||||
})
|
||||
.default(defaultWeightedString);
|
||||
|
||||
const weightedListItem = z.object({
|
||||
id: z.string(),
|
||||
description: z.string(),
|
||||
confidence: confidenceValue,
|
||||
evidence: evidenceArray,
|
||||
});
|
||||
|
||||
const stageEnum = z.enum([
|
||||
'idea',
|
||||
'prototype',
|
||||
'mvp_in_progress',
|
||||
'live_beta',
|
||||
'live_paid',
|
||||
'unknown',
|
||||
]);
|
||||
|
||||
const severityEnum = z.enum(['low', 'medium', 'high', 'unknown']);
|
||||
const frequencyEnum = z.enum(['rare', 'occasional', 'frequent', 'constant', 'unknown']);
|
||||
const competitorTypeEnum = z.enum(['direct', 'indirect', 'alternative', 'unknown']);
|
||||
const relatedAreaEnum = z.enum(['product', 'tech', 'market', 'business_model', 'other']);
|
||||
const priorityEnum = z.enum(['high', 'medium', 'low']);
|
||||
|
||||
export const ChatExtractionSchema = z.object({
|
||||
project_summary: z.object({
|
||||
working_title: z.union([z.string(), z.null()]).default(null),
|
||||
one_liner: z.union([z.string(), z.null()]).default(null),
|
||||
stage: stageEnum.default('unknown'),
|
||||
overall_confidence: confidenceValue,
|
||||
evidence: evidenceArray,
|
||||
}),
|
||||
product_vision: z.object({
|
||||
problem_statement: weightedStringField,
|
||||
target_outcome: weightedStringField,
|
||||
founder_intent: weightedStringField,
|
||||
completion_score: completionScore,
|
||||
}),
|
||||
target_users: z.object({
|
||||
primary_segment: weightedStringField,
|
||||
segments: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
description: z.string(),
|
||||
jobs_to_be_done: z.array(z.string()).default([]),
|
||||
environment: z.union([z.string(), z.null()]),
|
||||
confidence: confidenceValue,
|
||||
evidence: evidenceArray,
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
completion_score: completionScore,
|
||||
}),
|
||||
problems_and_pains: z.object({
|
||||
problems: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
description: z.string(),
|
||||
severity: severityEnum,
|
||||
frequency: frequencyEnum,
|
||||
confidence: confidenceValue,
|
||||
evidence: evidenceArray,
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
completion_score: completionScore,
|
||||
}),
|
||||
solution_and_features: z.object({
|
||||
core_solution: weightedStringField,
|
||||
core_features: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
is_must_have_for_v1: z.boolean(),
|
||||
confidence: confidenceValue,
|
||||
evidence: evidenceArray,
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
nice_to_have_features: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
confidence: confidenceValue,
|
||||
evidence: evidenceArray,
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
completion_score: completionScore,
|
||||
}),
|
||||
market_and_competition: z.object({
|
||||
market_category: weightedStringField,
|
||||
competitors: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
type: competitorTypeEnum,
|
||||
confidence: confidenceValue,
|
||||
evidence: evidenceArray,
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
differentiation_points: weightedListItem.array().default([]),
|
||||
completion_score: completionScore,
|
||||
}),
|
||||
tech_and_constraints: z.object({
|
||||
stack_mentions: weightedListItem.array().default([]),
|
||||
constraints: weightedListItem.array().default([]),
|
||||
completion_score: completionScore,
|
||||
}),
|
||||
execution_status: z.object({
|
||||
current_stage: weightedStringField,
|
||||
work_done: weightedListItem.array().default([]),
|
||||
work_in_progress: weightedListItem.array().default([]),
|
||||
blocked_items: weightedListItem.array().default([]),
|
||||
completion_score: completionScore,
|
||||
}),
|
||||
goals_and_success: z.object({
|
||||
short_term_goals: weightedListItem.array().default([]),
|
||||
long_term_goals: weightedListItem.array().default([]),
|
||||
success_criteria: weightedListItem.array().default([]),
|
||||
completion_score: completionScore,
|
||||
}),
|
||||
unknowns_and_questions: z.object({
|
||||
unknowns: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
description: z.string(),
|
||||
related_area: relatedAreaEnum,
|
||||
evidence: evidenceArray,
|
||||
confidence: confidenceValue,
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
questions_to_ask_user: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
question: z.string(),
|
||||
priority: priorityEnum,
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
}),
|
||||
summary_scores: z.object({
|
||||
overall_completion: completionScore,
|
||||
overall_confidence: confidenceValue,
|
||||
}),
|
||||
});
|
||||
|
||||
export type ChatExtractionData = z.infer<typeof ChatExtractionSchema>;
|
||||
|
||||
|
||||
42
vibn-frontend/lib/ai/chat-extractor.ts
Normal file
42
vibn-frontend/lib/ai/chat-extractor.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { LlmClient } from '@/lib/ai/llm-client';
|
||||
import { ChatExtractionSchema } from '@/lib/ai/chat-extraction-types';
|
||||
import type { ChatExtractionData } from '@/lib/ai/chat-extraction-types';
|
||||
import type { KnowledgeItem } from '@/lib/types/knowledge';
|
||||
|
||||
const SYSTEM_PROMPT = `
|
||||
You are the Product Chat Signal Extractor for stalled SaaS projects.
|
||||
- Read the provided transcript carefully.
|
||||
- Extract grounded signals about the product, market, users, execution status, and unknowns.
|
||||
- Never invent data. Use "null" or empty arrays when the transcript lacks information.
|
||||
- Respond with valid JSON that matches the provided schema exactly. Do not include prose or code fences.
|
||||
`.trim();
|
||||
|
||||
export async function runChatExtraction(
|
||||
knowledgeItem: KnowledgeItem,
|
||||
llm: LlmClient,
|
||||
): Promise<ChatExtractionData> {
|
||||
const transcript = knowledgeItem.content.trim();
|
||||
|
||||
const userMessage = `
|
||||
You will analyze the following transcript. Use message references when listing evidence (e.g., msg_1).
|
||||
Focus on actionable product-building insights.
|
||||
|
||||
TRANSCRIPT_START
|
||||
${transcript}
|
||||
TRANSCRIPT_END`.trim();
|
||||
|
||||
return llm.structuredCall<ChatExtractionData>({
|
||||
model: 'gemini',
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
},
|
||||
],
|
||||
schema: ChatExtractionSchema,
|
||||
temperature: 0.2,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
38
vibn-frontend/lib/ai/chat-modes.ts
Normal file
38
vibn-frontend/lib/ai/chat-modes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Chat Modes and System Prompts
|
||||
*
|
||||
* Defines available chat modes and maps them to their system prompts.
|
||||
* Prompts are now versioned and managed in separate files under lib/ai/prompts/
|
||||
*/
|
||||
|
||||
import {
|
||||
collectorPrompt,
|
||||
extractionReviewPrompt,
|
||||
visionPrompt,
|
||||
mvpPrompt,
|
||||
marketingPrompt,
|
||||
generalChatPrompt,
|
||||
} from './prompts';
|
||||
|
||||
export type ChatMode =
|
||||
| "collector_mode"
|
||||
| "extraction_review_mode"
|
||||
| "vision_mode"
|
||||
| "mvp_mode"
|
||||
| "marketing_mode"
|
||||
| "general_chat_mode";
|
||||
|
||||
/**
|
||||
* Maps each chat mode to its current active system prompt.
|
||||
*
|
||||
* Prompts are version-controlled in separate files.
|
||||
* To update a prompt or switch versions, edit the corresponding file in lib/ai/prompts/
|
||||
*/
|
||||
export const MODE_SYSTEM_PROMPTS: Record<ChatMode, string> = {
|
||||
collector_mode: collectorPrompt,
|
||||
extraction_review_mode: extractionReviewPrompt,
|
||||
vision_mode: visionPrompt,
|
||||
mvp_mode: mvpPrompt,
|
||||
marketing_mode: marketingPrompt,
|
||||
general_chat_mode: generalChatPrompt,
|
||||
};
|
||||
297
vibn-frontend/lib/ai/chunking.ts
Normal file
297
vibn-frontend/lib/ai/chunking.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Text chunking for semantic search
|
||||
*
|
||||
* Splits large documents into smaller, semantically coherent chunks
|
||||
* suitable for vector embedding and retrieval.
|
||||
*/
|
||||
|
||||
export interface TextChunk {
|
||||
/** Index of this chunk (0-based) */
|
||||
index: number;
|
||||
|
||||
/** The chunked text content */
|
||||
text: string;
|
||||
|
||||
/** Approximate token count (for reference) */
|
||||
estimatedTokens: number;
|
||||
}
|
||||
|
||||
export interface ChunkingOptions {
|
||||
/** Target maximum tokens per chunk (approximate) */
|
||||
maxTokens?: number;
|
||||
|
||||
/** Target maximum characters per chunk (fallback if no tokenizer) */
|
||||
maxChars?: number;
|
||||
|
||||
/** Overlap between chunks (in characters) */
|
||||
overlapChars?: number;
|
||||
|
||||
/** Whether to try preserving paragraph boundaries */
|
||||
preserveParagraphs?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<ChunkingOptions> = {
|
||||
maxTokens: 800,
|
||||
maxChars: 3000, // Rough approximation: ~4 chars per token
|
||||
overlapChars: 200,
|
||||
preserveParagraphs: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Estimate token count from character count
|
||||
*
|
||||
* Uses a rough heuristic: 1 token ≈ 4 characters for English text.
|
||||
* For more accuracy, integrate a real tokenizer (e.g., tiktoken).
|
||||
*/
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into paragraphs, preserving empty lines as separators
|
||||
*/
|
||||
function splitIntoParagraphs(text: string): string[] {
|
||||
return text.split(/\n\n+/).filter((p) => p.trim().length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into sentences (simple heuristic)
|
||||
*/
|
||||
function splitIntoSentences(text: string): string[] {
|
||||
// Simple sentence boundary detection
|
||||
return text
|
||||
.split(/[.!?]+\s+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk text into semantic pieces suitable for embedding
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Split by paragraphs (if preserveParagraphs = true)
|
||||
* 2. Group paragraphs/sentences until reaching maxTokens/maxChars
|
||||
* 3. Add overlap between chunks for context continuity
|
||||
*
|
||||
* @param content - Text to chunk
|
||||
* @param options - Chunking options
|
||||
* @returns Array of text chunks with metadata
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const chunks = chunkText(longDocument, { maxTokens: 500, overlapChars: 100 });
|
||||
* for (const chunk of chunks) {
|
||||
* console.log(`Chunk ${chunk.index}: ${chunk.estimatedTokens} tokens`);
|
||||
* await embedText(chunk.text);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function chunkText(
|
||||
content: string,
|
||||
options: ChunkingOptions = {}
|
||||
): TextChunk[] {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const chunks: TextChunk[] = [];
|
||||
|
||||
if (!content || content.trim().length === 0) {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// Clean up content
|
||||
const cleanedContent = content.trim();
|
||||
|
||||
// If content is small enough, return as single chunk
|
||||
if (estimateTokens(cleanedContent) <= opts.maxTokens) {
|
||||
return [
|
||||
{
|
||||
index: 0,
|
||||
text: cleanedContent,
|
||||
estimatedTokens: estimateTokens(cleanedContent),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Split into paragraphs or sentences
|
||||
const units = opts.preserveParagraphs
|
||||
? splitIntoParagraphs(cleanedContent)
|
||||
: splitIntoSentences(cleanedContent);
|
||||
|
||||
if (units.length === 0) {
|
||||
return [
|
||||
{
|
||||
index: 0,
|
||||
text: cleanedContent,
|
||||
estimatedTokens: estimateTokens(cleanedContent),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let currentChunk = '';
|
||||
let chunkIndex = 0;
|
||||
let previousOverlap = '';
|
||||
|
||||
for (let i = 0; i < units.length; i++) {
|
||||
const unit = units[i];
|
||||
const potentialChunk = currentChunk
|
||||
? `${currentChunk}\n\n${unit}`
|
||||
: `${previousOverlap}${unit}`;
|
||||
|
||||
const potentialTokens = estimateTokens(potentialChunk);
|
||||
const potentialChars = potentialChunk.length;
|
||||
|
||||
// Check if adding this unit would exceed limits
|
||||
if (
|
||||
potentialTokens > opts.maxTokens ||
|
||||
potentialChars > opts.maxChars
|
||||
) {
|
||||
// Save current chunk if it has content
|
||||
if (currentChunk.length > 0) {
|
||||
chunks.push({
|
||||
index: chunkIndex++,
|
||||
text: currentChunk,
|
||||
estimatedTokens: estimateTokens(currentChunk),
|
||||
});
|
||||
|
||||
// Prepare overlap for next chunk
|
||||
const overlapStart = Math.max(
|
||||
0,
|
||||
currentChunk.length - opts.overlapChars
|
||||
);
|
||||
previousOverlap = currentChunk.substring(overlapStart);
|
||||
if (previousOverlap.length > 0 && !previousOverlap.endsWith(' ')) {
|
||||
// Try to start overlap at a word boundary
|
||||
const spaceIndex = previousOverlap.indexOf(' ');
|
||||
if (spaceIndex > 0) {
|
||||
previousOverlap = previousOverlap.substring(spaceIndex + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start new chunk with current unit
|
||||
currentChunk = `${previousOverlap}${unit}`;
|
||||
} else {
|
||||
// Add unit to current chunk
|
||||
currentChunk = potentialChunk;
|
||||
}
|
||||
}
|
||||
|
||||
// Add final chunk if it has content
|
||||
if (currentChunk.length > 0) {
|
||||
chunks.push({
|
||||
index: chunkIndex++,
|
||||
text: currentChunk,
|
||||
estimatedTokens: estimateTokens(currentChunk),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Chunking] Split ${cleanedContent.length} chars into ${chunks.length} chunks`
|
||||
);
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk text with code-aware splitting
|
||||
*
|
||||
* Preserves code blocks and tries to keep them intact.
|
||||
* Useful for chunking AI chat transcripts that contain code snippets.
|
||||
*/
|
||||
export function chunkTextWithCodeAwareness(
|
||||
content: string,
|
||||
options: ChunkingOptions = {}
|
||||
): TextChunk[] {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
// Detect code blocks (triple backticks)
|
||||
const codeBlockRegex = /```[\s\S]*?```/g;
|
||||
const codeBlocks: { start: number; end: number; content: string }[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = codeBlockRegex.exec(content)) !== null) {
|
||||
codeBlocks.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
content: match[0],
|
||||
});
|
||||
}
|
||||
|
||||
// If no code blocks, use standard chunking
|
||||
if (codeBlocks.length === 0) {
|
||||
return chunkText(content, options);
|
||||
}
|
||||
|
||||
// Split content around code blocks
|
||||
const chunks: TextChunk[] = [];
|
||||
let chunkIndex = 0;
|
||||
let currentPosition = 0;
|
||||
|
||||
for (const codeBlock of codeBlocks) {
|
||||
// Chunk text before code block
|
||||
const textBefore = content.substring(currentPosition, codeBlock.start);
|
||||
if (textBefore.trim().length > 0) {
|
||||
const textChunks = chunkText(textBefore, opts);
|
||||
for (const chunk of textChunks) {
|
||||
chunks.push({
|
||||
...chunk,
|
||||
index: chunkIndex++,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add code block as its own chunk (or split if too large)
|
||||
const codeTokens = estimateTokens(codeBlock.content);
|
||||
if (codeTokens <= opts.maxTokens) {
|
||||
chunks.push({
|
||||
index: chunkIndex++,
|
||||
text: codeBlock.content,
|
||||
estimatedTokens: codeTokens,
|
||||
});
|
||||
} else {
|
||||
// Code block is too large, split by lines
|
||||
const codeLines = codeBlock.content.split('\n');
|
||||
let currentCodeChunk = '';
|
||||
for (const line of codeLines) {
|
||||
const potentialChunk = currentCodeChunk
|
||||
? `${currentCodeChunk}\n${line}`
|
||||
: line;
|
||||
if (estimateTokens(potentialChunk) > opts.maxTokens) {
|
||||
if (currentCodeChunk.length > 0) {
|
||||
chunks.push({
|
||||
index: chunkIndex++,
|
||||
text: currentCodeChunk,
|
||||
estimatedTokens: estimateTokens(currentCodeChunk),
|
||||
});
|
||||
}
|
||||
currentCodeChunk = line;
|
||||
} else {
|
||||
currentCodeChunk = potentialChunk;
|
||||
}
|
||||
}
|
||||
if (currentCodeChunk.length > 0) {
|
||||
chunks.push({
|
||||
index: chunkIndex++,
|
||||
text: currentCodeChunk,
|
||||
estimatedTokens: estimateTokens(currentCodeChunk),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
currentPosition = codeBlock.end;
|
||||
}
|
||||
|
||||
// Chunk remaining text after last code block
|
||||
const textAfter = content.substring(currentPosition);
|
||||
if (textAfter.trim().length > 0) {
|
||||
const textChunks = chunkText(textAfter, opts);
|
||||
for (const chunk of textChunks) {
|
||||
chunks.push({
|
||||
...chunk,
|
||||
index: chunkIndex++,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
173
vibn-frontend/lib/ai/embeddings.ts
Normal file
173
vibn-frontend/lib/ai/embeddings.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Embedding generation using Gemini API
|
||||
*
|
||||
* Converts text into vector embeddings for semantic search.
|
||||
*/
|
||||
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
|
||||
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
|
||||
|
||||
if (!GEMINI_API_KEY) {
|
||||
console.warn('[Embeddings] GEMINI_API_KEY not set - embedding functions will fail');
|
||||
}
|
||||
|
||||
const genAI = GEMINI_API_KEY ? new GoogleGenerativeAI(GEMINI_API_KEY) : null;
|
||||
|
||||
// Gemini embedding model - text-embedding-004 produces 768-dim embeddings
|
||||
// Adjust EMBEDDING_DIMENSION in knowledge-chunks-schema.sql if using different model
|
||||
const EMBEDDING_MODEL = 'text-embedding-004';
|
||||
const EMBEDDING_DIMENSION = 768;
|
||||
|
||||
/**
|
||||
* Generate embedding for a single text string
|
||||
*
|
||||
* @param text - Input text to embed
|
||||
* @returns Vector embedding as array of numbers
|
||||
*
|
||||
* @throws Error if Gemini API is not configured or request fails
|
||||
*/
|
||||
export async function embedText(text: string): Promise<number[]> {
|
||||
if (!genAI) {
|
||||
throw new Error('GEMINI_API_KEY not configured - cannot generate embeddings');
|
||||
}
|
||||
|
||||
if (!text || text.trim().length === 0) {
|
||||
throw new Error('Cannot embed empty text');
|
||||
}
|
||||
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({ model: EMBEDDING_MODEL });
|
||||
const result = await model.embedContent(text);
|
||||
const embedding = result.embedding;
|
||||
|
||||
if (!embedding || !embedding.values || embedding.values.length === 0) {
|
||||
throw new Error('Gemini returned empty embedding');
|
||||
}
|
||||
|
||||
// Verify dimension matches expectation
|
||||
if (embedding.values.length !== EMBEDDING_DIMENSION) {
|
||||
console.warn(
|
||||
`[Embeddings] Unexpected dimension: got ${embedding.values.length}, expected ${EMBEDDING_DIMENSION}`
|
||||
);
|
||||
}
|
||||
|
||||
return embedding.values;
|
||||
} catch (error) {
|
||||
console.error('[Embeddings] Failed to embed text:', error);
|
||||
throw new Error(
|
||||
`Embedding generation failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings for multiple texts in batch
|
||||
*
|
||||
* More efficient than calling embedText() repeatedly.
|
||||
* Processes texts sequentially to avoid rate limiting.
|
||||
*
|
||||
* @param texts - Array of texts to embed
|
||||
* @param options - Batch processing options
|
||||
* @returns Array of embeddings (same order as input texts)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const chunks = ["First chunk...", "Second chunk...", "Third chunk..."];
|
||||
* const embeddings = await embedTextBatch(chunks);
|
||||
* // embeddings[0] corresponds to chunks[0], etc.
|
||||
* ```
|
||||
*/
|
||||
export async function embedTextBatch(
|
||||
texts: string[],
|
||||
options: { delayMs?: number; skipEmpty?: boolean } = {}
|
||||
): Promise<number[][]> {
|
||||
const { delayMs = 100, skipEmpty = true } = options;
|
||||
|
||||
if (texts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const embeddings: number[][] = [];
|
||||
|
||||
for (let i = 0; i < texts.length; i++) {
|
||||
const text = texts[i];
|
||||
|
||||
// Skip empty texts if requested
|
||||
if (skipEmpty && (!text || text.trim().length === 0)) {
|
||||
console.warn(`[Embeddings] Skipping empty text at index ${i}`);
|
||||
embeddings.push(new Array(EMBEDDING_DIMENSION).fill(0)); // Zero vector for empty
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const embedding = await embedText(text);
|
||||
embeddings.push(embedding);
|
||||
|
||||
// Add delay between requests to avoid rate limiting (except for last item)
|
||||
if (i < texts.length - 1 && delayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Embeddings] Failed to embed text at index ${i}:`, error);
|
||||
// Push zero vector as fallback
|
||||
embeddings.push(new Array(EMBEDDING_DIMENSION).fill(0));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Embeddings] Generated ${embeddings.length} embeddings`);
|
||||
|
||||
return embeddings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute cosine similarity between two embeddings
|
||||
*
|
||||
* @param a - First embedding vector
|
||||
* @param b - Second embedding vector
|
||||
* @returns Cosine similarity score (0-1, higher = more similar)
|
||||
*/
|
||||
export function cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (a.length !== b.length) {
|
||||
throw new Error('Embedding dimensions do not match');
|
||||
}
|
||||
|
||||
let dotProduct = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dotProduct += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
const magnitude = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
|
||||
if (magnitude === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return dotProduct / magnitude;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expected embedding dimension for the current model
|
||||
*/
|
||||
export function getEmbeddingDimension(): number {
|
||||
return EMBEDDING_DIMENSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if embeddings API is configured and working
|
||||
*/
|
||||
export async function checkEmbeddingsHealth(): Promise<boolean> {
|
||||
try {
|
||||
const testEmbedding = await embedText('health check');
|
||||
return testEmbedding.length === EMBEDDING_DIMENSION;
|
||||
} catch (error) {
|
||||
console.error('[Embeddings Health Check] Failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
138
vibn-frontend/lib/ai/error-recovery.ts
Normal file
138
vibn-frontend/lib/ai/error-recovery.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Tool-error recovery middleware.
|
||||
*
|
||||
* Pattern-matches known-recoverable error strings in MCP tool results
|
||||
* and produces a synthetic system message instructing the model on the
|
||||
* exact recovery action. Injected into the conversation before the
|
||||
* next model round.
|
||||
*
|
||||
* Why this exists (vs just a system-prompt rule):
|
||||
* Static prompt rules against accumulating tool reality lose. We've
|
||||
* shipped 4 orphan twenty-* services because the model kept doing
|
||||
* delete-and-recreate even though the prompt told it not to. The
|
||||
* model treats prompt rules as soft guidance; it cannot ignore a
|
||||
* fresh `role: "system"` message that arrives between tool result
|
||||
* and next call. See AI_HARNESS_GAPS.md §1 for the full case.
|
||||
*
|
||||
* Adding a rule:
|
||||
* 1. Pick a regex that matches the error string with NO false
|
||||
* positives. If it could fire on a legitimate success or
|
||||
* unrelated failure, leave it out — silent miss > wrong fix.
|
||||
* 2. Write the `diagnosis` as a sentence the model can use as-is
|
||||
* in a status update to the user.
|
||||
* 3. Write `requiredAction` as the literal next tool call(s) the
|
||||
* model should make, with arg shapes if non-obvious.
|
||||
* 4. Write `antipattern` as the wrong-but-tempting move the model
|
||||
* keeps doing. The injected message tells it explicitly NOT
|
||||
* to do this.
|
||||
*
|
||||
* Rules are checked in registration order. First match wins.
|
||||
*/
|
||||
|
||||
export interface RecoveryRule {
|
||||
/** Stable identifier for logs / future telemetry. */
|
||||
id: string;
|
||||
/** Pattern that uniquely identifies this error in tool output. */
|
||||
pattern: RegExp;
|
||||
/** Human-readable explanation of what went wrong. */
|
||||
diagnosis: string;
|
||||
/** Exact next tool call(s) the model should make. */
|
||||
requiredAction: string;
|
||||
/** The wrong move the model keeps making for this error. */
|
||||
antipattern: string;
|
||||
}
|
||||
|
||||
const RULES: RecoveryRule[] = [
|
||||
{
|
||||
id: 'orphan-container-conflict',
|
||||
// Matches: `Conflict. The container name "/postgres-..." is already in use`
|
||||
// Real prod example, twenty-crm thread, 2026-04-30.
|
||||
pattern: /Conflict\.\s+The container name\s+["/]?[\w./-]+["/]?\s+is already in use/i,
|
||||
diagnosis:
|
||||
'A previous deploy left an orphan Docker container holding this service\'s container name. The new boot is colliding with the orphan. This is a recoverable state.',
|
||||
requiredAction:
|
||||
'Call `apps_unstick { uuid }` against the SAME app uuid you were just trying to deploy, then `apps_deploy { uuid }`. Both calls use the existing uuid; do not create a new app.',
|
||||
antipattern:
|
||||
'Do NOT delete the failing app and create a new one with a different name. That keeps the orphan running, doubles the stack, and ships another shadow service. We have shipped 4 orphan twenty-* services this way before. Do not repeat it.',
|
||||
},
|
||||
{
|
||||
id: 'image-pull-denied',
|
||||
// Matches: `pull access denied for ...` and `manifest unknown` from the registry.
|
||||
pattern: /(pull access denied for|manifest unknown|repository does not exist)/i,
|
||||
diagnosis:
|
||||
'The Docker image referenced by this app is not on the host, and the registry pull failed (private repo, missing credentials, or wrong tag).',
|
||||
requiredAction:
|
||||
'Call `apps_repair { uuid }` to re-attempt the post-deploy fixes. If that fails too, surface the exact image reference to the user and ask whether the image should be pulled from a different registry or rebuilt.',
|
||||
antipattern:
|
||||
'Do NOT retry the same `apps_deploy` blindly hoping the registry will respond differently. The pull failure is persistent until the underlying image-availability issue is fixed.',
|
||||
},
|
||||
{
|
||||
id: 'workspace-quota-exceeded',
|
||||
// Matches the structured 402 returned by quotas.ts. The substring
|
||||
// "QUOTA_EXCEEDED" (the .code field) plus "active dev containers"
|
||||
// or "active projects" disambiguates from arbitrary text.
|
||||
pattern: /(QUOTA_EXCEEDED.*active (dev containers|projects)|already has \d+\/\d+ active (dev containers|projects))/i,
|
||||
diagnosis:
|
||||
'The workspace has hit its soft cap on active resources. This is a beta-limit guardrail, not a real error.',
|
||||
requiredAction:
|
||||
'Tell the user clearly which cap was hit and offer the two options: (1) suspend an existing dev container with `devcontainer_suspend { projectId }` if they have an idle one, or delete an unused project, OR (2) email support@vibnai.com to raise their cap. Do NOT retry the same call expecting a different result.',
|
||||
antipattern:
|
||||
'Do NOT keep retrying `devcontainer_ensure` or `projects.create` blindly. The cap is real until something is freed up. Do not try to bypass it by switching workspaces or projects.',
|
||||
},
|
||||
{
|
||||
id: 'devcontainer-still-provisioning',
|
||||
// Matches the JSON returned by devcontainer.status when the row is
|
||||
// still in 'provisioning' state. The status tool now self-heals
|
||||
// via a `true` exec probe, so seeing this means the probe failed
|
||||
// (container not yet up) — keep waiting OR escalate.
|
||||
pattern: /"state"\s*:\s*"provisioning"/,
|
||||
diagnosis:
|
||||
'The dev container is still booting. devcontainer.status already tried a liveness probe and the container did not respond yet. First-boot for a brand-new project takes 15-45s; image-pull failures take longer to surface as `likelyFailed: true`.',
|
||||
requiredAction:
|
||||
'If `ageSeconds < 60` and `likelyFailed` is not set: send the user ONE status message ("Spinning up your environment, this takes ~30s on first boot...") and wait. Do NOT poll devcontainer.status more than once every 15 seconds, and never more than 3 times in a row. After the wait, call `shell.exec { command: "echo ready" }` instead of `devcontainer.status` — shell.exec lazy-provisions and will return the moment the container is reachable, which is the actual signal you need. If `likelyFailed: true` (ageSeconds > 120): surface the failure to the user with the project id and stop polling.',
|
||||
antipattern:
|
||||
'Do NOT call `devcontainer.status` repeatedly in a tight loop. Status is a read; it does not boot anything. Polling it back-to-back wastes turns and shows the user a wall of identical "still provisioning" messages.',
|
||||
},
|
||||
{
|
||||
id: 'port-already-allocated',
|
||||
// Matches: `port is already allocated` / `bind: address already in use`.
|
||||
pattern: /(port\s+\S+\s+is already allocated|bind:\s+address already in use|Ports are not available)/i,
|
||||
diagnosis:
|
||||
'A different container or process on the host is already bound to the port this app is trying to claim.',
|
||||
requiredAction:
|
||||
'Use `apps_containers_list { uuid }` plus `shell_exec` (e.g. `docker ps --filter publish=<port>`) to identify the holder. If the holder is a stale Coolify-managed container, call `apps_unstick { uuid }` on its app. If it is a legitimate other app, surface the conflict to the user and ask which one should get the port.',
|
||||
antipattern:
|
||||
'Do NOT pick a random different port and retry. Port choice is part of the user\'s product configuration; a silent change will break their docs / DNS / clients.',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Inspect a tool result and return the matching recovery rule, or
|
||||
* null if nothing matches. The result is treated as plain text;
|
||||
* structured JSON tool results work fine because the error strings
|
||||
* we match on appear inside the JSON value.
|
||||
*/
|
||||
export function detectKnownError(toolResult: unknown): RecoveryRule | null {
|
||||
if (toolResult == null) return null;
|
||||
const text = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
|
||||
for (const rule of RULES) {
|
||||
if (rule.pattern.test(text)) return rule;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a recovery rule as the synthetic system message we inject
|
||||
* into the conversation before the next model round. The shape is
|
||||
* deliberately imperative ("CALL X. DO NOT do Y.") because that is
|
||||
* the prompting style the model responds to most reliably.
|
||||
*/
|
||||
export function formatRecoveryMessage(rule: RecoveryRule): string {
|
||||
return [
|
||||
`[RECOVERY: ${rule.id}]`,
|
||||
`Diagnosis: ${rule.diagnosis}`,
|
||||
`Required next action: ${rule.requiredAction}`,
|
||||
`Do NOT: ${rule.antipattern}`,
|
||||
`Send the user a one-line status before the recovery call so they know what you are doing.`,
|
||||
].join('\n');
|
||||
}
|
||||
285
vibn-frontend/lib/ai/gemini-chat.ts
Normal file
285
vibn-frontend/lib/ai/gemini-chat.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Gemini 3.1 Pro chat client with tool-calling support.
|
||||
*
|
||||
* Architecture:
|
||||
* - Tool-calling rounds use generateContent (non-streaming) so we always
|
||||
* get the complete response including thought_signature. Thinking models
|
||||
* (2.5+, 3.x) require this field to be echoed back in functionResponse
|
||||
* and it is not reliably present in individual SSE chunks.
|
||||
* - Final text-only response uses streamGenerateContent for good UX.
|
||||
*/
|
||||
|
||||
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || "";
|
||||
const GEMINI_MODEL = process.env.VIBN_CHAT_MODEL || "gemini-3.1-pro-preview";
|
||||
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
thoughtSignature?: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
/** Must be echoed back in functionResponse for Gemini thinking models */
|
||||
thoughtSignature?: string;
|
||||
}
|
||||
|
||||
export interface ToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChatChunk {
|
||||
type: "text" | "thinking" | "tool_call" | "done" | "error";
|
||||
text?: string;
|
||||
toolCall?: ToolCall;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Convert our ChatMessage[] to Gemini's contents[] format */
|
||||
function toGeminiContents(messages: ChatMessage[]) {
|
||||
const contents: any[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
contents.push({ role: "user", parts: [{ text: msg.content }] });
|
||||
} else if (msg.role === "assistant") {
|
||||
const parts: any[] = [];
|
||||
if (msg.content) parts.push({ text: msg.content });
|
||||
if (msg.toolCalls?.length) {
|
||||
for (const tc of msg.toolCalls) {
|
||||
// thoughtSignature is a SIBLING of functionCall in the part object,
|
||||
// not nested inside it. See: ai.google.dev/gemini-api/docs/thought-signatures
|
||||
const part: any = {
|
||||
functionCall: { name: tc.name, args: tc.args, id: tc.id },
|
||||
};
|
||||
if (tc.thoughtSignature) part.thoughtSignature = tc.thoughtSignature;
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
if (parts.length) contents.push({ role: "model", parts });
|
||||
} else if (msg.role === "tool") {
|
||||
const part = {
|
||||
functionResponse: {
|
||||
name: msg.toolName || "unknown",
|
||||
id: msg.toolCallId,
|
||||
response: { content: msg.content },
|
||||
},
|
||||
};
|
||||
const last = contents[contents.length - 1];
|
||||
if (last?.role === "user") {
|
||||
last.parts.push(part);
|
||||
} else {
|
||||
contents.push({ role: "user", parts: [part] });
|
||||
}
|
||||
}
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
function toGeminiFunctions(tools: ToolDefinition[]) {
|
||||
if (!tools.length) return undefined;
|
||||
return [
|
||||
{
|
||||
functionDeclarations: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.parameters,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildBody(opts: {
|
||||
systemPrompt: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
/**
|
||||
* Ask Gemini to return its thought summaries as parts marked
|
||||
* `thought: true`. We pay for thinking tokens regardless; this just
|
||||
* makes them visible so the UI can show "Reading server.js…",
|
||||
* "Shipping to production…" between tool calls instead of leaving
|
||||
* the user staring at a silent tool tray. Defaults to true.
|
||||
*/
|
||||
includeThoughts?: boolean;
|
||||
}) {
|
||||
const body: any = {
|
||||
contents: toGeminiContents(opts.messages),
|
||||
systemInstruction: { parts: [{ text: opts.systemPrompt }] },
|
||||
generationConfig: {
|
||||
temperature: opts.temperature ?? 0.7,
|
||||
maxOutputTokens: 8192,
|
||||
thinkingConfig: { includeThoughts: opts.includeThoughts ?? true },
|
||||
},
|
||||
};
|
||||
const fns = toGeminiFunctions(opts.tools ?? []);
|
||||
if (fns) body.tools = fns;
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-streaming call — used for tool-calling rounds.
|
||||
* Returns complete response with thought_signature guaranteed.
|
||||
*/
|
||||
export async function callGeminiChat(opts: {
|
||||
systemPrompt: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
includeThoughts?: boolean;
|
||||
}): Promise<{
|
||||
text: string;
|
||||
/** First-person reasoning narration; meant for a "thinking" UI panel, not the main bubble. */
|
||||
thoughts: string;
|
||||
toolCalls: ToolCall[];
|
||||
finishReason?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const url = `${GEMINI_BASE_URL}/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(buildBody(opts)),
|
||||
});
|
||||
} catch (e) {
|
||||
return {
|
||||
text: "",
|
||||
thoughts: "",
|
||||
toolCalls: [],
|
||||
error: `Network error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg = data?.error?.message || JSON.stringify(data).slice(0, 200);
|
||||
return {
|
||||
text: "",
|
||||
thoughts: "",
|
||||
toolCalls: [],
|
||||
error: `Gemini API error ${res.status}: ${msg}`,
|
||||
};
|
||||
}
|
||||
|
||||
const cand = data?.candidates?.[0];
|
||||
const parts: any[] = cand?.content?.parts ?? [];
|
||||
let text = "";
|
||||
let thoughts = "";
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.text) {
|
||||
// CRITICAL: Gemini tags reasoning parts with `thought: true`. If
|
||||
// we lump them into `text` they leak into the chat bubble as if
|
||||
// they were prose for the user — which is the opposite of what
|
||||
// the user wants. Keep them in their own bucket so the route
|
||||
// can stream them as a separate SSE event type.
|
||||
if (part.thought) thoughts += part.text;
|
||||
else text += part.text;
|
||||
}
|
||||
if (part.functionCall) {
|
||||
toolCalls.push({
|
||||
id:
|
||||
part.functionCall.id ||
|
||||
`tc-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
name: part.functionCall.name,
|
||||
args: part.functionCall.args ?? {},
|
||||
// thoughtSignature is a SIBLING of functionCall in the part, not inside it
|
||||
thoughtSignature: part.thoughtSignature,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { text, thoughts, toolCalls, finishReason: cand?.finishReason };
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming call — used for the final text-only response.
|
||||
* Yields ChatChunk objects.
|
||||
*/
|
||||
export async function* streamGeminiChat(opts: {
|
||||
systemPrompt: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
}): AsyncGenerator<ChatChunk> {
|
||||
const url = `${GEMINI_BASE_URL}/models/${GEMINI_MODEL}:streamGenerateContent?key=${GEMINI_API_KEY}&alt=sse`;
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(buildBody(opts)),
|
||||
});
|
||||
} catch (e) {
|
||||
yield {
|
||||
type: "error",
|
||||
error: `Network error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => "");
|
||||
yield {
|
||||
type: "error",
|
||||
error: `Gemini API error ${res.status}: ${errText.slice(0, 300)}`,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
yield { type: "error", error: "No response body" };
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (!data || data === "[DONE]") continue;
|
||||
let chunk: any;
|
||||
try {
|
||||
chunk = JSON.parse(data);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const parts = chunk?.candidates?.[0]?.content?.parts ?? [];
|
||||
for (const part of parts) {
|
||||
if (part.text) {
|
||||
yield part.thought
|
||||
? { type: "thinking", text: part.text }
|
||||
: { type: "text", text: part.text };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
yield { type: "done" };
|
||||
}
|
||||
189
vibn-frontend/lib/ai/gemini-client.ts
Normal file
189
vibn-frontend/lib/ai/gemini-client.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { LlmClient, StructuredCallArgs } from '@/lib/ai/llm-client';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
const DEFAULT_MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash-exp';
|
||||
const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || '';
|
||||
|
||||
const GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||
|
||||
class JsonValidationError extends Error {
|
||||
constructor(message: string, public readonly rawResponse: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function extractJsonPayload(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.startsWith('```')) {
|
||||
return trimmed.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function parseResponse<TOutput>(
|
||||
rawResponse: any,
|
||||
schema: StructuredCallArgs<TOutput>['schema'],
|
||||
): Promise<TOutput> {
|
||||
let text = '';
|
||||
|
||||
const finishReason = rawResponse?.candidates?.[0]?.finishReason;
|
||||
if (finishReason && finishReason !== 'STOP') {
|
||||
console.warn(`[Gemini] WARNING: Response may be incomplete. finishReason: ${finishReason}`);
|
||||
}
|
||||
|
||||
if (rawResponse?.candidates?.[0]?.content?.parts?.[0]?.text) {
|
||||
text = rawResponse.candidates[0].content.parts[0].text;
|
||||
} else if (rawResponse?.text) {
|
||||
text = rawResponse.text;
|
||||
} else if (typeof rawResponse === 'string') {
|
||||
text = rawResponse;
|
||||
}
|
||||
|
||||
if (text.trim().startsWith('<!DOCTYPE') || text.trim().startsWith('<html')) {
|
||||
console.error('[Gemini] Received HTML — likely an API auth error');
|
||||
throw new Error('Gemini API returned HTML. Check GOOGLE_API_KEY.');
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
console.error('[Gemini] Empty response:', JSON.stringify(rawResponse)?.slice(0, 300));
|
||||
throw new Error('Empty response from Gemini API');
|
||||
}
|
||||
|
||||
console.log('[Gemini] Response preview:', text.slice(0, 200));
|
||||
|
||||
const cleaned = extractJsonPayload(text);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(cleaned);
|
||||
} catch (error) {
|
||||
throw new JsonValidationError(
|
||||
`Failed to parse JSON: ${(error as Error).message}`,
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
const validation = schema.safeParse(parsed);
|
||||
if (!validation.success) {
|
||||
console.error('[Gemini] Schema validation failed:', validation.error.errors);
|
||||
throw new JsonValidationError(validation.error.message, text);
|
||||
}
|
||||
|
||||
return validation.data;
|
||||
}
|
||||
|
||||
export class GeminiLlmClient implements LlmClient {
|
||||
private readonly model: string;
|
||||
|
||||
constructor() {
|
||||
this.model = DEFAULT_MODEL;
|
||||
if (!GOOGLE_API_KEY) {
|
||||
console.warn('[Gemini] WARNING: GOOGLE_API_KEY is not set');
|
||||
}
|
||||
console.log(`[Gemini] Initialized — model: ${this.model}`);
|
||||
}
|
||||
|
||||
async structuredCall<TOutput>(args: StructuredCallArgs<TOutput>): Promise<TOutput> {
|
||||
if (args.model !== 'gemini') {
|
||||
throw new Error(`GeminiLlmClient only supports model "gemini" (got ${args.model})`);
|
||||
}
|
||||
|
||||
// Convert Zod schema → Google schema format
|
||||
const rawJsonSchema = zodToJsonSchema(args.schema, 'responseSchema') as any;
|
||||
let actualSchema: any = rawJsonSchema;
|
||||
if (rawJsonSchema.$ref && rawJsonSchema.definitions) {
|
||||
const refName = rawJsonSchema.$ref.replace('#/definitions/', '');
|
||||
actualSchema = rawJsonSchema.definitions[refName];
|
||||
}
|
||||
|
||||
const convertToGoogleSchema = (schema: any): any => {
|
||||
if (!schema || typeof schema !== 'object') return schema;
|
||||
const out: any = {};
|
||||
if (schema.type) out.type = schema.type.toUpperCase();
|
||||
if (schema.properties) {
|
||||
out.properties = {};
|
||||
for (const [k, v] of Object.entries(schema.properties)) {
|
||||
out.properties[k] = convertToGoogleSchema(v);
|
||||
}
|
||||
}
|
||||
if (schema.items) out.items = convertToGoogleSchema(schema.items);
|
||||
if (schema.required) out.required = schema.required;
|
||||
if (schema.description) out.description = schema.description;
|
||||
if (schema.enum) out.enum = schema.enum;
|
||||
return out;
|
||||
};
|
||||
|
||||
const googleSchema = convertToGoogleSchema(actualSchema);
|
||||
|
||||
// Build request body
|
||||
const body: any = {
|
||||
contents: args.messages.map((m) => ({
|
||||
role: m.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: m.content }],
|
||||
})),
|
||||
generationConfig: {
|
||||
temperature: args.temperature ?? 1.0,
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: googleSchema,
|
||||
maxOutputTokens: 32768,
|
||||
},
|
||||
};
|
||||
|
||||
if (args.systemPrompt) {
|
||||
const exampleJson: any = {};
|
||||
for (const key of Object.keys(googleSchema.properties || {})) {
|
||||
exampleJson[key] = key === 'reply' ? 'Your response here' : null;
|
||||
}
|
||||
body.systemInstruction = {
|
||||
parts: [{
|
||||
text: `${args.systemPrompt}\n\nIMPERATIVE: Respond ONLY with this exact JSON format:\n${JSON.stringify(exampleJson)}\n\nDo NOT add any other fields.`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
if (args.thinking_config) {
|
||||
body.generationConfig.thinkingConfig = {
|
||||
thinkingLevel: args.thinking_config.thinking_level?.toUpperCase() || 'HIGH',
|
||||
includeThoughts: args.thinking_config.include_thoughts || false,
|
||||
};
|
||||
}
|
||||
|
||||
const url = `${GEMINI_BASE_URL}/${this.model}:generateContent?key=${GOOGLE_API_KEY}`;
|
||||
|
||||
const run = async () => {
|
||||
console.log(`[Gemini] POST ${GEMINI_BASE_URL}/${this.model}:generateContent`);
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(180_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Gemini API error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return parseResponse(result, args.schema);
|
||||
};
|
||||
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
if (!(error instanceof JsonValidationError)) throw error;
|
||||
|
||||
// Retry once on JSON parse failure
|
||||
body.contents = [
|
||||
...body.contents,
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{
|
||||
text: `Your previous response was not valid JSON. Error: ${error.message}\nRespond again with ONLY valid JSON matching the schema. No code fences or comments.`,
|
||||
}],
|
||||
},
|
||||
];
|
||||
return run();
|
||||
}
|
||||
}
|
||||
}
|
||||
43
vibn-frontend/lib/ai/llm-client.ts
Normal file
43
vibn-frontend/lib/ai/llm-client.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ZodType, ZodTypeDef } from 'zod';
|
||||
|
||||
export type LlmModel = 'gemini' | 'gpt' | 'sonnet';
|
||||
|
||||
export interface LlmMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ThinkingConfig {
|
||||
/**
|
||||
* Thinking level for Gemini 3 models
|
||||
* - 'low': Minimizes latency and cost (for simple tasks)
|
||||
* - 'high': Maximizes reasoning depth (for complex tasks, default)
|
||||
*/
|
||||
thinking_level?: 'low' | 'high';
|
||||
|
||||
/**
|
||||
* Whether to include thoughts in the response
|
||||
* Useful for debugging/understanding model reasoning
|
||||
*/
|
||||
include_thoughts?: boolean;
|
||||
}
|
||||
|
||||
export interface StructuredCallArgs<TOutput> {
|
||||
model: LlmModel;
|
||||
systemPrompt: string;
|
||||
messages: LlmMessage[];
|
||||
schema: ZodType<TOutput, ZodTypeDef, any>;
|
||||
temperature?: number;
|
||||
|
||||
/**
|
||||
* Gemini 3 thinking configuration
|
||||
* Enables internal reasoning before responding
|
||||
*/
|
||||
thinking_config?: ThinkingConfig;
|
||||
}
|
||||
|
||||
export interface LlmClient {
|
||||
structuredCall<TOutput>(args: StructuredCallArgs<TOutput>): Promise<TOutput>;
|
||||
}
|
||||
|
||||
|
||||
70
vibn-frontend/lib/ai/marketing-agent.ts
Normal file
70
vibn-frontend/lib/ai/marketing-agent.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { z } from 'zod';
|
||||
import type { LlmClient } from '@/lib/ai/llm-client';
|
||||
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
|
||||
import { clamp, nowIso, loadPhaseContainers, persistPhaseArtifacts } from '@/lib/server/projects';
|
||||
import type { MarketingModel } from '@/lib/types/marketing';
|
||||
|
||||
const HomepageMessagingSchema = z.object({
|
||||
headline: z.string().nullable(),
|
||||
subheadline: z.string().nullable(),
|
||||
bullets: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
const MarketingModelSchema = z.object({
|
||||
projectId: z.string(),
|
||||
icp: z.array(z.string()).default([]),
|
||||
positioning: z.string().nullable(),
|
||||
homepageMessaging: HomepageMessagingSchema,
|
||||
initialChannels: z.array(z.string()).default([]),
|
||||
launchAngles: z.array(z.string()).default([]),
|
||||
overallConfidence: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
export async function runMarketingPlanning(
|
||||
projectId: string,
|
||||
llmClient?: LlmClient,
|
||||
): Promise<MarketingModel> {
|
||||
const { phaseData } = await loadPhaseContainers(projectId);
|
||||
const canonical = phaseData.canonicalProductModel;
|
||||
if (!canonical) {
|
||||
throw new Error('Canonical product model missing. Run buildCanonicalProductModel first.');
|
||||
}
|
||||
|
||||
const llm = llmClient ?? new GeminiLlmClient();
|
||||
const systemPrompt =
|
||||
'You are a SaaS marketing strategist. Given the canonical product model, produce ICP, positioning, homepage messaging, and launch ideas as strict JSON.';
|
||||
|
||||
const marketing = await llm.structuredCall<MarketingModel>({
|
||||
model: 'gemini',
|
||||
systemPrompt,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
'Canonical product model JSON:',
|
||||
'```json',
|
||||
JSON.stringify(canonical, null, 2),
|
||||
'```',
|
||||
'Respond ONLY with valid JSON that matches the required schema.',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
schema: MarketingModelSchema,
|
||||
temperature: 0.2,
|
||||
});
|
||||
|
||||
await persistPhaseArtifacts(projectId, (phaseData, phaseScores, phaseHistory) => {
|
||||
phaseData.marketingPlan = marketing;
|
||||
phaseScores.marketing = {
|
||||
overallCompletion: clamp(marketing.homepageMessaging.bullets.length ? 0.7 : 0.5),
|
||||
overallConfidence: marketing.overallConfidence,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
phaseHistory.push({ phase: 'marketing', status: 'completed', timestamp: nowIso() });
|
||||
return { phaseData, phaseScores, phaseHistory, nextPhase: 'marketing_ready' };
|
||||
});
|
||||
|
||||
return marketing;
|
||||
}
|
||||
|
||||
|
||||
62
vibn-frontend/lib/ai/mvp-agent.ts
Normal file
62
vibn-frontend/lib/ai/mvp-agent.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { z } from 'zod';
|
||||
import type { LlmClient } from '@/lib/ai/llm-client';
|
||||
import { GeminiLlmClient } from '@/lib/ai/gemini-client';
|
||||
import { clamp, nowIso, loadPhaseContainers, persistPhaseArtifacts } from '@/lib/server/projects';
|
||||
import type { MvpPlan } from '@/lib/types/mvp';
|
||||
|
||||
const MvpPlanSchema = z.object({
|
||||
projectId: z.string(),
|
||||
coreFlows: z.array(z.string()).default([]),
|
||||
coreFeatures: z.array(z.string()).default([]),
|
||||
supportingFeatures: z.array(z.string()).default([]),
|
||||
outOfScope: z.array(z.string()).default([]),
|
||||
technicalTasks: z.array(z.string()).default([]),
|
||||
blockers: z.array(z.string()).default([]),
|
||||
overallConfidence: z.number().min(0).max(1),
|
||||
});
|
||||
|
||||
export async function runMvpPlanning(projectId: string, llmClient?: LlmClient): Promise<MvpPlan> {
|
||||
const { phaseData } = await loadPhaseContainers(projectId);
|
||||
const canonical = phaseData.canonicalProductModel;
|
||||
if (!canonical) {
|
||||
throw new Error('Canonical product model missing. Run buildCanonicalProductModel first.');
|
||||
}
|
||||
|
||||
const llm = llmClient ?? new GeminiLlmClient();
|
||||
const systemPrompt =
|
||||
'You are an expert SaaS product manager. Given the canonical product model, produce the smallest sellable MVP plan as strict JSON.';
|
||||
|
||||
const plan = await llm.structuredCall<MvpPlan>({
|
||||
model: 'gemini',
|
||||
systemPrompt,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
'Canonical product model JSON:',
|
||||
'```json',
|
||||
JSON.stringify(canonical, null, 2),
|
||||
'```',
|
||||
'Respond ONLY with JSON that matches the required schema.',
|
||||
].join('\n'),
|
||||
},
|
||||
],
|
||||
schema: MvpPlanSchema,
|
||||
temperature: 0.2,
|
||||
});
|
||||
|
||||
await persistPhaseArtifacts(projectId, (phaseData, phaseScores, phaseHistory) => {
|
||||
phaseData.mvpPlan = plan;
|
||||
phaseScores.mvp = {
|
||||
overallCompletion: clamp(plan.coreFeatures.length ? 0.8 : 0.5),
|
||||
overallConfidence: plan.overallConfidence,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
phaseHistory.push({ phase: 'mvp', status: 'completed', timestamp: nowIso() });
|
||||
return { phaseData, phaseScores, phaseHistory, nextPhase: 'mvp_ready' };
|
||||
});
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
|
||||
370
vibn-frontend/lib/ai/openai-compatible-chat.ts
Normal file
370
vibn-frontend/lib/ai/openai-compatible-chat.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* OpenAI Chat Completions-compatible backend (DeepSeek, etc.).
|
||||
*
|
||||
* DeepSeek: base URL + `/chat/completions`, Bearer key — see
|
||||
* https://api-docs.deepseek.com/
|
||||
*
|
||||
* Tool schemas in Vibn are authored for Gemini (uppercase type enums).
|
||||
* We normalize them to JSON Schema before sending.
|
||||
*/
|
||||
|
||||
import type { ChatMessage, ToolCall, ToolDefinition } from "./gemini-chat";
|
||||
|
||||
const DEFAULT_CHAT_URL = "https://api.deepseek.com/chat/completions";
|
||||
|
||||
function resolveApiKey(): string {
|
||||
return (
|
||||
process.env.DEEPSEEK_API_KEY?.trim() ||
|
||||
process.env.VIBN_OPENAI_COMPATIBLE_API_KEY?.trim() ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
function resolveChatUrl(): string {
|
||||
const raw = process.env.VIBN_OPENAI_COMPATIBLE_CHAT_URL?.trim();
|
||||
if (raw) return raw.replace(/\/$/, "");
|
||||
const base = process.env.VIBN_OPENAI_COMPATIBLE_BASE_URL?.trim().replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
if (!base) return DEFAULT_CHAT_URL;
|
||||
if (base.endsWith("/chat/completions")) return base;
|
||||
return `${base}/chat/completions`;
|
||||
}
|
||||
|
||||
function resolveModel(): string {
|
||||
return (
|
||||
process.env.VIBN_OPENAI_COMPATIBLE_MODEL?.trim() ||
|
||||
process.env.DEEPSEEK_MODEL?.trim() ||
|
||||
"deepseek-chat"
|
||||
);
|
||||
}
|
||||
|
||||
/** Gemini API Catalog-style schema → OpenAI JSON Schema */
|
||||
function geminiStyleToJsonSchema(node: unknown): unknown {
|
||||
if (node === null || typeof node !== "object" || Array.isArray(node))
|
||||
return node;
|
||||
const n = node as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, val] of Object.entries(n)) {
|
||||
if (key === "type" && typeof val === "string") {
|
||||
const map: Record<string, string> = {
|
||||
OBJECT: "object",
|
||||
STRING: "string",
|
||||
NUMBER: "number",
|
||||
INTEGER: "integer",
|
||||
BOOLEAN: "boolean",
|
||||
ARRAY: "array",
|
||||
};
|
||||
const upper = val.toUpperCase();
|
||||
out.type = map[upper] ?? val.toLowerCase();
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
key === "properties" &&
|
||||
val &&
|
||||
typeof val === "object" &&
|
||||
!Array.isArray(val)
|
||||
) {
|
||||
out.properties = Object.fromEntries(
|
||||
Object.entries(val as object).map(([k, v]) => [
|
||||
k,
|
||||
geminiStyleToJsonSchema(v),
|
||||
]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (key === "items") {
|
||||
out.items = geminiStyleToJsonSchema(val);
|
||||
continue;
|
||||
}
|
||||
out[key] =
|
||||
val && typeof val === "object" && !Array.isArray(val)
|
||||
? geminiStyleToJsonSchema(val)
|
||||
: val;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toOpenAiTools(
|
||||
tools: ToolDefinition[] | undefined,
|
||||
): object[] | undefined {
|
||||
if (!tools?.length) return undefined;
|
||||
return tools.map((t) => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: geminiStyleToJsonSchema(t.parameters) as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI Chat Completions forbid `user`/`assistant` between an assistant
|
||||
* `tool_calls` block and the matching `tool` replies. Gemini-oriented code
|
||||
* may inject recovery `user` rows between individual tool results — move
|
||||
* those users to immediately after all tool rows for that assistant turn.
|
||||
*/
|
||||
function reorderMessagesForOpenAiToolPairs(
|
||||
messages: ChatMessage[],
|
||||
): ChatMessage[] {
|
||||
const result: ChatMessage[] = [];
|
||||
let i = 0;
|
||||
while (i < messages.length) {
|
||||
const m = messages[i]!;
|
||||
if (m.role !== "assistant" || !m.toolCalls?.length) {
|
||||
result.push(m);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedIds = m.toolCalls.map((tc) => tc.id);
|
||||
const pending = new Set(expectedIds);
|
||||
result.push(m);
|
||||
i++;
|
||||
|
||||
const toolById = new Map<string, ChatMessage>();
|
||||
const bufferedUsers: ChatMessage[] = [];
|
||||
|
||||
while (i < messages.length && pending.size > 0) {
|
||||
const n = messages[i]!;
|
||||
if (n.role === "tool" && n.toolCallId && pending.has(n.toolCallId)) {
|
||||
toolById.set(n.toolCallId, n);
|
||||
pending.delete(n.toolCallId);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (n.role === "user") {
|
||||
bufferedUsers.push(n);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
for (const id of expectedIds) {
|
||||
const t = toolById.get(id);
|
||||
if (t) result.push(t);
|
||||
}
|
||||
result.push(...bufferedUsers);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function toOpenAiMessages(
|
||||
systemPrompt: string,
|
||||
messages: ChatMessage[],
|
||||
): object[] {
|
||||
const normalized = reorderMessagesForOpenAiToolPairs(messages);
|
||||
const out: object[] = [{ role: "system", content: systemPrompt }];
|
||||
for (const m of normalized) {
|
||||
if (m.role === "user") {
|
||||
out.push({ role: "user", content: m.content });
|
||||
} else if (m.role === "assistant") {
|
||||
const hasTools = Boolean(m.toolCalls?.length);
|
||||
const text = typeof m.content === "string" ? m.content.trim() : "";
|
||||
const msg: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: text.length > 0 ? m.content : hasTools ? null : "",
|
||||
};
|
||||
if (hasTools && m.toolCalls) {
|
||||
msg.tool_calls = m.toolCalls.map((tc) => ({
|
||||
id: tc.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: tc.name,
|
||||
arguments: JSON.stringify(tc.args ?? {}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
out.push(msg);
|
||||
} else if (m.role === "tool") {
|
||||
const body =
|
||||
typeof m.content === "string"
|
||||
? m.content
|
||||
: JSON.stringify(m.content ?? "");
|
||||
out.push({
|
||||
role: "tool",
|
||||
tool_call_id: m.toolCallId ?? "",
|
||||
content: body.length > 0 ? body : "(empty)",
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseAssistantMessage(message: Record<string, unknown> | undefined): {
|
||||
text: string;
|
||||
thoughts: string;
|
||||
toolCalls: ToolCall[];
|
||||
} {
|
||||
const rawText = typeof message?.content === "string" ? message.content : "";
|
||||
const thoughts =
|
||||
typeof message?.reasoning_content === "string"
|
||||
? message.reasoning_content
|
||||
: typeof (message as { reasoning?: string })?.reasoning === "string"
|
||||
? (message as { reasoning: string }).reasoning
|
||||
: "";
|
||||
// DeepSeek separates thinking from speaking — during tool loops it
|
||||
// often puts everything in reasoning_content and leaves content empty.
|
||||
// When that happens, surface the reasoning as the user-visible text
|
||||
// so the user isn't staring at silent tool pills.
|
||||
const text = rawText || thoughts;
|
||||
const toolCalls: ToolCall[] = [];
|
||||
const rawCalls = message?.tool_calls;
|
||||
if (Array.isArray(rawCalls)) {
|
||||
for (const c of rawCalls) {
|
||||
const call = c as Record<string, unknown>;
|
||||
if (call.type !== "function") continue;
|
||||
const fn = call.function as Record<string, unknown> | undefined;
|
||||
const name = typeof fn?.name === "string" ? fn.name : "";
|
||||
const id =
|
||||
typeof call.id === "string"
|
||||
? call.id
|
||||
: `tc-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
let args: Record<string, unknown> = {};
|
||||
const argStr = typeof fn?.arguments === "string" ? fn.arguments : "{}";
|
||||
try {
|
||||
args = JSON.parse(argStr || "{}") as Record<string, unknown>;
|
||||
} catch {
|
||||
args = {};
|
||||
}
|
||||
if (name) toolCalls.push({ id, name, args });
|
||||
}
|
||||
}
|
||||
return { text, thoughts, toolCalls };
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-streaming chat + tool calls — mirrors {@link callGeminiChat} return shape.
|
||||
*/
|
||||
export async function callOpenAiCompatibleChat(opts: {
|
||||
systemPrompt: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
/** Unused for OpenAI-compat; kept for call-site symmetry */
|
||||
includeThoughts?: boolean;
|
||||
}): Promise<{
|
||||
text: string;
|
||||
thoughts: string;
|
||||
toolCalls: ToolCall[];
|
||||
finishReason?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const apiKey = resolveApiKey();
|
||||
if (!apiKey) {
|
||||
return {
|
||||
text: "",
|
||||
thoughts: "",
|
||||
toolCalls: [],
|
||||
error:
|
||||
"No API key: set DEEPSEEK_API_KEY or VIBN_OPENAI_COMPATIBLE_API_KEY for OpenAI-compatible chat.",
|
||||
};
|
||||
}
|
||||
|
||||
const url = resolveChatUrl();
|
||||
const model = resolveModel();
|
||||
const tools = toOpenAiTools(opts.tools);
|
||||
const oaiMessages = toOpenAiMessages(opts.systemPrompt, opts.messages);
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages: oaiMessages,
|
||||
temperature: opts.temperature ?? 0.7,
|
||||
max_tokens: 8192,
|
||||
stream: false,
|
||||
};
|
||||
if (tools?.length) body.tools = tools;
|
||||
|
||||
// ── Request logging (DeepSeek 400 debug) ──────────────────────────────
|
||||
const msgSummary = oaiMessages.map((m: any) => ({
|
||||
role: m.role,
|
||||
has_tool_calls:
|
||||
m.role === "assistant" ? Boolean(m.tool_calls?.length) : undefined,
|
||||
tool_calls_ids:
|
||||
m.role === "assistant" && m.tool_calls?.length
|
||||
? m.tool_calls.map((tc: any) => tc.id)
|
||||
: undefined,
|
||||
tool_call_id: m.role === "tool" ? m.tool_call_id : undefined,
|
||||
content_len: typeof m.content === "string" ? m.content.length : 0,
|
||||
}));
|
||||
console.error(
|
||||
"[deepseek] request",
|
||||
JSON.stringify({
|
||||
url,
|
||||
model,
|
||||
msg_count: oaiMessages.length,
|
||||
has_tools: Boolean(tools?.length),
|
||||
tool_count: tools?.length ?? 0,
|
||||
msg_summary: msgSummary,
|
||||
last_5_roles: msgSummary.slice(-5).map((m: any) => m.role),
|
||||
}),
|
||||
);
|
||||
// ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (e) {
|
||||
return {
|
||||
text: "",
|
||||
thoughts: "",
|
||||
toolCalls: [],
|
||||
error: `Network error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
if (!res.ok) {
|
||||
// ── Error logging (DeepSeek 400 debug) ───────────────────────────────
|
||||
console.error(
|
||||
"[deepseek] error response",
|
||||
JSON.stringify(
|
||||
{
|
||||
status: res.status,
|
||||
status_text: res.statusText,
|
||||
headers: Object.fromEntries(res.headers.entries()),
|
||||
body: data,
|
||||
// include the last few messages sent so we can see the exact
|
||||
// pattern that triggered the error
|
||||
last_5_sent: msgSummary.slice(-5),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
const errObj = data?.error as Record<string, unknown> | undefined;
|
||||
const msg =
|
||||
(typeof errObj?.message === "string" && errObj.message) ||
|
||||
JSON.stringify(data).slice(0, 280);
|
||||
return {
|
||||
text: "",
|
||||
thoughts: "",
|
||||
toolCalls: [],
|
||||
error: `Chat API error ${res.status}: ${msg}`,
|
||||
};
|
||||
}
|
||||
|
||||
const choice = (data.choices as Record<string, unknown>[] | undefined)?.[0];
|
||||
const message = choice?.message as Record<string, unknown> | undefined;
|
||||
const { text, thoughts, toolCalls } = parseAssistantMessage(message);
|
||||
const finishReason =
|
||||
typeof choice?.finish_reason === "string"
|
||||
? choice.finish_reason
|
||||
: undefined;
|
||||
|
||||
return { text, thoughts, toolCalls, finishReason };
|
||||
}
|
||||
266
vibn-frontend/lib/ai/plan-extract.ts
Normal file
266
vibn-frontend/lib/ai/plan-extract.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Fire-and-forget plan extraction from chat conversations.
|
||||
*
|
||||
* After each chat turn, we call a cheap Gemini model (Flash) to scan the
|
||||
* conversation for plan-worthy content — new tasks, decisions, vision updates —
|
||||
* and auto-persist them via the same `fs_projects.data->plan` path used by
|
||||
* the Plan tab MCP tools.
|
||||
*
|
||||
* The cheap model is configured via VIBN_CHEAP_MODEL (default: gemini-2.0-flash-001).
|
||||
*/
|
||||
|
||||
import { query } from "@/lib/db-postgres";
|
||||
|
||||
const GEMINI_API_KEY = process.env.GOOGLE_API_KEY || "";
|
||||
const CHEAP_MODEL =
|
||||
process.env.VIBN_CHEAP_MODEL || "gemini-2.0-flash-001";
|
||||
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
||||
|
||||
interface PlanExtraction {
|
||||
tasks: Array<{ title: string; description?: string }>;
|
||||
decisions: Array<{ title: string; choice: string; why?: string }>;
|
||||
visionUpdate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the cheap Gemini model to extract plan updates from the transcript.
|
||||
*/
|
||||
async function extractPlanFromTranscript(
|
||||
transcript: string,
|
||||
): Promise<PlanExtraction | null> {
|
||||
const url = `${GEMINI_BASE_URL}/models/${CHEAP_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
|
||||
|
||||
const body = {
|
||||
contents: [
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
text:
|
||||
"Extract any plan-worthy content from this AI coding conversation. " +
|
||||
"Return ONLY valid JSON with this schema:\n" +
|
||||
'{\n "tasks": [{"title": "short task name", "description": "optional details"}],\n' +
|
||||
' "decisions": [{"title": "what was decided", "choice": "the chosen option", "why": "reasoning"}],\n' +
|
||||
' "visionUpdate": "updated product vision (only if the conversation meaningfully changes or clarifies it)"\n' +
|
||||
"}\n\n" +
|
||||
"Rules:\n" +
|
||||
"- Only extract CLEAR tasks the AI committed to do or the user explicitly requested.\n" +
|
||||
"- Only extract NON-TRIVIAL decisions (not 'I'll read that file').\n" +
|
||||
"- visionUpdate: set ONLY when the user articulates or refines their product vision. Omit entirely if not.\n" +
|
||||
"- Return empty arrays if nothing worthy found.\n" +
|
||||
"- Do NOT wrap in markdown code fences. Just the raw JSON.\n\n" +
|
||||
"Conversation:\n" +
|
||||
transcript.slice(0, 12000),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: { temperature: 0.1, maxOutputTokens: 1024 },
|
||||
};
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) return null;
|
||||
|
||||
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
||||
if (!text.trim()) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(text.trim()) as PlanExtraction;
|
||||
} catch {
|
||||
// Strip markdown code fences if present
|
||||
const cleaned = text.replace(/```(?:json)?\s*/g, "").trim();
|
||||
try {
|
||||
return JSON.parse(cleaned) as PlanExtraction;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function planNewId(): string {
|
||||
return `plan_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
interface PlanProject {
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function loadPlanProject(
|
||||
projectId: string,
|
||||
): Promise<PlanProject | null> {
|
||||
const rows = await query<PlanProject>(
|
||||
`SELECT id, data FROM fs_projects WHERE id = $1 LIMIT 1`,
|
||||
[projectId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
interface PlanTask {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: "open" | "in_progress" | "review" | "done" | "blocked";
|
||||
text?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PlanDecision {
|
||||
id: string;
|
||||
title: string;
|
||||
choice: string;
|
||||
why?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PlanShape {
|
||||
vision?: string;
|
||||
ideas: Array<{ id: string; text: string; createdAt: string }>;
|
||||
tasks: PlanTask[];
|
||||
decisions: PlanDecision[];
|
||||
}
|
||||
|
||||
function readPlanFromData(data: Record<string, unknown>): PlanShape {
|
||||
const raw = (data?.plan as Record<string, unknown>) ?? {};
|
||||
const ideas = Array.isArray(raw.ideas) ? raw.ideas : [];
|
||||
const tasks = Array.isArray(raw.tasks)
|
||||
? (raw.tasks as PlanTask[]).map((t) => ({
|
||||
...t,
|
||||
id: String(t.id ?? planNewId()),
|
||||
title: String(t.title ?? t.text ?? "").trim(),
|
||||
status: t.status ?? "open",
|
||||
createdAt: String(t.createdAt ?? new Date().toISOString()),
|
||||
}))
|
||||
: [];
|
||||
const decisions = Array.isArray(raw.decisions)
|
||||
? (raw.decisions as PlanDecision[]).map((d) => ({
|
||||
...d,
|
||||
id: String(d.id ?? planNewId()),
|
||||
title: String(d.title ?? "").trim(),
|
||||
choice: String(d.choice ?? "").trim(),
|
||||
createdAt: String(d.createdAt ?? new Date().toISOString()),
|
||||
}))
|
||||
: [];
|
||||
return {
|
||||
vision: typeof raw.vision === "string" ? raw.vision : undefined,
|
||||
ideas,
|
||||
tasks,
|
||||
decisions,
|
||||
};
|
||||
}
|
||||
|
||||
async function writePlan(
|
||||
projectId: string,
|
||||
plan: PlanShape,
|
||||
alsoVision?: string,
|
||||
): Promise<void> {
|
||||
const serialized = {
|
||||
vision: plan.vision,
|
||||
ideas: plan.ideas,
|
||||
tasks: plan.tasks,
|
||||
decisions: plan.decisions,
|
||||
};
|
||||
if (alsoVision !== undefined) {
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = data || jsonb_build_object('plan', $2::jsonb, 'productVision', $3::text),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[projectId, JSON.stringify(serialized), alsoVision],
|
||||
);
|
||||
} else {
|
||||
await query(
|
||||
`UPDATE fs_projects
|
||||
SET data = data || jsonb_build_object('plan', $2::jsonb),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[projectId, JSON.stringify(serialized)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point: scan the conversation transcript and auto-update the
|
||||
* project plan with any extracted tasks/decisions/vision.
|
||||
*
|
||||
* Called fire-and-forget after each chat turn. Never throws.
|
||||
*/
|
||||
export async function autoExtractPlanUpdates(
|
||||
projectId: string,
|
||||
transcript: string,
|
||||
): Promise<{ tasks: number; decisions: number; vision: boolean } | null> {
|
||||
if (!projectId || transcript.length < 20) return null;
|
||||
|
||||
try {
|
||||
const extraction = await extractPlanFromTranscript(transcript);
|
||||
if (!extraction) return null;
|
||||
|
||||
const hasTasks = extraction.tasks?.length > 0;
|
||||
const hasDecisions = extraction.decisions?.length > 0;
|
||||
const hasVision =
|
||||
typeof extraction.visionUpdate === "string" &&
|
||||
extraction.visionUpdate.trim().length > 0;
|
||||
|
||||
if (!hasTasks && !hasDecisions && !hasVision) return null;
|
||||
|
||||
const project = await loadPlanProject(projectId);
|
||||
if (!project) return null;
|
||||
|
||||
const plan = readPlanFromData(project.data);
|
||||
const now = new Date().toISOString();
|
||||
let taskCount = 0;
|
||||
let decisionCount = 0;
|
||||
|
||||
for (const t of extraction.tasks ?? []) {
|
||||
const exists = plan.tasks.some(
|
||||
(existing) => existing.title.toLowerCase() === t.title.toLowerCase(),
|
||||
);
|
||||
if (exists) continue;
|
||||
plan.tasks.unshift({
|
||||
id: planNewId(),
|
||||
title: t.title.trim(),
|
||||
description: t.description?.trim(),
|
||||
status: "open",
|
||||
createdAt: now,
|
||||
});
|
||||
taskCount++;
|
||||
}
|
||||
|
||||
for (const d of extraction.decisions ?? []) {
|
||||
const exists = plan.decisions.some(
|
||||
(existing) => existing.title.toLowerCase() === d.title.toLowerCase(),
|
||||
);
|
||||
if (exists) continue;
|
||||
plan.decisions.unshift({
|
||||
id: planNewId(),
|
||||
title: d.title.trim(),
|
||||
choice: d.choice.trim(),
|
||||
why: d.why?.trim(),
|
||||
createdAt: now,
|
||||
});
|
||||
decisionCount++;
|
||||
}
|
||||
|
||||
if (hasVision && extraction.visionUpdate) {
|
||||
plan.vision = extraction.visionUpdate.trim();
|
||||
}
|
||||
|
||||
if (taskCount === 0 && decisionCount === 0 && !hasVision) return null;
|
||||
|
||||
await writePlan(projectId, plan, hasVision ? extraction.visionUpdate : undefined);
|
||||
return { tasks: taskCount, decisions: decisionCount, vision: hasVision };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
176
vibn-frontend/lib/ai/prompts/README.md
Normal file
176
vibn-frontend/lib/ai/prompts/README.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Prompt Management System
|
||||
|
||||
This directory contains all versioned system prompts for Vibn's chat modes.
|
||||
|
||||
## 📁 Structure
|
||||
|
||||
```
|
||||
prompts/
|
||||
├── index.ts # Exports all prompts
|
||||
├── shared.ts # Shared prompt components
|
||||
├── collector.ts # Collector mode prompts
|
||||
├── extraction-review.ts # Extraction review mode prompts
|
||||
├── vision.ts # Vision mode prompts
|
||||
├── mvp.ts # MVP mode prompts
|
||||
├── marketing.ts # Marketing mode prompts
|
||||
└── general-chat.ts # General chat mode prompts
|
||||
```
|
||||
|
||||
## 🔄 Versioning
|
||||
|
||||
Each prompt file contains:
|
||||
1. **Version history** - All versions of the prompt
|
||||
2. **Metadata** - Version number, date, description
|
||||
3. **Current version** - Which version is active
|
||||
|
||||
### Example Structure
|
||||
|
||||
```typescript
|
||||
const COLLECTOR_V1: PromptVersion = {
|
||||
version: 'v1',
|
||||
createdAt: '2024-11-17',
|
||||
description: 'Initial version',
|
||||
prompt: `...`,
|
||||
};
|
||||
|
||||
const COLLECTOR_V2: PromptVersion = {
|
||||
version: 'v2',
|
||||
createdAt: '2024-12-01',
|
||||
description: 'Added context-aware chunking',
|
||||
prompt: `...`,
|
||||
};
|
||||
|
||||
export const collectorPrompts = {
|
||||
v1: COLLECTOR_V1,
|
||||
v2: COLLECTOR_V2,
|
||||
current: 'v2', // ← Active version
|
||||
};
|
||||
```
|
||||
|
||||
## 📝 How to Add a New Prompt Version
|
||||
|
||||
1. **Open the relevant mode file** (e.g., `collector.ts`)
|
||||
2. **Create a new version constant:**
|
||||
```typescript
|
||||
const COLLECTOR_V2: PromptVersion = {
|
||||
version: 'v2',
|
||||
createdAt: '2024-12-01',
|
||||
description: 'What changed in this version',
|
||||
prompt: `
|
||||
Your new prompt text here...
|
||||
`,
|
||||
};
|
||||
```
|
||||
3. **Add to the prompts object:**
|
||||
```typescript
|
||||
export const collectorPrompts = {
|
||||
v1: COLLECTOR_V1,
|
||||
v2: COLLECTOR_V2, // Add new version
|
||||
current: 'v2', // Update current
|
||||
};
|
||||
```
|
||||
4. **Done!** The system will automatically use the new version.
|
||||
|
||||
## 🔙 How to Rollback
|
||||
|
||||
Simply change the `current` field:
|
||||
|
||||
```typescript
|
||||
export const collectorPrompts = {
|
||||
v1: COLLECTOR_V1,
|
||||
v2: COLLECTOR_V2,
|
||||
current: 'v1', // Rolled back to v1
|
||||
};
|
||||
```
|
||||
|
||||
## 📊 Benefits of This System
|
||||
|
||||
1. **Version History** - Keep all previous prompts for reference
|
||||
2. **Easy Rollback** - Instantly revert to a previous version
|
||||
3. **Git-Friendly** - Clear diffs show exactly what changed
|
||||
4. **Documentation** - Each version has a description of changes
|
||||
5. **A/B Testing Ready** - Can easily test multiple versions
|
||||
6. **Isolated Changes** - Changing one prompt doesn't affect others
|
||||
|
||||
## 🎯 Usage in Code
|
||||
|
||||
```typescript
|
||||
// Import current prompts (most common)
|
||||
import { MODE_SYSTEM_PROMPTS } from '@/lib/ai/chat-modes';
|
||||
|
||||
const prompt = MODE_SYSTEM_PROMPTS['collector_mode'];
|
||||
|
||||
// Or access version history
|
||||
import { collectorPrompts } from '@/lib/ai/prompts';
|
||||
|
||||
console.log(collectorPrompts.v1.prompt); // Old version
|
||||
console.log(collectorPrompts.current); // 'v2'
|
||||
```
|
||||
|
||||
## 🚀 Future Enhancements
|
||||
|
||||
### Analytics Tracking
|
||||
Track performance by prompt version:
|
||||
```typescript
|
||||
await logPromptUsage({
|
||||
mode: 'collector_mode',
|
||||
version: collectorPrompts.current,
|
||||
userId: user.id,
|
||||
responseQuality: 0.85,
|
||||
});
|
||||
```
|
||||
|
||||
### A/B Testing
|
||||
Test multiple versions simultaneously:
|
||||
```typescript
|
||||
const promptVersion = userInExperiment ? 'v2' : 'v1';
|
||||
const prompt = collectorPrompts[promptVersion].prompt;
|
||||
```
|
||||
|
||||
### Database Storage
|
||||
Move to Firestore for dynamic updates:
|
||||
```typescript
|
||||
// Future: Load from database
|
||||
const prompt = await getPrompt('collector_mode', 'latest');
|
||||
```
|
||||
|
||||
## 📚 Best Practices
|
||||
|
||||
1. **Always add a description** - Future you will thank you
|
||||
2. **Never delete old versions** - Keep history for rollback
|
||||
3. **Test before deploying** - Ensure new prompts work as expected
|
||||
4. **Document changes** - What problem does the new version solve?
|
||||
5. **Version incrementally** - Don't skip version numbers
|
||||
|
||||
## 🔍 Example: Adding Context-Aware Chunking
|
||||
|
||||
```typescript
|
||||
// 1. Create new version
|
||||
const COLLECTOR_V2: PromptVersion = {
|
||||
version: 'v2',
|
||||
createdAt: '2024-11-17',
|
||||
description: 'Added instructions for context-aware chunking',
|
||||
prompt: `
|
||||
${COLLECTOR_V1.prompt}
|
||||
|
||||
**Context-Aware Retrieval**:
|
||||
When referencing retrieved chunks, always cite the source document
|
||||
and chunk number for transparency.
|
||||
`,
|
||||
};
|
||||
|
||||
// 2. Update prompts object
|
||||
export const collectorPrompts = {
|
||||
v1: COLLECTOR_V1,
|
||||
v2: COLLECTOR_V2,
|
||||
current: 'v2',
|
||||
};
|
||||
|
||||
// 3. Deploy and monitor
|
||||
// If issues arise, simply change current: 'v1' to rollback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Questions?** Check the code in any prompt file for examples.
|
||||
|
||||
318
vibn-frontend/lib/ai/prompts/collector.ts
Normal file
318
vibn-frontend/lib/ai/prompts/collector.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Collector Mode Prompt
|
||||
*
|
||||
* Purpose: Gathers project materials and triggers analysis
|
||||
* Active when: No extractions exist yet
|
||||
*/
|
||||
|
||||
import { GITHUB_ACCESS_INSTRUCTION } from './shared';
|
||||
|
||||
export interface PromptVersion {
|
||||
version: string;
|
||||
prompt: string;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const COLLECTOR_V1: PromptVersion = {
|
||||
version: 'v1',
|
||||
createdAt: '2024-11-17',
|
||||
description: 'Initial version with GitHub analysis and context-aware behavior',
|
||||
prompt: `
|
||||
You are Vibn, an AI copilot that helps indie devs and small teams rescue stalled SaaS projects.
|
||||
|
||||
MODE: COLLECTOR
|
||||
|
||||
High-level goal:
|
||||
- First, ask and capture the 3 vision questions one at a time
|
||||
- Then help the user gather project materials (docs, GitHub, extension)
|
||||
- Once everything is gathered, trigger MVP generation
|
||||
- Be PROACTIVE and guide them step by step
|
||||
|
||||
You will receive:
|
||||
- A JSON object called projectContext with:
|
||||
- project: basic info including visionAnswers (q1, q2, q3 if answered)
|
||||
- knowledgeSummary: counts and examples of knowledge_items per sourceType
|
||||
- extractionSummary: will be empty in this phase
|
||||
- phaseData: likely empty at this point
|
||||
- repositoryAnalysis: GitHub repo structure, tech stack, README, and key files (if connected)
|
||||
- retrievedChunks: will be empty in this phase
|
||||
|
||||
**PRIORITY 1: ASK VISION QUESTIONS (One at a time):**
|
||||
Check projectContext.project.visionAnswers to see what's been answered:
|
||||
|
||||
**Question 1** - If visionAnswers.q1 is missing:
|
||||
Ask: "Let's start with your vision. **Who has the problem you want to fix and what is it?**"
|
||||
|
||||
When user answers:
|
||||
- Store ONLY: { visionAnswers: { q1: "[EXACT user answer]" } }
|
||||
- Do NOT include q2 or q3 yet
|
||||
- Reply MUST ask Q2: "Got it! [reflection]. Now, **tell me a story of this person using your tool and experiencing your vision?**"
|
||||
|
||||
**Question 2** - If visionAnswers.q1 exists but q2 is missing:
|
||||
Ask: "Now, **tell me a story of this person using your tool and experiencing your vision?**"
|
||||
|
||||
When user answers:
|
||||
- Store ONLY: { visionAnswers: { q2: "[EXACT user answer]" } }
|
||||
- Do NOT include q1 or q3 (they're already stored)
|
||||
- Reply MUST ask Q3: "Love it! [reflection]. One more: **How much did that improve things for them?**"
|
||||
|
||||
**Question 3** - If visionAnswers.q1 and q2 exist but q3 is missing:
|
||||
Ask: "One more: **How much did that improve things for them?**"
|
||||
|
||||
When user answers Q3, return EXACTLY this structure (be concise):
|
||||
{
|
||||
"reply": "Perfect! Let me generate your MVP plan now...",
|
||||
"visionAnswers": {
|
||||
"q3": "[user answer - keep under 50 words]",
|
||||
"allAnswered": true
|
||||
},
|
||||
"collectorHandoff": {
|
||||
"readyForExtraction": true
|
||||
}
|
||||
}
|
||||
|
||||
CRITICAL:
|
||||
- Do NOT repeat q1 or q2
|
||||
- Keep q3 value concise (under 50 words)
|
||||
- MUST include "allAnswered": true
|
||||
- MUST include "readyForExtraction": true
|
||||
|
||||
- Check if user has materials (docs, GitHub, extension in projectContext):
|
||||
* IF NO materials: Set collectorHandoff.readyForExtraction = true
|
||||
* IF materials exist: Set collectorHandoff.readyForExtraction = false (offer materials gathering)
|
||||
|
||||
**PRIORITY 2: GATHER MATERIALS (Only after all 3 vision questions answered):**
|
||||
When all vision questions answered AND user has materials (knowledgeSummary.totalCount > 0 OR githubRepo OR extensionLinked), say:
|
||||
|
||||
"Welcome to Vibn! I'm here to help you rescue your stalled SaaS project and get you shipping. Here's how this works:
|
||||
|
||||
**Step 1: Upload your documents** 📄
|
||||
Got any notes, specs, or brainstorm docs? Click the 'Context' tab to upload them.
|
||||
|
||||
**Step 2: Connect your GitHub repo** 🔗
|
||||
If you've already started coding, connect your repo so I can see your progress.
|
||||
|
||||
**Step 3: Install the browser extension** 🔌
|
||||
Have past AI chats with ChatGPT/Claude/Gemini? The Vibn extension captures those automatically and links them to this project.
|
||||
|
||||
Ready to start? What do you have for me first - documents, code, or AI chat history?"
|
||||
|
||||
**3-STEP CHECKLIST TRACKING:**
|
||||
Internally track these 3 items based on projectContext:
|
||||
|
||||
✅ **Documents uploaded?**
|
||||
- Check knowledgeSummary.bySourceType for 'imported_document' count > 0
|
||||
- If found, mention: "✅ I see you've uploaded [X] document(s)"
|
||||
|
||||
✅ **GitHub repo connected?**
|
||||
- Check if projectContext.project.githubRepo exists
|
||||
- If YES:
|
||||
* Lead with GitHub analysis from repositoryAnalysis
|
||||
* "✅ I can see your GitHub repo ([repo name]) - it's built with [tech stack], has [X] files..."
|
||||
* Do NOT ask them to explain the code - YOU tell THEM what you found
|
||||
- If NO and user hasn't been asked yet:
|
||||
* "Do you have a GitHub repo you'd like to connect? That way I can understand your technical progress."
|
||||
|
||||
✅ **Extension connected?**
|
||||
- Check projectContext.project.extensionLinked (boolean field)
|
||||
- If TRUE: "✅ I see your browser extension is connected"
|
||||
- If FALSE and user hasn't been asked yet:
|
||||
* "Have you installed the Vibn browser extension yet? It automatically captures your AI chat history from ChatGPT, Claude, etc. and links it to this project. Would you like to set that up?"
|
||||
|
||||
**BEHAVIOR RULES:**
|
||||
1. Be PROACTIVE, not reactive - guide them through the 3 steps
|
||||
2. ONE question at a time - don't overwhelm
|
||||
3. If user shares content in the message, acknowledge it: "Got it, I'll remember that."
|
||||
4. Do NOT repeat requests if items already exist in knowledgeSummary
|
||||
5. After each item is added, confirm it: "✅ Perfect, I've got that"
|
||||
6. When user seems done (or says "that's it", "that's all", etc.):
|
||||
- CHECK if at least ONE of the 3 items exists (docs, GitHub, or extension)
|
||||
- If YES, ask: **"Is that everything you want me to work with for now? If so, I'll start digging into the details of what you've shared."**
|
||||
- When user confirms (says "yes", "yep", "go ahead", etc.), respond:
|
||||
* "Perfect! Let me analyze what you've shared. This might take a moment..."
|
||||
* The system will automatically transition to extraction_review_mode
|
||||
7. If NO items exist yet, gently prompt: "What would you like to start with - uploading documents, connecting GitHub, or installing the extension?"
|
||||
8. **NEVER mention "Analyze Context" button or ask user to click anything** - the transition happens automatically when they say "that's everything"
|
||||
|
||||
**TONE:**
|
||||
- Supportive, practical, like a senior dev/PM who's helped rescue many projects
|
||||
- Reduce guilt about stalled work: "Totally normal to hit a wall. Let's get unstuck."
|
||||
- Example: "Cool, I've got that. Anything else you want to add before we analyze?"
|
||||
|
||||
${GITHUB_ACCESS_INSTRUCTION}`,
|
||||
};
|
||||
|
||||
const COLLECTOR_V2: PromptVersion = {
|
||||
version: 'v2',
|
||||
createdAt: '2025-11-17',
|
||||
description: 'Proactive collector with 3-step checklist and automatic handoff',
|
||||
prompt: `
|
||||
You are Vibn, an AI copilot that helps indie devs and small teams rescue stalled SaaS projects.
|
||||
|
||||
MODE: COLLECTOR
|
||||
|
||||
High-level goal:
|
||||
- First, ask and capture the 3 vision questions one at a time
|
||||
- Then help the user gather project materials (docs, GitHub, extension)
|
||||
- Once everything is gathered, trigger MVP generation
|
||||
- Be PROACTIVE and guide them step by step
|
||||
|
||||
You will receive:
|
||||
- A JSON object called projectContext with:
|
||||
- project: basic info including visionAnswers (q1, q2, q3 if answered)
|
||||
- knowledgeSummary: counts and examples of knowledge_items per sourceType
|
||||
- extractionSummary: will be empty in this phase
|
||||
- phaseData: likely empty at this point
|
||||
- repositoryAnalysis: GitHub repo structure, tech stack, README, and key files (if connected)
|
||||
- retrievedChunks: will be empty in this phase
|
||||
|
||||
**PRIORITY 1: ASK VISION QUESTIONS (One at a time):**
|
||||
Check projectContext.project.visionAnswers to see what's been answered:
|
||||
|
||||
**Question 1** - If visionAnswers.q1 is missing:
|
||||
Ask: "Let's start with your vision. **Who has the problem you want to fix and what is it?**"
|
||||
|
||||
When user answers:
|
||||
- Store ONLY: { visionAnswers: { q1: "[EXACT user answer]" } }
|
||||
- Do NOT include q2 or q3 yet
|
||||
- Reply MUST ask Q2: "Got it! [reflection]. Now, **tell me a story of this person using your tool and experiencing your vision?**"
|
||||
|
||||
**Question 2** - If visionAnswers.q1 exists but q2 is missing:
|
||||
Ask: "Now, **tell me a story of this person using your tool and experiencing your vision?**"
|
||||
|
||||
When user answers:
|
||||
- Store ONLY: { visionAnswers: { q2: "[EXACT user answer]" } }
|
||||
- Do NOT include q1 or q3 (they're already stored)
|
||||
- Reply MUST ask Q3: "Love it! [reflection]. One more: **How much did that improve things for them?**"
|
||||
|
||||
**Question 3** - If visionAnswers.q1 and q2 exist but q3 is missing:
|
||||
Ask: "One more: **How much did that improve things for them?**"
|
||||
|
||||
When user answers Q3, return EXACTLY this structure (be concise):
|
||||
{
|
||||
"reply": "Perfect! Let me generate your MVP plan now...",
|
||||
"visionAnswers": {
|
||||
"q3": "[user answer - keep under 50 words]",
|
||||
"allAnswered": true
|
||||
},
|
||||
"collectorHandoff": {
|
||||
"readyForExtraction": true
|
||||
}
|
||||
}
|
||||
|
||||
CRITICAL:
|
||||
- Do NOT repeat q1 or q2
|
||||
- Keep q3 value concise (under 50 words)
|
||||
- MUST include "allAnswered": true
|
||||
- MUST include "readyForExtraction": true
|
||||
|
||||
- Check if user has materials (docs, GitHub, extension in projectContext):
|
||||
* IF NO materials: Set collectorHandoff.readyForExtraction = true
|
||||
* IF materials exist: Set collectorHandoff.readyForExtraction = false (offer materials gathering)
|
||||
|
||||
**PRIORITY 2: GATHER MATERIALS (Only after all 3 vision questions answered):**
|
||||
When all vision questions answered AND user has materials (knowledgeSummary.totalCount > 0 OR githubRepo OR extensionLinked), say:
|
||||
|
||||
"Welcome to Vibn! I'm here to help you rescue your stalled SaaS project and get you shipping. Here's how this works:
|
||||
|
||||
**Step 1: Upload your documents** 📄
|
||||
Got any notes, specs, or brainstorm docs? Click the 'Context' tab to upload them.
|
||||
|
||||
**Step 2: Connect your GitHub repo** 🔗
|
||||
If you've already started coding, connect your repo so I can see your progress.
|
||||
|
||||
**Step 3: Install the browser extension** 🔌
|
||||
Have past AI chats with ChatGPT/Claude/Gemini? The Vibn extension captures those automatically and links them to this project.
|
||||
|
||||
Ready to start? What do you have for me first - documents, code, or AI chat history?"
|
||||
|
||||
**3-STEP CHECKLIST TRACKING:**
|
||||
Internally track these 3 items based on projectContext:
|
||||
|
||||
✅ **Documents uploaded?**
|
||||
- Check knowledgeSummary.bySourceType for 'imported_document' count > 0
|
||||
- If found, mention: "✅ I see you've uploaded [X] document(s)"
|
||||
|
||||
✅ **GitHub repo connected?**
|
||||
- Check if projectContext.project.githubRepo exists
|
||||
- If YES:
|
||||
* Lead with GitHub analysis from repositoryAnalysis
|
||||
* "✅ I can see your GitHub repo ([repo name]) - it's built with [tech stack], has [X] files..."
|
||||
* Do NOT ask them to explain the code - YOU tell THEM what you found
|
||||
- If NO and user hasn't been asked yet:
|
||||
* "Do you have a GitHub repo you'd like to connect? That way I can understand your technical progress."
|
||||
|
||||
✅ **Extension connected?**
|
||||
- Check projectContext.project.extensionLinked (boolean field)
|
||||
- If TRUE: "✅ I see your browser extension is connected"
|
||||
- If FALSE and user hasn't been asked yet:
|
||||
* "Have you installed the Vibn browser extension yet? It automatically captures your AI chat history from ChatGPT, Claude, etc. and links it to this project. Would you like to set that up?"
|
||||
|
||||
**BEHAVIOR RULES:**
|
||||
1. **VISION QUESTIONS FIRST** - Do NOT ask about documents/GitHub/extension until all 3 vision questions are answered
|
||||
2. ONE question at a time - don't overwhelm
|
||||
3. After answering Question 3:
|
||||
- If user has NO materials (no docs, no GitHub, no extension):
|
||||
* Say: "Perfect! I've got everything I need to create your MVP plan. Give me a moment to generate it..."
|
||||
* Set collectorHandoff.readyForExtraction = true to trigger MVP generation
|
||||
- If user DOES have materials (docs/GitHub/extension exist):
|
||||
* Transition to gathering mode and offer the 3-step setup
|
||||
4. If user shares content in the message, acknowledge it: "Got it, I'll remember that."
|
||||
5. Do NOT repeat requests if items already exist in knowledgeSummary
|
||||
6. After each item is added, confirm it: "✅ Perfect, I've got that"
|
||||
7. When user seems done with materials (or says "that's it", "that's all", etc.):
|
||||
- CHECK if at least ONE of the 3 items exists (docs, GitHub, or extension)
|
||||
- If YES, ask: **"Is that everything you want me to work with for now? If so, I'll start creating your MVP plan."**
|
||||
- When user confirms (says "yes", "yep", "go ahead", etc.), respond:
|
||||
* "Perfect! Let me generate your MVP plan. This might take a moment..."
|
||||
* Set collectorHandoff.readyForExtraction = true
|
||||
8. **NEVER mention "Analyze Context" button or ask user to click anything** - the transition happens automatically when they confirm
|
||||
|
||||
**TONE:**
|
||||
- Supportive, practical, like a senior dev/PM who's helped rescue many projects
|
||||
- Reduce guilt about stalled work: "Totally normal to hit a wall. Let's get unstuck."
|
||||
- Example: "Cool, I've got that. Anything else you want to add before we analyze?"
|
||||
|
||||
**STRUCTURED OUTPUT:**
|
||||
In addition to your conversational reply, you MUST also return these objects:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"reply": "Your conversational response here",
|
||||
"visionAnswers": {
|
||||
"q1": "User's answer to Q1", // Include if user answered Q1 this turn
|
||||
"q2": "User's answer to Q2", // Include if user answered Q2 this turn
|
||||
"q3": "User's answer to Q3", // Include if user answered Q3 this turn
|
||||
"allAnswered": true // Set to true ONLY when Q3 is answered
|
||||
},
|
||||
"collectorHandoff": {
|
||||
"hasDocuments": true, // Are documents uploaded?
|
||||
"documentCount": 5, // How many?
|
||||
"githubConnected": true, // Is GitHub connected?
|
||||
"githubRepo": "user/repo", // Repo name if connected
|
||||
"extensionLinked": false, // Is extension connected?
|
||||
"extensionDeclined": false, // Did user say no to extension?
|
||||
"noGithubYet": false, // Did user say they don't have GitHub yet?
|
||||
"readyForExtraction": false // Is user ready to move to MVP generation? (true when they say "yes" after materials OR after Q3 if no materials)
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Update this object on EVERY response based on the current state of:
|
||||
- What you see in projectContext (documents, GitHub, extension)
|
||||
- What the user explicitly confirms or declines
|
||||
|
||||
This data will be persisted to Firestore so the checklist state survives across sessions.
|
||||
|
||||
${GITHUB_ACCESS_INSTRUCTION}`,
|
||||
};
|
||||
|
||||
export const collectorPrompts = {
|
||||
v1: COLLECTOR_V1,
|
||||
v2: COLLECTOR_V2,
|
||||
current: 'v2',
|
||||
};
|
||||
|
||||
export const collectorPrompt = (collectorPrompts[collectorPrompts.current as 'v1' | 'v2'] as PromptVersion).prompt;
|
||||
|
||||
200
vibn-frontend/lib/ai/prompts/extraction-review.ts
Normal file
200
vibn-frontend/lib/ai/prompts/extraction-review.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Extraction Review Mode Prompt
|
||||
*
|
||||
* Purpose: Reviews extracted product signals and fills gaps
|
||||
* Active when: Extractions exist but no product model yet
|
||||
*/
|
||||
|
||||
import { GITHUB_ACCESS_INSTRUCTION } from './shared';
|
||||
import type { PromptVersion } from './collector';
|
||||
|
||||
const EXTRACTION_REVIEW_V1: PromptVersion = {
|
||||
version: 'v1',
|
||||
createdAt: '2024-11-17',
|
||||
description: 'Initial version for reviewing extracted signals',
|
||||
prompt: `
|
||||
You are Vibn, an AI copilot helping indie devs get unstuck on their SaaS projects.
|
||||
|
||||
MODE: EXTRACTION REVIEW
|
||||
|
||||
High-level goal:
|
||||
- Read the uploaded documents and GitHub code
|
||||
- Identify potential product insights (problems, users, features, constraints)
|
||||
- Collaborate with the user: "Is this section important for your product?"
|
||||
- Chunk and store confirmed insights as requirements for later retrieval
|
||||
|
||||
You will receive:
|
||||
- projectContext JSON with:
|
||||
- project
|
||||
- knowledgeSummary
|
||||
- extractionSummary: merged view over chat_extractions.data
|
||||
- phaseScores.extractor
|
||||
- phaseData.canonicalProductModel: likely undefined or incomplete
|
||||
- retrievedChunks: relevant content from AlloyDB vector search
|
||||
|
||||
**YOUR WORKFLOW:**
|
||||
|
||||
**Step 1: Read & Identify**
|
||||
- Go through each uploaded document and GitHub repo
|
||||
- Identify potential insights:
|
||||
* Problem statements
|
||||
* Target user descriptions
|
||||
* Feature requests or ideas
|
||||
* Technical constraints
|
||||
* Business requirements
|
||||
* Design decisions
|
||||
|
||||
**Step 2: Collaborative Review**
|
||||
- For EACH potential insight, ask the user:
|
||||
* "I found this section about [topic]. Is this important for your V1 product?"
|
||||
* Show them the specific text/code snippet
|
||||
* Ask: "Should I save this as a requirement?"
|
||||
|
||||
**Step 3: Chunk & Store**
|
||||
- When user confirms an insight is important:
|
||||
* Extract that specific section
|
||||
* Create a focused chunk (semantic boundary, not arbitrary split)
|
||||
* Store in AlloyDB with metadata:
|
||||
- importance: 'primary' (user confirmed)
|
||||
- sourceType: 'extracted_insight'
|
||||
- tags: ['requirement', 'user_confirmed', topic]
|
||||
* Acknowledge: "✅ Saved! I'll remember this for later phases."
|
||||
|
||||
**Step 4: Build Product Model**
|
||||
- After reviewing all documents, synthesize confirmed insights into:
|
||||
* canonicalProductModel: structured JSON with problems, users, features, constraints
|
||||
* This becomes the foundation for Vision and MVP phases
|
||||
|
||||
**BEHAVIOR RULES:**
|
||||
1. Start by saying: "I'm reading through everything you've shared. Let me walk through what I found..."
|
||||
2. Present insights ONE AT A TIME - don't overwhelm
|
||||
3. Show the ACTUAL TEXT from their docs: "Here's what you wrote: [quote]"
|
||||
4. Ask clearly: "Is this important for your product? Should I save it?"
|
||||
5. If user says "no" or "not for V1" → skip that section, move on
|
||||
6. If user says "yes" → chunk it, store it, confirm with ✅
|
||||
7. After reviewing all docs, ask: "I've identified [X] key requirements. Does that sound right, or should we revisit anything?"
|
||||
8. Do NOT auto-chunk everything - only chunk what the user confirms is important
|
||||
9. Keep responses TIGHT - you're guiding a review process, not writing essays
|
||||
|
||||
**CHUNKING STRATEGY:**
|
||||
- Chunk by SEMANTIC MEANING, not character count
|
||||
- A chunk = one cohesive insight (e.g., one feature description, one user persona, one constraint)
|
||||
- Preserve context: include enough surrounding text for the chunk to make sense later
|
||||
- Typical chunk size: 200-1000 words (flexible based on content)
|
||||
|
||||
**TONE:**
|
||||
- Collaborative: "Here's what I see. Tell me where I'm wrong."
|
||||
- Practical: "Let's figure out what matters for V1."
|
||||
- No interrogation, no long questionnaires.
|
||||
|
||||
${GITHUB_ACCESS_INSTRUCTION}`,
|
||||
};
|
||||
|
||||
const EXTRACTION_REVIEW_V2: PromptVersion = {
|
||||
version: 'v2',
|
||||
createdAt: '2025-11-17',
|
||||
description: 'Review backend extraction results',
|
||||
prompt: `
|
||||
You are Vibn, an AI copilot helping indie devs get unstuck on their SaaS projects.
|
||||
|
||||
MODE: EXTRACTION REVIEW
|
||||
|
||||
**CRITICAL**: You are NOT doing extraction. Extraction was ALREADY DONE by the backend.
|
||||
|
||||
Your job:
|
||||
- Review the extraction results that Vibn's backend already processed
|
||||
- Show the user what was found in their documents/code
|
||||
- Ask clarifying questions based on what's uncertain or missing
|
||||
- Help refine the product understanding
|
||||
|
||||
You will receive:
|
||||
- projectContext JSON with:
|
||||
- phaseData.phaseHandoffs.extraction: The extraction results
|
||||
- confirmed: {problems, targetUsers, features, constraints, opportunities}
|
||||
- uncertain: items that need clarification
|
||||
- missing: gaps the extraction identified
|
||||
- questionsForUser: specific questions to ask
|
||||
- extractionSummary: aggregated extraction data
|
||||
- repositoryAnalysis: GitHub repo structure (if connected)
|
||||
|
||||
**NEVER say:**
|
||||
- "I'm processing your documents..."
|
||||
- "Let me analyze this..."
|
||||
- "I'll read through everything..."
|
||||
|
||||
The extraction is DONE. You're reviewing the RESULTS.
|
||||
|
||||
**YOUR WORKFLOW:**
|
||||
|
||||
**Step 1: FIRST RESPONSE - Present Extraction Results**
|
||||
Your very first response MUST present what was extracted:
|
||||
|
||||
Example:
|
||||
"I've analyzed your materials. Here's what I found:
|
||||
|
||||
**Problems/Pain Points:**
|
||||
- [Problem 1 from extraction]
|
||||
- [Problem 2 from extraction]
|
||||
|
||||
**Target Users:**
|
||||
- [User type 1]
|
||||
- [User type 2]
|
||||
|
||||
**Key Features:**
|
||||
- [Feature 1]
|
||||
- [Feature 2]
|
||||
|
||||
**Constraints:**
|
||||
- [Constraint 1]
|
||||
|
||||
What looks right here? What's missing or wrong?"
|
||||
|
||||
**Step 2: Address Uncertainties**
|
||||
- If phaseHandoffs.extraction has questionsForUser:
|
||||
* Ask them: "I wasn't sure about [X]. Can you clarify?"
|
||||
- If phaseHandoffs.extraction has missing items:
|
||||
* Ask: "I didn't find info about [Y]. Do you have thoughts on that?"
|
||||
|
||||
**Step 3: Refine Understanding**
|
||||
- Listen to user feedback
|
||||
- Correct misunderstandings
|
||||
- Fill in gaps
|
||||
- Prepare for vision phase
|
||||
|
||||
**Step 4: Transition to Vision**
|
||||
- When user confirms extraction is complete/approved:
|
||||
* Set extractionReviewHandoff.readyForVision = true
|
||||
* Say something like: "Great! I've locked in the project scope, features, and constraints based on our review. We're all set to move on to the Vision phase to define your MVP."
|
||||
* The system will automatically transition to vision_mode
|
||||
|
||||
**BEHAVIOR RULES:**
|
||||
1. **Present extraction results immediately** - don't say "still processing"
|
||||
2. Show what was FOUND, not what you're FINDING
|
||||
3. Ask clarifying questions based on uncertainties/missing items
|
||||
4. Be conversational but brief
|
||||
5. Keep responses focused - you're REVIEWING, not extracting
|
||||
6. If extraction found nothing substantial, say: "I didn't find much detail in the documents. Let's fill in the gaps together. What's the core problem you're solving?"
|
||||
7. **IMPORTANT**: When user says "looks good", "approved", "let's move on", "ready for next phase" → set extractionReviewHandoff.readyForVision = true
|
||||
|
||||
**CHUNKING STRATEGY:**
|
||||
- Chunk by SEMANTIC MEANING, not character count
|
||||
- A chunk = one cohesive insight (e.g., one feature description, one user persona, one constraint)
|
||||
- Preserve context: include enough surrounding text for the chunk to make sense later
|
||||
- Typical chunk size: 200-1000 words (flexible based on content)
|
||||
|
||||
**TONE:**
|
||||
- Collaborative: "Here's what I see. Tell me where I'm wrong."
|
||||
- Practical: "Let's figure out what matters for V1."
|
||||
- No interrogation, no long questionnaires.
|
||||
|
||||
${GITHUB_ACCESS_INSTRUCTION}`,
|
||||
};
|
||||
|
||||
export const extractionReviewPrompts = {
|
||||
v1: EXTRACTION_REVIEW_V1,
|
||||
v2: EXTRACTION_REVIEW_V2,
|
||||
current: 'v2',
|
||||
};
|
||||
|
||||
export const extractionReviewPrompt = (extractionReviewPrompts[extractionReviewPrompts.current as 'v1' | 'v2'] as PromptVersion).prompt;
|
||||
|
||||
90
vibn-frontend/lib/ai/prompts/extractor.ts
Normal file
90
vibn-frontend/lib/ai/prompts/extractor.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Backend Extractor System Prompt
|
||||
*
|
||||
* Used ONLY by the backend extraction job.
|
||||
* NOT used in chat conversation.
|
||||
*
|
||||
* Features:
|
||||
* - Runs with Gemini 3 Pro Preview's thinking mode enabled
|
||||
* - Model performs internal reasoning before extracting signals
|
||||
* - Higher accuracy in pattern detection and signal classification
|
||||
*/
|
||||
|
||||
export const BACKEND_EXTRACTOR_SYSTEM_PROMPT = `You are a backend-only extraction engine for Vibn, not a chat assistant.
|
||||
|
||||
Your job:
|
||||
- Read the given document text.
|
||||
- Identify only product-related content:
|
||||
- problems/pain points
|
||||
- target users and personas
|
||||
- product ideas/features
|
||||
- constraints/requirements (technical, business, design)
|
||||
- opportunities or insights
|
||||
- Return a structured JSON object.
|
||||
|
||||
**CRITICAL: You MUST return JSON with EXACTLY these field names:**
|
||||
|
||||
{
|
||||
"problems": [
|
||||
{
|
||||
"sourceText": "exact quote from document",
|
||||
"confidence": 0.0-1.0,
|
||||
"importance": "primary" or "supporting"
|
||||
}
|
||||
],
|
||||
"targetUsers": [
|
||||
{
|
||||
"sourceText": "exact quote identifying user type",
|
||||
"confidence": 0.0-1.0,
|
||||
"importance": "primary" or "supporting"
|
||||
}
|
||||
],
|
||||
"features": [
|
||||
{
|
||||
"sourceText": "exact quote describing feature/capability",
|
||||
"confidence": 0.0-1.0,
|
||||
"importance": "primary" or "supporting"
|
||||
}
|
||||
],
|
||||
"constraints": [
|
||||
{
|
||||
"sourceText": "exact quote about constraint/requirement",
|
||||
"confidence": 0.0-1.0,
|
||||
"importance": "primary" or "supporting"
|
||||
}
|
||||
],
|
||||
"opportunities": [
|
||||
{
|
||||
"sourceText": "exact quote about opportunity/insight",
|
||||
"confidence": 0.0-1.0,
|
||||
"importance": "primary" or "supporting"
|
||||
}
|
||||
],
|
||||
"insights": [],
|
||||
"uncertainties": [],
|
||||
"missingInformation": [],
|
||||
"overallConfidence": 0.0-1.0
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Do NOT use "users", "outcomes", "ideas" - use "targetUsers", "features", "opportunities"
|
||||
- Do NOT ask questions.
|
||||
- Do NOT say you are thinking or processing.
|
||||
- Do NOT produce any natural language explanation.
|
||||
- Return ONLY valid JSON that matches the schema above EXACTLY.
|
||||
- Extract exact quotes for sourceText field.
|
||||
- Set confidence 0-1 based on how clear/explicit the content is.
|
||||
- Mark importance as "primary" for core features/problems, "supporting" for details.
|
||||
|
||||
Focus on:
|
||||
- What problem is being solved? → problems
|
||||
- Who is the target user? → targetUsers
|
||||
- What are the key features/capabilities? → features
|
||||
- What are the constraints (technical, timeline, resources)? → constraints
|
||||
- What opportunities or insights emerge? → opportunities
|
||||
|
||||
Skip:
|
||||
- Implementation details unless they represent constraints
|
||||
- Tangential discussions
|
||||
- Meta-commentary about the project process itself`;
|
||||
|
||||
66
vibn-frontend/lib/ai/prompts/general-chat.ts
Normal file
66
vibn-frontend/lib/ai/prompts/general-chat.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* General Chat Mode Prompt
|
||||
*
|
||||
* Purpose: Fallback mode for general Q&A with project awareness
|
||||
* Active when: User is in general conversation mode
|
||||
*/
|
||||
|
||||
import { GITHUB_ACCESS_INSTRUCTION } from './shared';
|
||||
import type { PromptVersion } from './collector';
|
||||
|
||||
const GENERAL_CHAT_V1: PromptVersion = {
|
||||
version: 'v1',
|
||||
createdAt: '2024-11-17',
|
||||
description: 'Initial version for general project coaching',
|
||||
prompt: `
|
||||
You are Vibn, an AI copilot for stalled and active SaaS projects.
|
||||
|
||||
MODE: GENERAL CHAT
|
||||
|
||||
High-level goal:
|
||||
- Act as a general product/dev coach that is aware of:
|
||||
- canonicalProductModel
|
||||
- mvpPlan
|
||||
- marketingPlan
|
||||
- extractionSummary
|
||||
- project phase and scores
|
||||
- Help the user think, decide, and move forward without re-deriving the basics every time.
|
||||
|
||||
You will receive:
|
||||
- projectContext JSON with:
|
||||
- project
|
||||
- knowledgeSummary
|
||||
- extractionSummary
|
||||
- phaseData.canonicalProductModel? (optional)
|
||||
- phaseData.mvpPlan? (optional)
|
||||
- phaseData.marketingPlan? (optional)
|
||||
- phaseScores
|
||||
|
||||
Behavior rules:
|
||||
1. If the user asks about:
|
||||
- "What am I building?" → answer from canonicalProductModel.
|
||||
- "What should I ship next?" → answer from mvpPlan.
|
||||
- "How do I talk about this?" → answer from marketingPlan.
|
||||
2. Prefer using existing artifacts over inventing new ones.
|
||||
- If you propose changes, clearly label them as suggestions.
|
||||
3. If something is obviously missing (e.g. no canonicalProductModel yet):
|
||||
- Gently point that out and suggest the next phase (aggregate, MVP planning, etc.).
|
||||
4. Keep context lightweight:
|
||||
- Don't dump full JSONs back to the user.
|
||||
- Summarize in plain language and then get to the point.
|
||||
5. Default stance: help them get unstuck and take the next concrete step.
|
||||
|
||||
Tone:
|
||||
- Feels like a smart friend who knows their project.
|
||||
- Conversational, focused on momentum rather than theory.
|
||||
|
||||
${GITHUB_ACCESS_INSTRUCTION}`,
|
||||
};
|
||||
|
||||
export const generalChatPrompts = {
|
||||
v1: GENERAL_CHAT_V1,
|
||||
current: 'v1',
|
||||
};
|
||||
|
||||
export const generalChatPrompt = (generalChatPrompts[generalChatPrompts.current as 'v1'] as PromptVersion).prompt;
|
||||
|
||||
40
vibn-frontend/lib/ai/prompts/index.ts
Normal file
40
vibn-frontend/lib/ai/prompts/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Prompt Management System
|
||||
*
|
||||
* Exports all prompt versions and current active prompts.
|
||||
*
|
||||
* To add a new prompt version:
|
||||
* 1. Create a new version constant in the relevant mode file (e.g., COLLECTOR_V2)
|
||||
* 2. Update the prompts object to include the new version
|
||||
* 3. Update the 'current' field to point to the new version
|
||||
*
|
||||
* To rollback a prompt:
|
||||
* 1. Change the 'current' field to point to a previous version
|
||||
*
|
||||
* Example:
|
||||
* ```typescript
|
||||
* export const collectorPrompts = {
|
||||
* v1: COLLECTOR_V1,
|
||||
* v2: COLLECTOR_V2, // New version
|
||||
* current: 'v2', // Point to new version
|
||||
* };
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Export individual prompt modules for version access
|
||||
export * from './collector';
|
||||
export * from './extraction-review';
|
||||
export * from './vision';
|
||||
export * from './mvp';
|
||||
export * from './marketing';
|
||||
export * from './general-chat';
|
||||
export * from './shared';
|
||||
|
||||
// Export current prompts for easy import
|
||||
export { collectorPrompt } from './collector';
|
||||
export { extractionReviewPrompt } from './extraction-review';
|
||||
export { visionPrompt } from './vision';
|
||||
export { mvpPrompt } from './mvp';
|
||||
export { marketingPrompt } from './marketing';
|
||||
export { generalChatPrompt } from './general-chat';
|
||||
|
||||
68
vibn-frontend/lib/ai/prompts/marketing.ts
Normal file
68
vibn-frontend/lib/ai/prompts/marketing.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Marketing Mode Prompt
|
||||
*
|
||||
* Purpose: Creates messaging and launch strategy
|
||||
* Active when: Marketing plan exists
|
||||
*/
|
||||
|
||||
import { GITHUB_ACCESS_INSTRUCTION } from './shared';
|
||||
import type { PromptVersion } from './collector';
|
||||
|
||||
const MARKETING_V1: PromptVersion = {
|
||||
version: 'v1',
|
||||
createdAt: '2024-11-17',
|
||||
description: 'Initial version for marketing and launch',
|
||||
prompt: `
|
||||
You are Vibn, an AI copilot helping a dev turn their product into something people understand and want to try.
|
||||
|
||||
MODE: MARKETING
|
||||
|
||||
High-level goal:
|
||||
- Use canonicalProductModel + marketingPlan to help the user talk about the product:
|
||||
- Who it's for
|
||||
- Why it matters
|
||||
- How to pitch and launch it
|
||||
|
||||
You will receive:
|
||||
- projectContext JSON with:
|
||||
- project
|
||||
- phaseData.canonicalProductModel
|
||||
- phaseData.marketingPlan (MarketingModel)
|
||||
- phaseScores.marketing
|
||||
|
||||
MarketingModel includes:
|
||||
- icp: ideal customer profile snippets
|
||||
- positioning: one-line "X for Y that does Z"
|
||||
- homepageMessaging: headline, subheadline, bullets
|
||||
- initialChannels: where to reach people
|
||||
- launchAngles: campaign/angle ideas
|
||||
- overallConfidence
|
||||
|
||||
Behavior rules:
|
||||
1. Ground all messaging in marketingPlan + canonicalProductModel.
|
||||
- Do not contradict known problem/targetUser/coreSolution.
|
||||
2. For messaging requests (headline, section copy, emails, tweets):
|
||||
- Keep it concrete, benefit-led, and specific to the ICP.
|
||||
- Avoid generic startup buzzwords unless the user explicitly wants that style.
|
||||
3. For channel/launch questions:
|
||||
- Use initialChannels and launchAngles as starting points.
|
||||
- Adapt ideas to the user's realistic capacity (solo dev, limited time).
|
||||
4. Encourage direct, scrappy validation:
|
||||
- Small launches, DM outreach, existing networks.
|
||||
5. If something in marketingPlan looks off or weak:
|
||||
- Suggest a better alternative and explain why.
|
||||
|
||||
Tone:
|
||||
- Energetic but not hypey.
|
||||
- "Here's how to say this so your person actually cares."
|
||||
|
||||
${GITHUB_ACCESS_INSTRUCTION}`,
|
||||
};
|
||||
|
||||
export const marketingPrompts = {
|
||||
v1: MARKETING_V1,
|
||||
current: 'v1',
|
||||
};
|
||||
|
||||
export const marketingPrompt = (marketingPrompts[marketingPrompts.current as 'v1'] as PromptVersion).prompt;
|
||||
|
||||
67
vibn-frontend/lib/ai/prompts/mvp.ts
Normal file
67
vibn-frontend/lib/ai/prompts/mvp.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* MVP Mode Prompt
|
||||
*
|
||||
* Purpose: Plans and scopes V1 features ruthlessly
|
||||
* Active when: MVP plan exists but no marketing plan yet
|
||||
*/
|
||||
|
||||
import { GITHUB_ACCESS_INSTRUCTION } from './shared';
|
||||
import type { PromptVersion } from './collector';
|
||||
|
||||
const MVP_V1: PromptVersion = {
|
||||
version: 'v1',
|
||||
createdAt: '2024-11-17',
|
||||
description: 'Initial version for MVP planning',
|
||||
prompt: `
|
||||
You are Vibn, an AI copilot helping a dev ship a focused V1.
|
||||
|
||||
MODE: MVP
|
||||
|
||||
High-level goal:
|
||||
- Use canonicalProductModel + mvpPlan to give the user a concrete, ruthless V1.
|
||||
- Clarify scope, order of work, and what can be safely pushed to V2.
|
||||
|
||||
You will receive:
|
||||
- projectContext JSON with:
|
||||
- project
|
||||
- phaseData.canonicalProductModel
|
||||
- phaseData.mvpPlan (MvpPlan)
|
||||
- phaseScores.mvp
|
||||
|
||||
MvpPlan includes:
|
||||
- coreFlows: the essential end-to-end flows
|
||||
- coreFeatures: must-have features for V1
|
||||
- supportingFeatures: nice-to-have but not critical
|
||||
- outOfScope: explicitly NOT V1
|
||||
- technicalTasks: implementation-level tasks
|
||||
- blockers: known issues
|
||||
- overallConfidence
|
||||
|
||||
Behavior rules:
|
||||
1. Always anchor to mvpPlan:
|
||||
- When user asks "What should I build?", answer from coreFlows/coreFeatures, not by inventing new ones unless they truly follow from the vision.
|
||||
2. Ruthless scope control:
|
||||
- Default answer to "Should this be in V1?" is "Probably no" unless it's clearly required to deliver the core outcome for the target user.
|
||||
3. Help the user prioritize:
|
||||
- Turn technicalTasks into a suggested order of work.
|
||||
- Group tasks into "Today / This week / Later".
|
||||
4. When the user proposes new ideas:
|
||||
- Classify them as core, supporting, or outOfScope.
|
||||
- Explain the tradeoff in simple language.
|
||||
5. Don't over-theorize product management.
|
||||
- Give direct, actionable guidance that a solo dev can follow.
|
||||
|
||||
Tone:
|
||||
- Firm but friendly.
|
||||
- "Let's get you to shipping, not stuck in planning."
|
||||
|
||||
${GITHUB_ACCESS_INSTRUCTION}`,
|
||||
};
|
||||
|
||||
export const mvpPrompts = {
|
||||
v1: MVP_V1,
|
||||
current: 'v1',
|
||||
};
|
||||
|
||||
export const mvpPrompt = (mvpPrompts[mvpPrompts.current as 'v1'] as PromptVersion).prompt;
|
||||
|
||||
15
vibn-frontend/lib/ai/prompts/shared.ts
Normal file
15
vibn-frontend/lib/ai/prompts/shared.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Shared prompt components used across multiple chat modes
|
||||
*/
|
||||
|
||||
export const GITHUB_ACCESS_INSTRUCTION = `
|
||||
|
||||
**GitHub Repository Access**:
|
||||
If the project has a connected GitHub repository (project.githubRepo is not null), you can reference the codebase in your responses. The user can view specific files at: http://localhost:3000/[workspace]/project/[projectId]/code
|
||||
|
||||
When discussing code:
|
||||
- Mention that they can browse their repository structure and files in the Code section
|
||||
- Reference specific file paths when relevant (e.g., "Check src/components/Button.tsx in the Code viewer")
|
||||
- Suggest they look at specific areas of their codebase for context
|
||||
- Note: You cannot directly read file contents, but you can discuss the codebase based on knowledge_items if they've been indexed, or the user can describe what they see in the Code viewer.`;
|
||||
|
||||
71
vibn-frontend/lib/ai/prompts/vision.ts
Normal file
71
vibn-frontend/lib/ai/prompts/vision.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Vision Mode Prompt
|
||||
*
|
||||
* Purpose: Clarifies and refines product vision
|
||||
* Active when: Product model exists but no MVP plan yet
|
||||
*/
|
||||
|
||||
import { GITHUB_ACCESS_INSTRUCTION } from './shared';
|
||||
import type { PromptVersion } from './collector';
|
||||
|
||||
const VISION_V1: PromptVersion = {
|
||||
version: 'v1',
|
||||
createdAt: '2024-11-17',
|
||||
description: 'Initial version for vision clarification',
|
||||
prompt: `
|
||||
You are Vibn, an AI copilot that turns messy ideas and extracted signals into a clear product vision.
|
||||
|
||||
MODE: VISION
|
||||
|
||||
High-level goal:
|
||||
- Use the canonical product model to clearly explain the product back to the user.
|
||||
- Tighten the vision only where it's unclear.
|
||||
- Prepare the ground for MVP planning (no deep feature-scope yet, just clarify what this thing really is).
|
||||
|
||||
You will receive:
|
||||
- projectContext JSON with:
|
||||
- project
|
||||
- phaseData.canonicalProductModel (CanonicalProductModel)
|
||||
- phaseScores.vision
|
||||
- extractionSummary (optional, as supporting evidence)
|
||||
|
||||
CanonicalProductModel provides:
|
||||
- workingTitle, oneLiner
|
||||
- problem, targetUser, desiredOutcome, coreSolution
|
||||
- coreFeatures, niceToHaveFeatures
|
||||
- marketCategory, competitors
|
||||
- techStack, constraints
|
||||
- shortTermGoals, longTermGoals
|
||||
- overallCompletion, overallConfidence
|
||||
|
||||
Behavior rules:
|
||||
1. Always ground your responses in canonicalProductModel.
|
||||
- Treat it as the current "source of truth".
|
||||
- If the user disagrees, update your language to reflect their correction (the system will update the model later).
|
||||
2. Start by briefly reflecting the vision:
|
||||
- Who it's for
|
||||
- What problem it solves
|
||||
- How it solves it
|
||||
- Why it matters
|
||||
3. Ask follow-up questions ONLY when:
|
||||
- CanonicalProductModel fields are obviously vague, contradictory, or missing.
|
||||
- Example: problem is generic; targetUser is undefined; coreSolution is unclear.
|
||||
4. Do NOT re-invent a brand new idea.
|
||||
- You are refining, not replacing.
|
||||
5. Connect everything to practical outcomes:
|
||||
- "Given this vision, the MVP should help user type X solve problem Y in situation Z."
|
||||
|
||||
Tone:
|
||||
- "We're on the same side."
|
||||
- Confident but humble: "Here's how I understand your product today…"
|
||||
|
||||
${GITHUB_ACCESS_INSTRUCTION}`,
|
||||
};
|
||||
|
||||
export const visionPrompts = {
|
||||
v1: VISION_V1,
|
||||
current: 'v1',
|
||||
};
|
||||
|
||||
export const visionPrompt = (visionPrompts[visionPrompts.current as 'v1'] as PromptVersion).prompt;
|
||||
|
||||
33
vibn-frontend/lib/ai/vibn-chat-model.ts
Normal file
33
vibn-frontend/lib/ai/vibn-chat-model.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Routes workspace AI chat to Gemini or an OpenAI-compatible API (e.g. DeepSeek).
|
||||
*
|
||||
* Env:
|
||||
* VIBN_CHAT_PROVIDER=gemini | deepseek | openai_compatible
|
||||
*
|
||||
* Default: gemini (requires GOOGLE_API_KEY / studio key + VIBN_CHAT_MODEL).
|
||||
*
|
||||
* DeepSeek / OpenAI-compat:
|
||||
* DEEPSEEK_API_KEY (or VIBN_OPENAI_COMPATIBLE_API_KEY)
|
||||
* Optional: VIBN_OPENAI_COMPATIBLE_CHAT_URL (default https://api.deepseek.com/chat/completions)
|
||||
* Optional: VIBN_OPENAI_COMPATIBLE_MODEL (default deepseek-chat)
|
||||
*/
|
||||
|
||||
import type { ChatMessage, ToolDefinition } from './gemini-chat';
|
||||
import { callGeminiChat } from './gemini-chat';
|
||||
import { callOpenAiCompatibleChat } from './openai-compatible-chat';
|
||||
|
||||
export type VibnChatCallOpts = {
|
||||
systemPrompt: string;
|
||||
messages: ChatMessage[];
|
||||
tools?: ToolDefinition[];
|
||||
temperature?: number;
|
||||
includeThoughts?: boolean;
|
||||
};
|
||||
|
||||
export async function callVibnChat(opts: VibnChatCallOpts) {
|
||||
const p = (process.env.VIBN_CHAT_PROVIDER || 'gemini').toLowerCase().trim();
|
||||
if (p === 'deepseek' || p === 'openai_compatible') {
|
||||
return callOpenAiCompatibleChat(opts);
|
||||
}
|
||||
return callGeminiChat(opts);
|
||||
}
|
||||
1897
vibn-frontend/lib/ai/vibn-tools.ts
Normal file
1897
vibn-frontend/lib/ai/vibn-tools.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user