diff --git a/frontend/.gitignore b/frontend/.gitignore index f541fa8387..0366724a82 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -14,4 +14,5 @@ static/tsdocs/ static/ui_builder/ ui_builder.tar.gz ui_builder_serve/ -src/lib/components/copilot/chat/flow/__tests__/eval/results/ +src/lib/components/copilot/chat/__tests__/flow/results/ +src/lib/components/copilot/chat/__tests__/app/results/ diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts new file mode 100644 index 0000000000..5183377caf --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appChat.eval.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest' +import { runVariantComparison, writeAppComparisonResults } from './appEvalRunner' +import { BASELINE_VARIANT, STREAMLINED_VARIANT } from './variants' +import { loadAppFixtureForEval } from './appFixtureLoader' +// @ts-ignore - Node.js path +import { dirname, join } from 'path' +// @ts-ignore - Node.js url +import { fileURLToPath } from 'url' + +// Get API key from environment - tests will be skipped if not set +// @ts-ignore +const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY + +// Skip all tests if no API key is provided +const describeWithApiKey = OPENROUTER_API_KEY ? describe : describe.skip + +// Get __dirname equivalent for ES modules +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const MODELS = ['google/gemini-2.5-flash', 'anthropic/claude-haiku-4.5', 'openai/gpt-4o'] +const VARIANTS = [ + ...MODELS.map((model) => ({ + ...BASELINE_VARIANT, + model, + name: `baseline-${model.replace('/', '-')}` + })), + ...MODELS.map((model) => ({ + ...STREAMLINED_VARIANT, + model, + name: `streamlined-${model.replace('/', '-')}` + })) +] + +describeWithApiKey('App Chat LLM Evaluation', () => { + const TEST_TIMEOUT = 120_000 + if (!OPENROUTER_API_KEY) { + console.warn('OPENROUTER_API_KEY is not set, skipping tests') + } + + it( + 'test1: creates a simple counter app', + async () => { + const USER_PROMPT = `Create a counter app with increment/decrement buttons` + const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) + // Write results to files + const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) + console.log(`\nResults written to: ${summaryPath}`) + console.log(`App files: ${appPaths.join(', ')}`) + + expect(true).toBe(true) + }, + TEST_TIMEOUT + ) + + it( + 'test2: modifies existing counter app to add reset button', + async () => { + // Load initial app from fixture folder + const { initialFrontend, initialBackend } = await loadAppFixtureForEval( + join(__dirname, 'initial', 'test1_counter_app') + ) + + const USER_PROMPT = `Add a reset button that sets the counter back to 0` + const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { + initialFrontend, + initialBackend + }) + // Write results to files + const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) + console.log(`\nResults written to: ${summaryPath}`) + console.log(`App files: ${appPaths.join(', ')}`) + + expect(true).toBe(true) + }, + TEST_TIMEOUT + ) + + // ==================== Shopping Cart Tests ==================== + + it( + 'test3: shopping cart - add quantity selector', + async () => { + const { initialFrontend, initialBackend } = await loadAppFixtureForEval( + join(__dirname, 'initial', 'shopping_cart') + ) + + const USER_PROMPT = `Add a quantity selector (+ and - buttons) to each cart item so users can adjust quantities without removing and re-adding items` + const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { + initialFrontend, + initialBackend + }) + + const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) + console.log(`\nResults written to: ${summaryPath}`) + console.log(`App files: ${appPaths.join(', ')}`) + + expect(true).toBe(true) + }, + TEST_TIMEOUT + ) + + it( + 'test4: shopping cart - add discount code', + async () => { + const { initialFrontend, initialBackend } = await loadAppFixtureForEval( + join(__dirname, 'initial', 'shopping_cart') + ) + + const USER_PROMPT = `Add a discount code input field in the cart. When the code "SAVE10" is entered, apply a 10% discount to the total` + const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { + initialFrontend, + initialBackend + }) + + const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) + console.log(`\nResults written to: ${summaryPath}`) + console.log(`App files: ${appPaths.join(', ')}`) + + expect(true).toBe(true) + }, + TEST_TIMEOUT + ) + + // ==================== File Manager Tests ==================== + + it( + 'test5: file manager - add search bar', + async () => { + const { initialFrontend, initialBackend } = await loadAppFixtureForEval( + join(__dirname, 'initial', 'file_manager') + ) + + const USER_PROMPT = `Add a search bar in the toolbar that filters files and folders by name as the user types` + const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { + initialFrontend, + initialBackend + }) + + const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) + console.log(`\nResults written to: ${summaryPath}`) + console.log(`App files: ${appPaths.join(', ')}`) + + expect(true).toBe(true) + }, + TEST_TIMEOUT + ) + + it( + 'test6: file manager - show file details', + async () => { + const { initialFrontend, initialBackend } = await loadAppFixtureForEval( + join(__dirname, 'initial', 'file_manager') + ) + + const USER_PROMPT = `Show file size (formatted as KB/MB) and modified date in the file list for each item` + const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { + initialFrontend, + initialBackend + }) + + const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) + console.log(`\nResults written to: ${summaryPath}`) + console.log(`App files: ${appPaths.join(', ')}`) + + expect(true).toBe(true) + }, + TEST_TIMEOUT + ) + + it( + 'test7: file manager - add select all checkbox', + async () => { + const { initialFrontend, initialBackend } = await loadAppFixtureForEval( + join(__dirname, 'initial', 'file_manager') + ) + + const USER_PROMPT = `Add a "Select All" checkbox in the file list header and individual checkboxes for each file. Add a "Delete Selected" button that appears when items are selected` + const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!, { + initialFrontend, + initialBackend + }) + + const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) + console.log(`\nResults written to: ${summaryPath}`) + console.log(`App files: ${appPaths.join(', ')}`) + + expect(true).toBe(true) + }, + TEST_TIMEOUT + ) + + // ==================== From-Scratch Creation Tests ==================== + + it( + 'test8: create quiz app from scratch', + async () => { + const USER_PROMPT = `Create a multiple choice quiz app with 5 questions about general knowledge. Show one question at a time with 4 answer options. Track the score and show results at the end with percentage correct.` + const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) + + const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) + console.log(`\nResults written to: ${summaryPath}`) + console.log(`App files: ${appPaths.join(', ')}`) + + expect(true).toBe(true) + }, + TEST_TIMEOUT + ) + + it( + 'test9: create recipe book from scratch', + async () => { + const USER_PROMPT = `Create a recipe book app where users can add recipes with a name, ingredients list, and instructions. Include a search bar to filter recipes by name and the ability to delete recipes.` + const results = await runVariantComparison(USER_PROMPT, VARIANTS, OPENROUTER_API_KEY!) + + const { summaryPath, appPaths } = await writeAppComparisonResults(USER_PROMPT, results) + console.log(`\nResults written to: ${summaryPath}`) + console.log(`App files: ${appPaths.join(', ')}`) + + expect(true).toBe(true) + }, + TEST_TIMEOUT + ) +}) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts new file mode 100644 index 0000000000..456299c142 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalComparison.ts @@ -0,0 +1,166 @@ +import OpenAI from 'openai' +import type { AppFiles, BackendRunnable } from '../../app/core' +import { BASE_EVALUATOR_RESPONSE_FORMAT } from '../shared' +import type { EvaluationResult } from '../shared' + +/** + * Expected app structure for evaluation. + */ +export interface ExpectedApp { + frontend: Record + backend: Record +} + +/** + * Initial app state for evaluation context. + */ +export interface InitialApp { + frontend: Record + backend: Record +} + +/** + * System prompt for evaluating app generation without a reference expected app. + * Evaluates based on user request fulfillment and appropriate modifications to initial state. + */ +const APP_GENERATION_EVALUATOR_SYSTEM_PROMPT = `You are an expert evaluator for Windmill Raw App definitions. Your task is to evaluate a generated app based on: +1. The original user request/prompt +2. The initial app state (if any) - this is what the app looked like before the AI made changes + +## Windmill Raw App Context +- Raw Apps consist of frontend files and backend runnables +- Frontend files are TypeScript/JavaScript files bundled with esbuild (entrypoint: index.tsx) +- Backend runnables can be: inline scripts (TypeScript/Python), workspace scripts, workspace flows, or hub scripts +- Frontend calls backend using \`await backend.(args...)\` +- Each backend runnable has a key (identifier), name (description), type, and configuration + +## Backend Runnable Types +- **inline**: Custom code with \`inlineScript.language\` and \`inlineScript.content\` +- **script**: Workspace script reference with \`path\` +- **flow**: Workspace flow reference with \`path\` +- **hubscript**: Hub script reference with \`path\` + +## Evaluation Criteria +1. **User Request Fulfillment**: Does the generated app address ALL requirements from the user's original prompt? + - Are all requested features implemented? + - Does the frontend UI match the requirements? + - Are the correct backend runnables created? +2. **Appropriate Modifications** (if initial app was provided): + - Were the changes made relevant to the user's request? + - Was existing functionality preserved where appropriate? + - Were only necessary changes made (no unnecessary removals or additions)? +3. **Frontend Structure**: Are the frontend files correctly organized and implemented? + - Is the code valid TypeScript/JavaScript? + - Are components properly structured? + - Are backend calls correctly made? +4. **Backend Structure**: Are the backend runnables correctly configured? + - Do inline scripts have proper main functions? + - Are types and paths correct for non-inline runnables? +5. **Integration**: Does the frontend correctly call the backend? + - Are the runnable keys correctly referenced? + - Are arguments passed correctly? +6. **Code Quality**: Is the code functionally correct and well-structured? + +## Important Notes +- Focus on whether the user's request was fulfilled, not on stylistic preferences +- If an initial app was provided, evaluate the appropriateness of the changes made +- For new apps (no initial state), evaluate completeness and correctness +- Extra helper functions or slightly different approaches can still score high if they accomplish the goal + +${BASE_EVALUATOR_RESPONSE_FORMAT}` + +/** + * Evaluates how well a generated app fulfills the user's request, considering any initial app state. + * This evaluator does not require an expected reference app - it evaluates based on the request alone. + * + * @param userPrompt The original user request + * @param generatedApp The app generated by the AI + * @param initialApp Optional initial app state (what the app looked like before AI changes) + * @returns Evaluation result with score, statement, and missing requirements + */ +export async function evaluateAppGeneration( + userPrompt: string, + generatedApp: AppFiles, + initialApp?: InitialApp +): Promise { + // @ts-ignore + const apiKey = process.env.OPENROUTER_API_KEY + + const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey }) + + let userMessage = `## User's Original Request +${userPrompt} + +` + + if (initialApp) { + userMessage += `## Initial App State (before AI modifications) +\`\`\`json +${JSON.stringify(initialApp, null, 2)} +\`\`\` + +` + } else { + userMessage += `## Initial App State +No initial app was provided - this is a new app created from scratch. + +` + } + + userMessage += `## Generated App +\`\`\`json +${JSON.stringify(generatedApp, null, 2)} +\`\`\` + +Please evaluate how well the generated app: +1. Fulfills ALL requirements from the user's original request +2. ${initialApp ? 'Makes appropriate modifications to the initial app state' : 'Implements a complete and correct new app'}` + + try { + const response = await client.chat.completions.create({ + model: 'anthropic/claude-sonnet-4.5', + messages: [ + { role: 'system', content: APP_GENERATION_EVALUATOR_SYSTEM_PROMPT }, + { role: 'user', content: userMessage } + ], + temperature: 0 + }) + + const content = response.choices[0]?.message?.content + if (!content) { + return { + success: false, + resemblanceScore: 0, + statement: 'No response from evaluator', + error: 'Empty response from LLM' + } + } + + // Parse JSON response - handle potential markdown code blocks + let jsonContent = content.trim() + if (jsonContent.startsWith('```')) { + jsonContent = jsonContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '') + } + + const parsed = JSON.parse(jsonContent) as { + resemblanceScore: number + statement: string + missingRequirements?: string[] + } + + return { + success: true, + resemblanceScore: Math.max(0, Math.min(100, Math.round(parsed.resemblanceScore))), + statement: parsed.statement, + missingRequirements: parsed.missingRequirements ?? [] + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + return { + success: false, + resemblanceScore: 0, + statement: 'Evaluation failed', + error: errorMessage + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalHelpers.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalHelpers.ts new file mode 100644 index 0000000000..ef7bb4248f --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalHelpers.ts @@ -0,0 +1,119 @@ +import type { + AppAIChatHelpers, + AppFiles, + BackendRunnable, + LintResult, + SelectedContext +} from '../../app/core' + +/** + * Creates an empty lint result (no errors or warnings). + */ +function createEmptyLintResult(): LintResult { + return { + errorCount: 0, + warningCount: 0, + errors: { frontend: {}, backend: {} }, + warnings: { frontend: {}, backend: {} } + } +} + +/** + * Creates mock AppAIChatHelpers for eval testing. + * Tracks app files state in memory and allows tool functions to modify it. + */ +export function createAppEvalHelpers( + initialFrontend: Record = {}, + initialBackend: Record = {} +) { + // In-memory state + let frontend: Record = { ...initialFrontend } + let backend: Record = { ...initialBackend } + let snapshotId = 0 + const snapshots: Map; backend: Record }> = new Map() + + const helpers: AppAIChatHelpers = { + // Frontend file operations + listFrontendFiles: () => Object.keys(frontend), + + getFrontendFile: (path: string) => frontend[path], + + getFrontendFiles: () => ({ ...frontend }), + + setFrontendFile: (path: string, content: string) => { + frontend[path] = content + // Return mock lint result - in real usage this would validate the file + return createEmptyLintResult() + }, + + deleteFrontendFile: (path: string) => { + delete frontend[path] + }, + + // Backend runnable operations + listBackendRunnables: () => { + return Object.entries(backend).map(([key, runnable]) => ({ + key, + name: runnable.name + })) + }, + + getBackendRunnable: (key: string) => backend[key], + + getBackendRunnables: () => ({ ...backend }), + + setBackendRunnable: async (key: string, runnable: BackendRunnable) => { + backend[key] = runnable + // Return mock lint result - in real usage this would validate the runnable + return createEmptyLintResult() + }, + + deleteBackendRunnable: (key: string) => { + delete backend[key] + }, + + // Combined view + getFiles: (): AppFiles => ({ + frontend: { ...frontend }, + backend: { ...backend } + }), + + getSelectedContext: (): SelectedContext => ({ + type: 'none' + }), + + // Snapshot management + snapshot: () => { + const id = ++snapshotId + snapshots.set(id, { + frontend: { ...frontend }, + backend: { ...backend } + }) + return id + }, + + revertToSnapshot: (id: number) => { + const snap = snapshots.get(id) + if (snap) { + frontend = { ...snap.frontend } + backend = { ...snap.backend } + } + }, + + // Linting + lint: () => { + // Return mock lint result - no actual linting in eval + return createEmptyLintResult() + } + } + + return { + helpers, + getFiles: (): AppFiles => ({ + frontend: { ...frontend }, + backend: { ...backend } + }), + getFrontend: () => ({ ...frontend }), + getBackend: () => ({ ...backend }) + } +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts new file mode 100644 index 0000000000..bb0de14bc8 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appEvalRunner.ts @@ -0,0 +1,168 @@ +import type { AppFiles, BackendRunnable, AppAIChatHelpers } from '../../app/core' +import { getAppTools, prepareAppSystemMessage, prepareAppUserMessage } from '../../app/core' +import { createAppEvalHelpers } from './appEvalHelpers' +import { evaluateAppGeneration, type InitialApp } from './appEvalComparison' +import { + runEval, + resolveSystemPrompt, + resolveTools, + resolveModel, + type VariantConfig, + type BaseEvalResult, + type EvaluationResult, + type Tool, + type VariantDefaults +} from '../shared' +import { writeAppComparisonResultsToFolders } from './appResultsWriter' + +// Re-export for convenience +export type { InitialApp } from './appEvalComparison' + +/** + * App-specific evaluation result. + */ +export interface AppEvalResult extends BaseEvalResult { + /** Alias for output to maintain API compatibility */ + files: AppFiles +} + +/** + * Options for running an app evaluation. + */ +export interface AppEvalOptions { + initialFrontend?: Record + initialBackend?: Record + model?: string + customSystemPrompt?: string + maxIterations?: number + variant?: VariantConfig + /** Whether to evaluate the generated app with LLM. Default: true. Set to false to skip evaluation. */ + evaluateWithLLM?: boolean +} + +/** + * App-specific variant defaults. + */ +const appDefaults: VariantDefaults = { + prepareSystemMessage: prepareAppSystemMessage, + tools: getAppTools() as Tool[] +} + +/** + * Runs an app chat evaluation with real OpenAI API calls. + * Executes tool calls using the actual app tools from core.ts or variant-configured tools. + */ +export async function runAppEval( + userPrompt: string, + openaiApiKey: string, + options?: AppEvalOptions +): Promise { + const { helpers, getFiles } = createAppEvalHelpers( + options?.initialFrontend ?? {}, + options?.initialBackend ?? {} + ) + + // Resolve variant configuration + const variantName = options?.variant?.name ?? 'baseline' + const systemMessage = resolveSystemPrompt( + options?.variant, + appDefaults, + options?.customSystemPrompt + ) + const { toolDefs, tools } = resolveTools(options?.variant, appDefaults) + const model = resolveModel(options?.variant, options?.model) + + // Build user message + const userMessage = prepareAppUserMessage( + userPrompt, + helpers.getFiles(), + helpers.getSelectedContext() + ) + + // Run the base evaluation + const rawResult = await runEval({ + userPrompt, + systemMessage, + userMessage, + toolDefs, + tools, + helpers, + apiKey: openaiApiKey, + getOutput: getFiles, + options: { + maxIterations: options?.maxIterations, + model, + workspace: 'test-workspace' + } + }) + + // Run LLM evaluation unless explicitly disabled + let evaluationResult: EvaluationResult | undefined + if (options?.evaluateWithLLM !== false) { + const generatedApp = getFiles() + const initialApp: InitialApp | undefined = + options?.initialFrontend || options?.initialBackend + ? { + frontend: options.initialFrontend ?? {}, + backend: options.initialBackend ?? {} + } + : undefined + evaluationResult = await evaluateAppGeneration(userPrompt, generatedApp, initialApp) + } + + return { + ...rawResult, + variantName, + files: rawResult.output, + evaluationResult + } +} + +/** + * Runs the same prompt against multiple variants sequentially for comparison. + * Returns results in the same order as the input variants. + */ +export async function runVariantComparison( + userPrompt: string, + variants: VariantConfig[], + openaiApiKey: string, + baseOptions?: Omit +): Promise { + const results: AppEvalResult[] = await Promise.all( + variants.map(async (variant) => { + return await runAppEval(userPrompt, openaiApiKey, { + ...baseOptions, + variant + }) + }) + ) + return results +} + +/** + * Writes app comparison results to a folder-based structure. + * Each variant gets its own folder with frontend/, backend/, and details.json. + */ +export async function writeAppComparisonResults( + userPrompt: string, + results: AppEvalResult[], + outputDir?: string +): Promise<{ summaryPath: string; appPaths: string[] }> { + // @ts-ignore + const { dirname, join } = await import('path') + // @ts-ignore + const { fileURLToPath } = await import('url') + + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + + const resultsDir = outputDir ?? join(__dirname, 'results') + + const result = await writeAppComparisonResultsToFolders({ + userPrompt, + results, + outputDir: resultsDir + }) + + return { summaryPath: result.summaryPath, appPaths: result.variantPaths } +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appFixtureLoader.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appFixtureLoader.ts new file mode 100644 index 0000000000..8d3be427e4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appFixtureLoader.ts @@ -0,0 +1,170 @@ +import type { AppFiles, BackendRunnable, InlineScript } from '../../app/core' + +/** + * Backend runnable metadata stored in meta.json files. + */ +interface BackendMeta { + name: string + language: 'bun' | 'python3' +} + +/** + * Recursively reads all files in a directory and returns them as a record. + * File paths are relative to the base directory with a leading '/'. + */ +async function readFilesRecursively( + dir: string, + basePath: string = '' +): Promise> { + // @ts-ignore - Node.js fs/promises + const { readdir, readFile } = await import('fs/promises') + // @ts-ignore - Node.js path + const { join } = await import('path') + + const result: Record = {} + const entries = await readdir(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = join(dir, entry.name) + const relativePath = basePath ? `${basePath}/${entry.name}` : `/${entry.name}` + + if (entry.isDirectory()) { + const subFiles = await readFilesRecursively(fullPath, relativePath) + Object.assign(result, subFiles) + } else { + const content = await readFile(fullPath, 'utf-8') + result[relativePath] = content + } + } + + return result +} + +/** + * Loads frontend files from a directory. + * All files are read recursively and paths become keys with leading '/'. + */ +async function loadFrontend(frontendPath: string): Promise> { + // @ts-ignore - Node.js fs/promises + const { access } = await import('fs/promises') + + try { + await access(frontendPath) + } catch { + // Directory doesn't exist, return empty + return {} + } + + return readFilesRecursively(frontendPath) +} + +/** + * Loads backend runnables from a directory. + * Each subdirectory is a runnable with: + * - main.ts or main.py: The code content + * - meta.json: Metadata { name, language } + */ +async function loadBackend(backendPath: string): Promise> { + // @ts-ignore - Node.js fs/promises + const { readdir, readFile, access } = await import('fs/promises') + // @ts-ignore - Node.js path + const { join } = await import('path') + + try { + await access(backendPath) + } catch { + // Directory doesn't exist, return empty + return {} + } + + const result: Record = {} + const entries = await readdir(backendPath, { withFileTypes: true }) + + for (const entry of entries) { + if (!entry.isDirectory()) continue + + const runnableKey = entry.name + const runnablePath = join(backendPath, entry.name) + + // Read meta.json + const metaPath = join(runnablePath, 'meta.json') + let meta: BackendMeta + try { + const metaContent = await readFile(metaPath, 'utf-8') + meta = JSON.parse(metaContent) + } catch { + console.warn(`Missing or invalid meta.json for runnable '${runnableKey}', skipping`) + continue + } + + // Find and read the main file (main.ts or main.py) + const runnableFiles = await readdir(runnablePath) + const mainFile = runnableFiles.find((f) => f === 'main.ts' || f === 'main.py') + + if (!mainFile) { + console.warn(`No main.ts or main.py found for runnable '${runnableKey}', skipping`) + continue + } + + const content = await readFile(join(runnablePath, mainFile), 'utf-8') + + const inlineScript: InlineScript = { + language: meta.language, + content + } + + result[runnableKey] = { + name: meta.name, + type: 'inline', + inlineScript + } + } + + return result +} + +/** + * Loads an app fixture from a directory structure. + * + * Expected structure: + * ``` + * fixturePath/ + * ├── frontend/ + * │ └── index.tsx # → frontend["/index.tsx"] + * │ └── components/ + * │ └── Button.tsx # → frontend["/components/Button.tsx"] + * └── backend/ + * └── incrementCounter/ + * ├── main.ts # The code content + * └── meta.json # { "name": "...", "language": "bun" } + * ``` + * + * @param fixturePath - Path to the fixture directory + * @returns AppFiles object with frontend and backend + */ +export async function loadAppFixture(fixturePath: string): Promise { + // @ts-ignore - Node.js path + const { join } = await import('path') + + const frontend = await loadFrontend(join(fixturePath, 'frontend')) + const backend = await loadBackend(join(fixturePath, 'backend')) + + return { frontend, backend } +} + +/** + * Loads an app fixture and returns the separate frontend and backend objects. + * Convenience function for use with runAppEval options. + */ +export async function loadAppFixtureForEval( + fixturePath: string +): Promise<{ + initialFrontend: Record + initialBackend: Record +}> { + const { frontend, backend } = await loadAppFixture(fixturePath) + return { + initialFrontend: frontend, + initialBackend: backend + } +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/appResultsWriter.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/appResultsWriter.ts new file mode 100644 index 0000000000..a9b3475e96 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/appResultsWriter.ts @@ -0,0 +1,247 @@ +import type { AppFiles, BackendRunnable } from '../../app/core' +import type { AppEvalResult } from './appEvalRunner' +import { generateTimestamp } from '../shared' + +/** + * Writes frontend files to a folder, preserving directory structure. + * File paths like "/components/Button.tsx" become "frontend/components/Button.tsx" + */ +async function writeFrontendFiles( + frontend: Record, + frontendPath: string +): Promise { + // @ts-ignore - Node.js fs/promises + const { writeFile, mkdir } = await import('fs/promises') + // @ts-ignore - Node.js path + const { join, dirname } = await import('path') + + for (const [filePath, content] of Object.entries(frontend)) { + // Remove leading slash and join with frontend path + const relativePath = filePath.startsWith('/') ? filePath.slice(1) : filePath + const fullPath = join(frontendPath, relativePath) + + // Ensure parent directory exists + await mkdir(dirname(fullPath), { recursive: true }) + + await writeFile(fullPath, content) + } +} + +/** + * Writes backend runnables to a folder structure. + * Each runnable becomes a folder with main.ts/main.py and meta.json + */ +async function writeBackendRunnables( + backend: Record, + backendPath: string +): Promise { + // @ts-ignore - Node.js fs/promises + const { writeFile, mkdir } = await import('fs/promises') + // @ts-ignore - Node.js path + const { join } = await import('path') + + for (const [key, runnable] of Object.entries(backend)) { + const runnablePath = join(backendPath, key) + await mkdir(runnablePath, { recursive: true }) + + // Write meta.json + const meta: { name: string; language?: string; type?: string; path?: string } = { + name: runnable.name + } + + if (runnable.type === 'inline' && runnable.inlineScript) { + meta.language = runnable.inlineScript.language + + // Write main file + const extension = runnable.inlineScript.language === 'python3' ? 'py' : 'ts' + const mainPath = join(runnablePath, `main.${extension}`) + await writeFile(mainPath, runnable.inlineScript.content) + } else { + // For non-inline runnables, store type and path in meta + meta.type = runnable.type + if (runnable.path) { + meta.path = runnable.path + } + } + + const metaPath = join(runnablePath, 'meta.json') + await writeFile(metaPath, JSON.stringify(meta, null, '\t')) + } +} + +/** + * Writes app files (frontend + backend) to a folder structure. + */ +async function writeAppToFolder(appFiles: AppFiles, folderPath: string): Promise { + // @ts-ignore - Node.js path + const { join } = await import('path') + + if (Object.keys(appFiles.frontend).length > 0) { + await writeFrontendFiles(appFiles.frontend, join(folderPath, 'frontend')) + } + + if (Object.keys(appFiles.backend).length > 0) { + await writeBackendRunnables(appFiles.backend, join(folderPath, 'backend')) + } +} + +/** + * Parameters for writing app comparison results. + */ +export interface WriteAppResultsParams { + userPrompt: string + results: AppEvalResult[] + outputDir: string +} + +/** + * Writes app comparison results to a folder-based structure. + * + * Creates: + * ``` + * results/{timestamp}/ + * ├── summary.md + * └── {variant_name}/ + * ├── details.json # Metadata (toolsCalled, evaluationResult, etc.) + * ├── frontend/ # Frontend files + * │ └── index.tsx + * └── backend/ # Backend runnables + * └── myFunction/ + * ├── main.ts + * └── meta.json + * ``` + */ +export async function writeAppComparisonResultsToFolders( + params: WriteAppResultsParams +): Promise<{ summaryPath: string; variantPaths: string[] }> { + // @ts-ignore - Node.js fs/promises + const { writeFile, mkdir } = await import('fs/promises') + // @ts-ignore - Node.js path + const { join } = await import('path') + + const { userPrompt, results, outputDir } = params + const timestamp = generateTimestamp() + + // Ensure results directory exists + await mkdir(outputDir, { recursive: true }) + const resultFolder = join(outputDir, timestamp) + await mkdir(resultFolder, { recursive: true }) + + // Check if any results have evaluation data + const hasEvaluation = results.some((r) => r.evaluationResult) + + // Build summary markdown + const summaryLines: string[] = [ + `# App Eval Results - ${timestamp}`, + '', + '## User Prompt', + '```', + userPrompt.trim(), + '```', + '', + '## Results', + '' + ] + + // Add results table header based on whether evaluation data exists + if (hasEvaluation) { + summaryLines.push( + '| Variant | Success | Total Tokens | Tool Calls | Iterations | Resemblance Score |' + ) + summaryLines.push( + '|---------|---------|--------------|------------|------------|-------------------|' + ) + } else { + summaryLines.push('| Variant | Success | Total Tokens | Tool Calls | Iterations |') + summaryLines.push('|---------|---------|--------------|------------|------------|') + } + + for (const result of results) { + const baseRow = `| ${result.variantName} | ${result.success} | ${result.tokenUsage.total} | ${result.toolsCalled.length} | ${result.iterations}` + if (hasEvaluation) { + const score = result.evaluationResult?.resemblanceScore ?? 'N/A' + summaryLines.push(`${baseRow} | ${score} |`) + } else { + summaryLines.push(`${baseRow} |`) + } + } + + // Add evaluation details section if available + if (hasEvaluation) { + summaryLines.push('') + summaryLines.push('## Evaluation Details') + summaryLines.push('') + for (const result of results) { + if (result.evaluationResult) { + summaryLines.push(`### ${result.variantName}`) + summaryLines.push('') + summaryLines.push(`**Score:** ${result.evaluationResult.resemblanceScore}/100`) + summaryLines.push('') + summaryLines.push(`**Statement:** ${result.evaluationResult.statement}`) + summaryLines.push('') + if ( + result.evaluationResult.missingRequirements && + result.evaluationResult.missingRequirements.length > 0 + ) { + summaryLines.push('**Missing Requirements:**') + for (const req of result.evaluationResult.missingRequirements) { + summaryLines.push(`- ${req}`) + } + summaryLines.push('') + } + if (result.evaluationResult.error) { + summaryLines.push(`**Error:** ${result.evaluationResult.error}`) + summaryLines.push('') + } + } + } + } + + // Add errors section for failed variants + const failedResults = results.filter((r) => !r.success && r.error) + if (failedResults.length > 0) { + summaryLines.push('') + summaryLines.push('## Errors') + summaryLines.push('') + for (const result of failedResults) { + summaryLines.push(`### ${result.variantName}`) + summaryLines.push('') + summaryLines.push('```') + summaryLines.push(result.error!) + summaryLines.push('```') + summaryLines.push('') + } + } + + const variantPaths: string[] = [] + + // Write each variant to its own folder + for (const result of results) { + const variantFolder = join(resultFolder, result.variantName) + await mkdir(variantFolder, { recursive: true }) + variantPaths.push(variantFolder) + + // Write details.json (metadata without app files) + const details = { + variantName: result.variantName, + success: result.success, + error: result.error ?? null, + evaluationResult: result.evaluationResult ?? null, + toolsCalled: result.toolsCalled, + toolCallDetails: result.toolCallDetails, + tokenUsage: result.tokenUsage, + iterations: result.iterations, + messages: result.messages + } + await writeFile(join(variantFolder, 'details.json'), JSON.stringify(details, null, '\t')) + + // Write app files to frontend/ and backend/ folders + await writeAppToFolder(result.files, variantFolder) + } + + // Write summary markdown file + const summaryPath = join(resultFolder, 'summary.md') + await writeFile(summaryPath, summaryLines.join('\n')) + + return { summaryPath, variantPaths } +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/createFolder/main.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/createFolder/main.ts new file mode 100644 index 0000000000..7b3e436404 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/createFolder/main.ts @@ -0,0 +1,22 @@ +interface Folder { + id: string + name: string + parentId: string | null +} + +export async function main({ + name, + parentId +}: { + name: string + parentId: string | null +}): Promise { + // In a real implementation, this would create a folder in storage + const newFolder: Folder = { + id: `folder_${Date.now()}`, + name, + parentId + } + + return newFolder +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/createFolder/meta.json b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/createFolder/meta.json new file mode 100644 index 0000000000..9c50764d85 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/createFolder/meta.json @@ -0,0 +1,4 @@ +{ + "name": "Create Folder", + "language": "bun" +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/deleteItem/main.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/deleteItem/main.ts new file mode 100644 index 0000000000..fc72b3d149 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/deleteItem/main.ts @@ -0,0 +1,12 @@ +export async function main({ + id, + type +}: { + id: string + type: 'file' | 'folder' +}): Promise<{ success: boolean }> { + // In a real implementation, this would delete from storage + console.log(`Deleting ${type} with id: ${id}`) + + return { success: true } +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/deleteItem/meta.json b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/deleteItem/meta.json new file mode 100644 index 0000000000..0056f5599b --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/deleteItem/meta.json @@ -0,0 +1,4 @@ +{ + "name": "Delete Item", + "language": "bun" +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/listFiles/main.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/listFiles/main.ts new file mode 100644 index 0000000000..ca2828055b --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/listFiles/main.ts @@ -0,0 +1,24 @@ +interface FileItem { + id: string + name: string + type: 'file' | 'folder' + size?: number + modifiedAt: string + parentId: string | null +} + +// Mock file system data +const mockFiles: FileItem[] = [ + { id: 'f1', name: 'Documents', type: 'folder', modifiedAt: '2024-01-15', parentId: null }, + { id: 'f2', name: 'Images', type: 'folder', modifiedAt: '2024-01-10', parentId: null }, + { id: 'f3', name: 'readme.txt', type: 'file', size: 1024, modifiedAt: '2024-01-20', parentId: null }, + { id: 'f4', name: 'report.pdf', type: 'file', size: 52400, modifiedAt: '2024-01-18', parentId: 'f1' }, + { id: 'f5', name: 'notes.txt', type: 'file', size: 256, modifiedAt: '2024-01-12', parentId: 'f1' }, + { id: 'f6', name: 'photo1.jpg', type: 'file', size: 2048000, modifiedAt: '2024-01-08', parentId: 'f2' }, + { id: 'f7', name: 'photo2.jpg', type: 'file', size: 1536000, modifiedAt: '2024-01-09', parentId: 'f2' }, + { id: 'f8', name: 'Projects', type: 'folder', modifiedAt: '2024-01-05', parentId: 'f1' } +] + +export async function main({ folderId }: { folderId: string | null }): Promise { + return mockFiles.filter((file) => file.parentId === folderId) +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/listFiles/meta.json b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/listFiles/meta.json new file mode 100644 index 0000000000..a3640b3cb7 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/listFiles/meta.json @@ -0,0 +1,4 @@ +{ + "name": "List Files", + "language": "bun" +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/listFolders/main.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/listFolders/main.ts new file mode 100644 index 0000000000..1d3613ad13 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/listFolders/main.ts @@ -0,0 +1,33 @@ +interface Folder { + id: string + name: string + parentId: string | null + children: Folder[] +} + +// Mock folder structure +const mockFolders: Folder[] = [ + { + id: 'f1', + name: 'Documents', + parentId: null, + children: [ + { + id: 'f8', + name: 'Projects', + parentId: 'f1', + children: [] + } + ] + }, + { + id: 'f2', + name: 'Images', + parentId: null, + children: [] + } +] + +export async function main(): Promise { + return mockFolders +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/listFolders/meta.json b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/listFolders/meta.json new file mode 100644 index 0000000000..255e7039f6 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/listFolders/meta.json @@ -0,0 +1,4 @@ +{ + "name": "List Folders", + "language": "bun" +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/moveItem/main.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/moveItem/main.ts new file mode 100644 index 0000000000..51d364b0c5 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/moveItem/main.ts @@ -0,0 +1,14 @@ +export async function main({ + id, + type, + targetFolderId +}: { + id: string + type: 'file' | 'folder' + targetFolderId: string | null +}): Promise<{ success: boolean }> { + // In a real implementation, this would move the item in storage + console.log(`Moving ${type} ${id} to folder: ${targetFolderId ?? 'root'}`) + + return { success: true } +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/moveItem/meta.json b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/moveItem/meta.json new file mode 100644 index 0000000000..d37ff548ed --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/moveItem/meta.json @@ -0,0 +1,4 @@ +{ + "name": "Move Item", + "language": "bun" +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/renameItem/main.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/renameItem/main.ts new file mode 100644 index 0000000000..db8c49ceb2 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/renameItem/main.ts @@ -0,0 +1,14 @@ +export async function main({ + id, + type, + newName +}: { + id: string + type: 'file' | 'folder' + newName: string +}): Promise<{ success: boolean; name: string }> { + // In a real implementation, this would rename in storage + console.log(`Renaming ${type} ${id} to: ${newName}`) + + return { success: true, name: newName } +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/renameItem/meta.json b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/renameItem/meta.json new file mode 100644 index 0000000000..d0966e561a --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/backend/renameItem/meta.json @@ -0,0 +1,4 @@ +{ + "name": "Rename Item", + "language": "bun" +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/Breadcrumb.tsx b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/Breadcrumb.tsx new file mode 100644 index 0000000000..13e8fa4191 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/Breadcrumb.tsx @@ -0,0 +1,26 @@ +import React from 'react' + +interface BreadcrumbProps { + path: { id: string | null; name: string }[] + onNavigate: (folderId: string | null, folderName: string) => void +} + +export const Breadcrumb: React.FC = ({ path, onNavigate }) => { + return ( +
+ {path.map((item, index) => ( + + {index > 0 && /} + + + ))} +
+ ) +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/FileItem.tsx b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/FileItem.tsx new file mode 100644 index 0000000000..61e3065d32 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/FileItem.tsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react' +import type { FileItem as FileItemType } from '../index' + +interface FileItemProps { + item: FileItemType + onDelete: (item: FileItemType) => void + onRename: (item: FileItemType, newName: string) => void + onFolderOpen: (folder: FileItemType) => void +} + +export const FileItem: React.FC = ({ item, onDelete, onRename, onFolderOpen }) => { + const [isRenaming, setIsRenaming] = useState(false) + const [newName, setNewName] = useState(item.name) + + const handleRename = () => { + if (newName.trim() && newName !== item.name) { + onRename(item, newName.trim()) + } + setIsRenaming(false) + } + + const handleDoubleClick = () => { + if (item.type === 'folder') { + onFolderOpen(item) + } + } + + const icon = item.type === 'folder' ? '📁' : '📄' + const formattedDate = new Date(item.modifiedAt).toLocaleDateString() + + return ( +
+
+ {icon} + {isRenaming ? ( + setNewName(e.target.value)} + onBlur={handleRename} + onKeyDown={(e) => e.key === 'Enter' && handleRename()} + className="border rounded px-2 py-1 flex-1" + autoFocus + /> + ) : ( + + {item.name} + + )} +
+ +
+ {item.type === 'folder' ? 'Folder' : 'File'} +
+ +
{formattedDate}
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/FileList.tsx b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/FileList.tsx new file mode 100644 index 0000000000..50f81ca07b --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/FileList.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { FileItem as FileItemComponent } from './FileItem' +import type { FileItem } from '../index' + +interface FileListProps { + files: FileItem[] + onDelete: (item: FileItem) => void + onRename: (item: FileItem, newName: string) => void + onFolderOpen: (folder: FileItem) => void +} + +export const FileList: React.FC = ({ files, onDelete, onRename, onFolderOpen }) => { + if (files.length === 0) { + return ( +
This folder is empty
+ ) + } + + // Sort: folders first, then files + const sortedFiles = [...files].sort((a, b) => { + if (a.type === 'folder' && b.type !== 'folder') return -1 + if (a.type !== 'folder' && b.type === 'folder') return 1 + return a.name.localeCompare(b.name) + }) + + return ( +
+
+
Name
+
Type
+
Modified
+
Actions
+
+ + {sortedFiles.map((file) => ( + + ))} +
+ ) +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/FolderTree.tsx b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/FolderTree.tsx new file mode 100644 index 0000000000..66e25d6a34 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/FolderTree.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import type { Folder } from '../index' + +interface FolderTreeProps { + folders: Folder[] + currentFolderId: string | null + onFolderSelect: (folderId: string | null, folderName: string) => void + depth?: number +} + +export const FolderTree: React.FC = ({ + folders, + currentFolderId, + onFolderSelect, + depth = 0 +}) => { + return ( +
+ {depth === 0 && ( +
onFolderSelect(null, 'Root')} + > + 📁 + Root +
+ )} + + {folders.map((folder) => ( +
+
onFolderSelect(folder.id, folder.name)} + > + 📁 + {folder.name} +
+ + {folder.children && folder.children.length > 0 && ( + + )} +
+ ))} +
+ ) +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/Toolbar.tsx b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/Toolbar.tsx new file mode 100644 index 0000000000..e4ffe0a660 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/components/Toolbar.tsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react' + +interface ToolbarProps { + onCreateFolder: (name: string) => void +} + +export const Toolbar: React.FC = ({ onCreateFolder }) => { + const [isCreating, setIsCreating] = useState(false) + const [folderName, setFolderName] = useState('') + + const handleCreate = () => { + if (folderName.trim()) { + onCreateFolder(folderName.trim()) + setFolderName('') + setIsCreating(false) + } + } + + return ( +
+ {isCreating ? ( +
+ setFolderName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleCreate()} + placeholder="Folder name" + className="border rounded px-3 py-1" + autoFocus + /> + + +
+ ) : ( + + )} +
+ ) +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/index.tsx b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/index.tsx new file mode 100644 index 0000000000..1d157fa0a3 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/file_manager/frontend/index.tsx @@ -0,0 +1,119 @@ +import React, { useState, useEffect } from 'react' +import { backend } from 'wmill' +import { FolderTree } from './components/FolderTree' +import { FileList } from './components/FileList' +import { Breadcrumb } from './components/Breadcrumb' +import { Toolbar } from './components/Toolbar' + +export interface FileItem { + id: string + name: string + type: 'file' | 'folder' + size?: number + modifiedAt: string + parentId: string | null +} + +export interface Folder { + id: string + name: string + parentId: string | null + children: Folder[] +} + +const App = () => { + const [folders, setFolders] = useState([]) + const [files, setFiles] = useState([]) + const [currentFolderId, setCurrentFolderId] = useState(null) + const [path, setPath] = useState<{ id: string | null; name: string }[]>([ + { id: null, name: 'Root' } + ]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + loadFolders() + }, []) + + useEffect(() => { + loadFiles(currentFolderId) + }, [currentFolderId]) + + const loadFolders = async () => { + const data = await backend.listFolders() + setFolders(data) + } + + const loadFiles = async (folderId: string | null) => { + setLoading(true) + const data = await backend.listFiles({ folderId }) + setFiles(data) + setLoading(false) + } + + const handleFolderSelect = (folderId: string | null, folderName: string) => { + setCurrentFolderId(folderId) + + if (folderId === null) { + setPath([{ id: null, name: 'Root' }]) + } else { + // Find if folder is already in path + const existingIndex = path.findIndex((p) => p.id === folderId) + if (existingIndex >= 0) { + setPath(path.slice(0, existingIndex + 1)) + } else { + setPath([...path, { id: folderId, name: folderName }]) + } + } + } + + const handleCreateFolder = async (name: string) => { + await backend.createFolder({ name, parentId: currentFolderId }) + await loadFolders() + await loadFiles(currentFolderId) + } + + const handleDeleteItem = async (item: FileItem) => { + await backend.deleteItem({ id: item.id, type: item.type }) + await loadFolders() + await loadFiles(currentFolderId) + } + + const handleRenameItem = async (item: FileItem, newName: string) => { + await backend.renameItem({ id: item.id, type: item.type, newName }) + await loadFolders() + await loadFiles(currentFolderId) + } + + return ( +
+
+
Folders
+ +
+ +
+ + + +
+ {loading ? ( +
Loading...
+ ) : ( + handleFolderSelect(folder.id, folder.name)} + /> + )} +
+
+
+ ) +} + +export default App diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/addToCart/main.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/addToCart/main.ts new file mode 100644 index 0000000000..007a33554e --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/addToCart/main.ts @@ -0,0 +1,34 @@ +interface Product { + id: string + name: string + price: number + image: string +} + +interface CartItem { + product: Product + quantity: number +} + +export async function main({ + cart, + product +}: { + cart: CartItem[] + product: Product +}): Promise { + const existingIndex = cart.findIndex((item) => item.product.id === product.id) + + if (existingIndex >= 0) { + // Increment quantity if already in cart + const updatedCart = [...cart] + updatedCart[existingIndex] = { + ...updatedCart[existingIndex], + quantity: updatedCart[existingIndex].quantity + 1 + } + return updatedCart + } + + // Add new item to cart + return [...cart, { product, quantity: 1 }] +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/addToCart/meta.json b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/addToCart/meta.json new file mode 100644 index 0000000000..5bfa774278 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/addToCart/meta.json @@ -0,0 +1,4 @@ +{ + "name": "Add to Cart", + "language": "bun" +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/calculateTotal/main.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/calculateTotal/main.ts new file mode 100644 index 0000000000..573ce46315 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/calculateTotal/main.ts @@ -0,0 +1,17 @@ +interface Product { + id: string + name: string + price: number + image: string +} + +interface CartItem { + product: Product + quantity: number +} + +export async function main({ items }: { items: CartItem[] }): Promise { + return items.reduce((total, item) => { + return total + item.product.price * item.quantity + }, 0) +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/calculateTotal/meta.json b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/calculateTotal/meta.json new file mode 100644 index 0000000000..afef1d40ac --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/calculateTotal/meta.json @@ -0,0 +1,4 @@ +{ + "name": "Calculate Total", + "language": "bun" +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/getProducts/main.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/getProducts/main.ts new file mode 100644 index 0000000000..4495a207be --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/getProducts/main.ts @@ -0,0 +1,41 @@ +export async function main() { + // Mock product catalog + return [ + { + id: '1', + name: 'Wireless Headphones', + price: 79.99, + image: 'https://picsum.photos/seed/headphones/300/200' + }, + { + id: '2', + name: 'Smart Watch', + price: 199.99, + image: 'https://picsum.photos/seed/watch/300/200' + }, + { + id: '3', + name: 'Laptop Stand', + price: 49.99, + image: 'https://picsum.photos/seed/stand/300/200' + }, + { + id: '4', + name: 'Mechanical Keyboard', + price: 129.99, + image: 'https://picsum.photos/seed/keyboard/300/200' + }, + { + id: '5', + name: 'USB-C Hub', + price: 39.99, + image: 'https://picsum.photos/seed/hub/300/200' + }, + { + id: '6', + name: 'Webcam HD', + price: 89.99, + image: 'https://picsum.photos/seed/webcam/300/200' + } + ] +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/getProducts/meta.json b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/getProducts/meta.json new file mode 100644 index 0000000000..6bfd1ad552 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/getProducts/meta.json @@ -0,0 +1,4 @@ +{ + "name": "Get Products", + "language": "bun" +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/removeFromCart/main.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/removeFromCart/main.ts new file mode 100644 index 0000000000..40cbadb8bb --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/removeFromCart/main.ts @@ -0,0 +1,21 @@ +interface Product { + id: string + name: string + price: number + image: string +} + +interface CartItem { + product: Product + quantity: number +} + +export async function main({ + cart, + productId +}: { + cart: CartItem[] + productId: string +}): Promise { + return cart.filter((item) => item.product.id !== productId) +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/removeFromCart/meta.json b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/removeFromCart/meta.json new file mode 100644 index 0000000000..bd49cb8265 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/backend/removeFromCart/meta.json @@ -0,0 +1,4 @@ +{ + "name": "Remove from Cart", + "language": "bun" +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/frontend/components/Cart.tsx b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/frontend/components/Cart.tsx new file mode 100644 index 0000000000..165e45ebcc --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/frontend/components/Cart.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import type { CartItem } from '../index' + +interface CartProps { + items: CartItem[] + total: number + onRemoveItem: (productId: string) => void +} + +export const Cart: React.FC = ({ items, total, onRemoveItem }) => { + return ( +
+

Cart

+ + {items.length === 0 ? ( +

Your cart is empty

+ ) : ( + <> +
+ {items.map((item) => ( +
+
+

{item.product.name}

+

+ ${item.product.price.toFixed(2)} x {item.quantity} +

+
+ +
+ ))} +
+ +
+
+ Total: + ${total.toFixed(2)} +
+
+ + )} +
+ ) +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/frontend/components/ProductCard.tsx b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/frontend/components/ProductCard.tsx new file mode 100644 index 0000000000..b8a22e46a8 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/frontend/components/ProductCard.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import type { Product } from '../index' + +interface ProductCardProps { + product: Product + onAddToCart: (product: Product) => void +} + +export const ProductCard: React.FC = ({ product, onAddToCart }) => { + return ( +
+ {product.name} +

{product.name}

+

${product.price.toFixed(2)}

+ +
+ ) +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/frontend/components/ProductList.tsx b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/frontend/components/ProductList.tsx new file mode 100644 index 0000000000..eb848287f3 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/frontend/components/ProductList.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { ProductCard } from './ProductCard' +import type { Product } from '../index' + +interface ProductListProps { + products: Product[] + onAddToCart: (product: Product) => void +} + +export const ProductList: React.FC = ({ products, onAddToCart }) => { + return ( +
+ {products.map((product) => ( + + ))} +
+ ) +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/frontend/index.tsx b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/frontend/index.tsx new file mode 100644 index 0000000000..600aefab15 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/shopping_cart/frontend/index.tsx @@ -0,0 +1,81 @@ +import React, { useState, useEffect } from 'react' +import { backend } from 'wmill' +import { ProductList } from './components/ProductList' +import { Cart } from './components/Cart' + +export interface Product { + id: string + name: string + price: number + image: string +} + +export interface CartItem { + product: Product + quantity: number +} + +const App = () => { + const [products, setProducts] = useState([]) + const [cartItems, setCartItems] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + + useEffect(() => { + loadProducts() + }, []) + + useEffect(() => { + updateTotal() + }, [cartItems]) + + const loadProducts = async () => { + setLoading(true) + const data = await backend.getProducts() + setProducts(data) + setLoading(false) + } + + const updateTotal = async () => { + if (cartItems.length === 0) { + setTotal(0) + return + } + const newTotal = await backend.calculateTotal({ items: cartItems }) + setTotal(newTotal) + } + + const handleAddToCart = async (product: Product) => { + const updatedCart = await backend.addToCart({ + cart: cartItems, + product + }) + setCartItems(updatedCart) + } + + const handleRemoveFromCart = async (productId: string) => { + const updatedCart = await backend.removeFromCart({ + cart: cartItems, + productId + }) + setCartItems(updatedCart) + } + + if (loading) { + return
Loading products...
+ } + + return ( +
+
+

Shop

+ +
+
+ +
+
+ ) +} + +export default App diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/backend/decrementCounter/main.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/backend/decrementCounter/main.ts new file mode 100644 index 0000000000..b34d66d604 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/backend/decrementCounter/main.ts @@ -0,0 +1,3 @@ +export async function main({ currentCount }: { currentCount: number }) { + return currentCount - 1 +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/backend/decrementCounter/meta.json b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/backend/decrementCounter/meta.json new file mode 100644 index 0000000000..c8eade4768 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/backend/decrementCounter/meta.json @@ -0,0 +1,4 @@ +{ + "name": "Decrement Counter", + "language": "bun" +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/backend/incrementCounter/main.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/backend/incrementCounter/main.ts new file mode 100644 index 0000000000..e452c0aab9 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/backend/incrementCounter/main.ts @@ -0,0 +1,3 @@ +export async function main({ currentCount }: { currentCount: number }) { + return currentCount + 1 +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/backend/incrementCounter/meta.json b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/backend/incrementCounter/meta.json new file mode 100644 index 0000000000..1ae9e9979f --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/backend/incrementCounter/meta.json @@ -0,0 +1,4 @@ +{ + "name": "Increment Counter", + "language": "bun" +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/frontend/index.tsx b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/frontend/index.tsx new file mode 100644 index 0000000000..813c8384e1 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/initial/test1_counter_app/frontend/index.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react' +import { backend } from 'wmill' + +const CounterApp = () => { + const [count, setCount] = useState(0) + + const increment = async () => { + const newCount = await backend.incrementCounter({ currentCount: count }) + setCount(newCount) + } + + const decrement = async () => { + const newCount = await backend.decrementCounter({ currentCount: count }) + setCount(newCount) + } + + return ( +
+

Counter: {count}

+
+ + +
+
+ ) +} + +export default CounterApp diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/variants/baseline.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/variants/baseline.ts new file mode 100644 index 0000000000..558424e972 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/variants/baseline.ts @@ -0,0 +1,12 @@ +import type { VariantConfig } from '../../shared' + +/** + * Baseline variant - uses the production system prompt and all tools. + * This is the default configuration that matches the actual app chat implementation. + */ +export const BASELINE_VARIANT: VariantConfig = { + name: 'baseline', + description: 'Production configuration with default system prompt and all tools', + systemPrompt: { type: 'default' }, + tools: { type: 'default' } +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/variants/index.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/variants/index.ts new file mode 100644 index 0000000000..b49c56123d --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/variants/index.ts @@ -0,0 +1,6 @@ +// Re-export all variant configurations +export { BASELINE_VARIANT } from './baseline' +export { STREAMLINED_VARIANT } from './streamlined' + +// Re-export types for convenience +export type { VariantConfig } from '../../shared' diff --git a/frontend/src/lib/components/copilot/chat/__tests__/app/variants/streamlined.ts b/frontend/src/lib/components/copilot/chat/__tests__/app/variants/streamlined.ts new file mode 100644 index 0000000000..027bf55474 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/app/variants/streamlined.ts @@ -0,0 +1,144 @@ +import type { VariantConfig } from '../../shared' +import type { Tool } from '../../shared' +import type { AppAIChatHelpers } from '../../../app/core' +import { getAppTools } from '../../../app/core' + +// Tool names to remove (batch-fetch tools) +const TOOLS_TO_REMOVE = ['get_files', 'get_frontend_files', 'get_backend_runnables'] + +/** + * Build the streamlined tools by filtering out batch-fetch tools. + */ +function buildStreamlinedTools(): Tool[] { + const defaultTools = getAppTools() + return defaultTools.filter((t) => !TOOLS_TO_REMOVE.includes(t.def.function.name)) +} + +/** + * Streamlined system prompt - simplified instructions focused on: + * 1. Reading relevant files first + * 2. Making changes with appropriate tools + * 3. Using lint at the end to fix errors + */ +const STREAMLINED_SYSTEM_PROMPT = `You are a helpful assistant that creates and edits apps on the Windmill platform. Apps are defined as a collection of files that contains both the frontend and the backend. + +## App Structure + +### Frontend +- The frontend is bundled using esbuild with entrypoint \`index.tsx\` +- Frontend files are managed separately from backend runnables +- The \`wmill.d.ts\` file is generated automatically from the backend runnables shape + +### Backend +Backend runnables can be of different types: +- **inline**: Custom code written directly in the app (TypeScript/Bun or Python) +- **script**: Reference to a workspace script by path +- **flow**: Reference to a workspace flow by path +- **hubscript**: Reference to a hub script by path + +Frontend calls backend using \`await backend.(args...)\`. + +For inline scripts, the code must have a \`main\` function as its entrypoint. + +## Available Tools + +### File Management +- \`list_frontend_files()\`: List all frontend file paths (use this first to see what exists) +- \`get_frontend_file(path)\`: Get content of a specific frontend file +- \`set_frontend_file(path, content)\`: Create or update a frontend file. Returns lint diagnostics. +- \`delete_frontend_file(path)\`: Delete a frontend file +- \`list_backend_runnables()\`: List all backend runnable keys and names (use this first to see what exists) +- \`get_backend_runnable(key)\`: Get full configuration of a specific backend runnable +- \`set_backend_runnable(key, name, type, ...)\`: Create or update a backend runnable. Returns lint diagnostics. +- \`delete_backend_runnable(key)\`: Delete a backend runnable + +### Linting +- \`lint()\`: Lint all files. Returns errors/warnings grouped by frontend/backend. + +### Discovery +- \`list_workspace_runnables(query, type?)\`: Search workspace scripts and flows +- \`search_hub_scripts(query)\`: Search hub scripts + +## Backend Runnable Configuration + +When creating a backend runnable with \`set_backend_runnable\`: + +1. **For inline scripts** (type: "inline"): + \`\`\` + { + key: "myFunction", + name: "Does something useful", + type: "inline", + inlineScript: { + language: "bun", // or "python3" + content: "export async function main(arg1: string) { return result; }" + } + } + \`\`\` + +2. **For workspace scripts** (type: "script"): + \`\`\` + { + key: "sendEmail", + name: "Send email via SMTP", + type: "script", + path: "f/folder/send_email", + staticInputs: { smtp_server: "mail.example.com" } // optional pre-filled inputs + } + \`\`\` + +3. **For workspace flows** (type: "flow"): + \`\`\` + { + key: "processOrder", + name: "Process customer order", + type: "flow", + path: "f/folder/process_order_flow" + } + \`\`\` + +4. **For hub scripts** (type: "hubscript"): + \`\`\` + { + key: "slackMessage", + name: "Send Slack message", + type: "hubscript", + path: "hub/123/slack/send_message" + } + \`\`\` + +## Instructions + +1. Start by reading relevant files to understand the current state +2. Make changes using the appropriate tools +3. Use \`lint()\` at the end to check for and fix any errors + +Windmill expects all backend runnable calls to use an object parameter structure. For example for: +\`\`\`typescript +export async function main(arg1: string, arg2: string, arg3: number, arg4: { field1: string, field2: number }) { + ... +} +\`\`\` + +You would call it like this: +\`\`\`typescript +await backend.myFunction({ arg1: 'value1', arg2: 'value2', arg3: 3, arg4: { field1: 'value1', field2: 2 } }) +\`\`\` +If the runnable has no parameters, you can call it without an object: +\`\`\`typescript +await backend.myFunction() +\`\`\` + +When you are using the windmill-client, do not forget that as id for variables or resources, those are path that are of the form 'u//' or 'f//'. +` + +/** + * Streamlined variant - removes batch-fetch tools and uses simplified instructions. + * Forces the model to read individual files before making changes. + */ +export const STREAMLINED_VARIANT: VariantConfig = { + name: 'streamlined', + description: 'No batch tools - forces reading individual files before making changes', + systemPrompt: { type: 'custom', content: STREAMLINED_SYSTEM_PROMPT }, + tools: { type: 'custom', tools: buildStreamlinedTools() } +} diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test1.json b/frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test1.json similarity index 100% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test1.json rename to frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test1.json diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test2.json b/frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test2.json similarity index 100% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test2.json rename to frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test2.json diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test3.json b/frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test3.json similarity index 100% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test3.json rename to frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test3.json diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test4.json b/frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test4.json similarity index 100% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test4.json rename to frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test4.json diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test5_modify_simple.json b/frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test5_modify_simple.json similarity index 100% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test5_modify_simple.json rename to frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test5_modify_simple.json diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test6_modify_medium.json b/frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test6_modify_medium.json similarity index 100% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test6_modify_medium.json rename to frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test6_modify_medium.json diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test7_modify_complex.json b/frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test7_modify_complex.json similarity index 100% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/expected/test7_modify_complex.json rename to frontend/src/lib/components/copilot/chat/__tests__/flow/expected/test7_modify_complex.json diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/flowChat.eval.test.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts similarity index 95% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/flowChat.eval.test.ts rename to frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts index 7c4882eda5..8210ea50fb 100644 --- a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/flowChat.eval.test.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowChat.eval.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect } from 'vitest' -import { runVariantComparison, type ExpectedFlow } from './evalRunner' -import { writeComparisonResults } from './evalResultsWriter' +import { runVariantComparison, writeFlowComparisonResults, type ExpectedFlow } from './flowEvalRunner' import { BASELINE_VARIANT, MINIMAL_SINGLE_TOOL_VARIANT } from './variants' // @ts-ignore - JSON import import expectedTest1 from './expected/test1.json' @@ -71,7 +70,7 @@ STEP 5: Return action taken for each user }) // Write results to files - const { summaryPath, flowPaths } = await writeComparisonResults(USER_PROMPT, results) + const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`Flow files: ${flowPaths.join(', ')}`) @@ -117,7 +116,7 @@ STEP 7: Return final order summary with status expectedFlow: expectedTest2 as ExpectedFlow }) - const { summaryPath, flowPaths } = await writeComparisonResults(USER_PROMPT, results) + const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`Flow files: ${flowPaths.join(', ')}`) @@ -166,7 +165,7 @@ STEP 6: Return processing report with statistics (total records, quality score, expectedFlow: expectedTest3 as ExpectedFlow }) - const { summaryPath, flowPaths } = await writeComparisonResults(USER_PROMPT, results) + const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`Flow files: ${flowPaths.join(', ')}`) @@ -215,7 +214,7 @@ STEP 5: Return the agent's response and any actions taken expectedFlow: expectedTest4 as ExpectedFlow }) - const { summaryPath, flowPaths } = await writeComparisonResults(USER_PROMPT, results) + const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`Flow files: ${flowPaths.join(', ')}`) @@ -263,7 +262,7 @@ Modify this existing flow to add error handling: expectedFlow: expectedTest5 as ExpectedFlow }) - const { summaryPath, flowPaths } = await writeComparisonResults(USER_PROMPT, results) + const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`Flow files: ${flowPaths.join(', ')}`) @@ -309,7 +308,7 @@ Modify the order processing loop to handle different order types: expectedFlow: expectedTest6 as ExpectedFlow }) - const { summaryPath, flowPaths } = await writeComparisonResults(USER_PROMPT, results) + const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`Flow files: ${flowPaths.join(', ')}`) @@ -355,7 +354,7 @@ Refactor this flow for better performance by parallelizing the enrichment steps: expectedFlow: expectedTest7 as ExpectedFlow }) - const { summaryPath, flowPaths } = await writeComparisonResults(USER_PROMPT, results) + const { summaryPath, flowPaths } = await writeFlowComparisonResults(USER_PROMPT, results) console.log(`\nResults written to: ${summaryPath}`) console.log(`Flow files: ${flowPaths.join(', ')}`) diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts new file mode 100644 index 0000000000..f55979bb40 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalComparison.ts @@ -0,0 +1,72 @@ +import type { FlowModule } from '$lib/gen' +import { evaluateWithLLM, BASE_EVALUATOR_RESPONSE_FORMAT } from '../shared' +import type { EvaluationResult } from '../shared' + +/** + * Expected flow structure for evaluation. + */ +export interface ExpectedFlow { + summary?: string + value: { + modules: FlowModule[] + } + schema?: Record +} + +/** + * Flow-specific evaluator system prompt. + */ +const FLOW_EVALUATOR_SYSTEM_PROMPT = `You are an expert evaluator for Windmill flow definitions. Your task is to evaluate a generated flow against: +1. The original user request/prompt +2. An expected reference flow + +## Windmill Flow Context +- Flows consist of modules (steps) that execute sequentially +- Module types include: rawscript, forloopflow, branchone, branchall, script, flow, aiagent +- Each module has an id, value (containing type and config), and may have input_transforms +- input_transforms connect modules using expressions like "results.previous_step". Valid input_transforms are: static, javascript. Valid variables in javascript expressions are: results, flow_input, flow_input.iter.value (for forloopflow), flow_input.iter.index (for forloopflow). +- forloopflow contains nested modules that execute per iteration with access to flow_input.iter.value +- branchone executes first matching branch, branchall executes all matching branches +- Branches have conditional expressions (expr) that determine execution +- aiagent modules contain tools array with tool definitions + +## Evaluation Criteria +1. **User Request Fulfillment**: Does the generated flow address ALL requirements from the user's original prompt? + - Are all requested steps present? + - Are the requested features implemented (loops, branches, specific logic)? + - Does the schema match what the user requested for inputs? +2. **Structure**: Are the module types and nesting structure appropriate for the task? +3. **Logic**: Does the flow accomplish the intended logical task? +4. **Connections**: Are input_transforms connecting data correctly between steps? +5. **Completeness**: Are all required steps present with no major omissions? +6. **Code Quality**: Is the code functionally correct (exact syntax doesn't need to match)? + +## Important Notes +- Minor differences in variable names, code formatting, or exact wording are acceptable +- Focus on functional equivalence, not character-by-character matching +- The generated flow should achieve the same outcome as described in the user request +- Extra helper steps or slightly different approaches can still score high if they accomplish the goal +- If the user requested specific module types (like aiagent), verify they are used correctly + +${BASE_EVALUATOR_RESPONSE_FORMAT}` + +/** + * Evaluates how well a generated flow matches an expected flow and user request using an LLM. + * Returns a resemblance score (0-100), a qualitative statement, and any missing requirements. + */ +export async function evaluateFlowComparison( + generatedFlow: ExpectedFlow, + expectedFlow: ExpectedFlow, + userPrompt: string +): Promise { + // @ts-ignore + const apiKey = process.env.OPENROUTER_API_KEY + + return evaluateWithLLM({ + userPrompt, + generatedOutput: generatedFlow, + expectedOutput: expectedFlow, + evaluatorSystemPrompt: FLOW_EVALUATOR_SYSTEM_PROMPT, + apiKey + }) +} diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalHelpers.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalHelpers.ts similarity index 93% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalHelpers.ts rename to frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalHelpers.ts index aecea05cc6..4861240002 100644 --- a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalHelpers.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalHelpers.ts @@ -1,14 +1,14 @@ -import type { FlowAIChatHelpers } from '../../core' +import type { FlowAIChatHelpers } from '../../flow/core' import type { FlowModule, InputTransform } from '$lib/gen' import type { ExtendedOpenFlow } from '$lib/components/flows/types' -import { findModuleById } from '../../../shared' -import { inlineScriptStore, restoreInlineScriptReferences } from '../../inlineScriptsUtils' +import { findModuleById } from '../../shared' +import { inlineScriptStore, restoreInlineScriptReferences } from '../../flow/inlineScriptsUtils' /** * Creates mock FlowAIChatHelpers for eval testing. * Tracks flow state in memory and allows tool functions to modify it. */ -export function createEvalHelpers( +export function createFlowEvalHelpers( initialModules: FlowModule[] = [], initialSchema?: Record ) { diff --git a/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts new file mode 100644 index 0000000000..3f27143c69 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/flowEvalRunner.ts @@ -0,0 +1,173 @@ +import type { FlowModule } from '$lib/gen' +import type { ExtendedOpenFlow } from '$lib/components/flows/types' +import { flowTools, prepareFlowSystemMessage, prepareFlowUserMessage, type FlowAIChatHelpers } from '../../flow/core' +import { createFlowEvalHelpers } from './flowEvalHelpers' +import { evaluateFlowComparison, type ExpectedFlow } from './flowEvalComparison' +import { + runEval, + resolveSystemPrompt, + resolveTools, + resolveModel, + writeComparisonResults, + type VariantConfig, + type BaseEvalResult, + type EvaluationResult, + type Tool, + type VariantDefaults +} from '../shared' + +// Re-export for convenience +export type { ExpectedFlow } from './flowEvalComparison' + +/** + * Flow-specific evaluation result. + */ +export interface FlowEvalResult extends BaseEvalResult { + /** Alias for output to maintain API compatibility */ + flow: ExtendedOpenFlow +} + +/** + * Options for running a flow evaluation. + */ +export interface FlowEvalOptions { + initialModules?: FlowModule[] + initialSchema?: Record + model?: string + customSystemPrompt?: string + maxIterations?: number + variant?: VariantConfig + expectedFlow?: ExpectedFlow +} + +/** + * Flow-specific variant defaults. + */ +const flowDefaults: VariantDefaults = { + prepareSystemMessage: prepareFlowSystemMessage, + tools: flowTools as Tool[] +} + +/** + * Runs a flow chat evaluation with real OpenAI API calls. + * Executes tool calls using the actual flowTools from core.ts or variant-configured tools. + */ +export async function runFlowEval( + userPrompt: string, + openaiApiKey: string, + options?: FlowEvalOptions +): Promise { + const { helpers, getFlow } = createFlowEvalHelpers( + options?.initialModules ?? [], + options?.initialSchema + ) + + // Resolve variant configuration + const variantName = options?.variant?.name ?? 'baseline' + const systemMessage = resolveSystemPrompt(options?.variant, flowDefaults, options?.customSystemPrompt) + const { toolDefs, tools } = resolveTools(options?.variant, flowDefaults) + const model = resolveModel(options?.variant, options?.model) + + // Build user message + const userMessage = prepareFlowUserMessage(userPrompt, helpers.getFlowAndSelectedId(), []) + + // Run the base evaluation + const rawResult = await runEval({ + userPrompt, + systemMessage, + userMessage, + toolDefs, + tools, + helpers, + apiKey: openaiApiKey, + getOutput: getFlow, + options: { + maxIterations: options?.maxIterations, + model, + workspace: 'test-workspace' + } + }) + + // Run evaluation if expected flow is provided + let evaluationResult: EvaluationResult | undefined + if (options?.expectedFlow) { + const generatedFlow = getFlow() + evaluationResult = await evaluateFlowComparison( + { + summary: generatedFlow.summary, + value: { modules: generatedFlow.value.modules }, + schema: generatedFlow.schema + }, + options.expectedFlow, + userPrompt + ) + } + + return { + ...rawResult, + variantName, + flow: rawResult.output, + evaluationResult + } +} + +/** + * Runs the same prompt against multiple variants sequentially for comparison. + * Returns results in the same order as the input variants. + */ +export async function runVariantComparison( + userPrompt: string, + variants: VariantConfig[], + openaiApiKey: string, + baseOptions?: Omit +): Promise { + const results: FlowEvalResult[] = await Promise.all( + variants.map(async (variant) => { + return await runFlowEval(userPrompt, openaiApiKey, { + ...baseOptions, + variant + }) + }) + ) + return results +} + +/** + * Writes flow comparison results to files. + */ +export async function writeFlowComparisonResults( + userPrompt: string, + results: FlowEvalResult[], + outputDir?: string +): Promise<{ summaryPath: string; flowPaths: string[] }> { + // @ts-ignore + const { dirname, join } = await import('path') + // @ts-ignore + const { fileURLToPath } = await import('url') + + const __filename = fileURLToPath(import.meta.url) + const __dirname = dirname(__filename) + + const resultsDir = outputDir ?? join(__dirname, 'results') + + const result = await writeComparisonResults({ + userPrompt, + results, + outputDir: resultsDir, + formatOutput: (flow: ExtendedOpenFlow) => ({ + summary: flow.summary ?? '', + value: { + modules: flow.value.modules + }, + schema: flow.schema ?? { + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: {}, + required: [], + type: 'object' + } + }), + outputLabel: 'flow' + }) + + return { summaryPath: result.summaryPath, flowPaths: result.outputPaths } +} diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/initial/test5_initial.json b/frontend/src/lib/components/copilot/chat/__tests__/flow/initial/test5_initial.json similarity index 100% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/initial/test5_initial.json rename to frontend/src/lib/components/copilot/chat/__tests__/flow/initial/test5_initial.json diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/initial/test6_initial.json b/frontend/src/lib/components/copilot/chat/__tests__/flow/initial/test6_initial.json similarity index 100% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/initial/test6_initial.json rename to frontend/src/lib/components/copilot/chat/__tests__/flow/initial/test6_initial.json diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/initial/test7_initial.json b/frontend/src/lib/components/copilot/chat/__tests__/flow/initial/test7_initial.json similarity index 100% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/initial/test7_initial.json rename to frontend/src/lib/components/copilot/chat/__tests__/flow/initial/test7_initial.json diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/variants/baseline.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/variants/baseline.ts similarity index 87% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/variants/baseline.ts rename to frontend/src/lib/components/copilot/chat/__tests__/flow/variants/baseline.ts index 7f6b743cb8..bd20f4f8c2 100644 --- a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/variants/baseline.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/variants/baseline.ts @@ -1,4 +1,4 @@ -import type { VariantConfig } from '../evalVariants' +import type { VariantConfig } from '../../shared' /** * Baseline variant - uses the production system prompt and all tools. diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/variants/index.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/variants/index.ts similarity index 79% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/variants/index.ts rename to frontend/src/lib/components/copilot/chat/__tests__/flow/variants/index.ts index 52fc0058d3..914db4a398 100644 --- a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/variants/index.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/variants/index.ts @@ -3,4 +3,4 @@ export { BASELINE_VARIANT } from './baseline' export { MINIMAL_SINGLE_TOOL_VARIANT, setFlowJsonTool } from './minimal-single-tool' // Re-export types for convenience -export type { VariantConfig } from '../evalVariants' +export type { VariantConfig } from '../../shared' diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/variants/minimal-single-tool.ts b/frontend/src/lib/components/copilot/chat/__tests__/flow/variants/minimal-single-tool.ts similarity index 96% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/variants/minimal-single-tool.ts rename to frontend/src/lib/components/copilot/chat/__tests__/flow/variants/minimal-single-tool.ts index 3e53998b8c..24d3036f4e 100644 --- a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/variants/minimal-single-tool.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/flow/variants/minimal-single-tool.ts @@ -1,8 +1,8 @@ -import type { VariantConfig } from '../evalVariants' -import type { Tool } from '../../../../shared' -import type { FlowAIChatHelpers } from '../../../core' -import { flowTools } from '../../../core' -import openFlowSchema from '../../../openFlow.json' +import type { VariantConfig } from '../../shared' +import type { Tool } from '../../shared' +import type { FlowAIChatHelpers } from '../../../flow/core' +import { flowTools } from '../../../flow/core' +import openFlowSchema from '../../../flow/openFlow.json' /** * IDs of the granular flow editing tools that should be replaced by set_flow_json. @@ -48,7 +48,7 @@ export const setFlowJsonTool: Tool = { } }, fn: async ({ args, helpers }) => { - const { modules, schema } = args + const { modules, schema } = args as { modules: any[]; schema?: Record } await helpers.setFlowJson(modules, schema) return `Flow updated with ${modules.length} module(s): [${modules.map((m: any) => m.id).join(', ')}]` } @@ -61,7 +61,7 @@ export const setFlowJsonTool: Tool = { */ function buildMinimalSingleToolTools(): Tool[] { // Get all production tools except flow editing tools - const utilityTools = flowTools.filter( + const utilityTools = (flowTools as Tool[]).filter( (t) => !FLOW_EDITING_TOOL_NAMES.includes(t.def.function.name) ) diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalRunner.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts similarity index 50% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalRunner.ts rename to frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts index d739c29294..b9b7820568 100644 --- a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalRunner.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseEvalRunner.ts @@ -1,91 +1,82 @@ import OpenAI, { APIError } from 'openai' -import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs' -import { prepareFlowUserMessage } from '../../core' -import { createEvalHelpers } from './evalHelpers' -import type { FlowModule } from '$lib/gen' -import type { ExtendedOpenFlow } from '$lib/components/flows/types' -import type { ToolCallbacks } from '../../../shared' -import { type VariantConfig, resolveSystemPrompt, resolveTools, resolveModel } from './evalVariants' -import { evaluateFlowComparison, type EvalComparisonResult } from './evalFlowComparison' +import type { ChatCompletionMessageParam, ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' +import type { ChatCompletionTool } from 'openai/resources/chat/completions.mjs' +import type { TokenUsage, ToolCallDetail, EvalRunnerOptions } from './types' +import type { Tool } from './baseVariants' -// Re-export for convenience -export type { EvalComparisonResult } from './evalFlowComparison' - -export interface ToolCallDetail { - name: string - arguments: Record -} - -export interface EvalResult { +/** + * Result from a single eval run (before domain-specific evaluation). + */ +export interface RawEvalResult { success: boolean - flow: ExtendedOpenFlow + output: TOutput error?: string - tokenUsage: { - prompt: number - completion: number - total: number - } + tokenUsage: TokenUsage toolCallsCount: number toolsCalled: string[] toolCallDetails: ToolCallDetail[] iterations: number - variantName: string - evaluationResult?: EvalComparisonResult messages: ChatCompletionMessageParam[] } -export interface ExpectedFlow { - summary?: string - value: { - modules: FlowModule[] - } - schema?: Record -} - -export interface EvalOptions { - initialModules?: FlowModule[] - initialSchema?: Record - model?: string - customSystemPrompt?: string - maxIterations?: number - variant?: VariantConfig - expectedFlow?: ExpectedFlow +/** + * Parameters for running a base evaluation. + */ +export interface RunEvalParams { + /** The user's prompt/instruction */ + userPrompt: string + /** System message for the LLM */ + systemMessage: ChatCompletionSystemMessageParam + /** User message for the LLM */ + userMessage: ChatCompletionMessageParam + /** Tool definitions for the LLM API */ + toolDefs: ChatCompletionTool[] + /** Full tool implementations for execution */ + tools: Tool[] + /** Domain-specific helpers for tool execution */ + helpers: THelpers + /** API key for OpenRouter */ + apiKey: string + /** Function to get the current output state */ + getOutput: () => TOutput + /** Optional configuration */ + options?: EvalRunnerOptions } /** - * Runs a flow chat evaluation with real OpenAI API calls. - * Executes tool calls using the actual flowTools from core.ts or variant-configured tools. + * Runs a generic evaluation with real LLM API calls. + * Executes tool calls in a loop until the LLM stops calling tools. + * + * This is the core execution loop shared across all chat eval tests. */ -export async function runFlowEval( - userPrompt: string, - openaiApiKey: string, - options?: EvalOptions -): Promise { - const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey: openaiApiKey }) - const { helpers, getFlow } = createEvalHelpers( - options?.initialModules ?? [], - options?.initialSchema - ) +export async function runEval( + params: RunEvalParams +): Promise> { + const { + systemMessage, + userMessage, + toolDefs, + tools, + helpers, + apiKey, + getOutput, + options + } = params - // Resolve variant configuration - const variantName = options?.variant?.name ?? 'baseline' - const systemMessage = resolveSystemPrompt(options?.variant, options?.customSystemPrompt) - const { toolDefs, tools } = resolveTools(options?.variant) - const model = resolveModel(options?.variant, options?.model) - - // Build user message - const userMessage = prepareFlowUserMessage(userPrompt, helpers.getFlowAndSelectedId(), []) + const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey }) + const model = options?.model ?? 'gpt-4o' + const maxIterations = options?.maxIterations ?? 20 + const workspace = options?.workspace ?? 'test-workspace' const messages: ChatCompletionMessageParam[] = [systemMessage, userMessage] - const totalTokens = { prompt: 0, completion: 0, total: 0 } + const totalTokens: TokenUsage = { prompt: 0, completion: 0, total: 0 } let toolCallsCount = 0 const toolsCalled: string[] = [] const toolCallDetails: ToolCallDetail[] = [] let iterations = 0 - const maxIterations = options?.maxIterations ?? 20 // No-op tool callbacks for eval - const toolCallbacks: ToolCallbacks = { + const toolCallbacks = { setToolStatus: () => {}, removeToolStatus: () => {} } @@ -155,7 +146,7 @@ export async function runFlowEval( toolCallDetails.push({ name: toolCall.function.name, arguments: args }) const result = await tool.fn({ args, - workspace: 'test-workspace', + workspace, helpers, toolCallbacks, toolId: toolCall.id @@ -176,27 +167,14 @@ export async function runFlowEval( } } - // Run evaluation if expected flow is provided - let evaluationResult: EvalComparisonResult | undefined - if (options?.expectedFlow) { - const generatedFlow = getFlow() - evaluationResult = await evaluateFlowComparison( - generatedFlow, - options.expectedFlow, - userPrompt - ) - } - return { success: true, - flow: getFlow(), + output: getOutput(), tokenUsage: totalTokens, toolCallsCount, toolsCalled, toolCallDetails, iterations, - variantName, - evaluationResult, messages } } catch (err) { @@ -218,55 +196,16 @@ export async function runFlowEval( errorMessage = String(err) } - // Still run evaluation on partial content if expected flow is provided - let evaluationResult: EvalComparisonResult | undefined - if (options?.expectedFlow) { - try { - const generatedFlow = getFlow() - evaluationResult = await evaluateFlowComparison( - generatedFlow, - options.expectedFlow, - userPrompt - ) - } catch (evalErr) { - // If evaluation itself fails, just log it and continue - console.error('Evaluation failed:', evalErr) - } - } - return { success: false, - flow: getFlow(), + output: getOutput(), error: errorMessage, tokenUsage: totalTokens, toolCallsCount, toolsCalled, toolCallDetails, iterations, - variantName, - evaluationResult, messages } } } - -/** - * Runs the same prompt against multiple variants sequentially for comparison. - * Returns results in the same order as the input variants. - */ -export async function runVariantComparison( - userPrompt: string, - variants: VariantConfig[], - openaiApiKey: string, - baseOptions?: Omit -): Promise { - const results: EvalResult[] = await Promise.all( - variants.map(async (variant) => { - return await runFlowEval(userPrompt, openaiApiKey, { - ...baseOptions, - variant - }) - }) - ) - return results -} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts new file mode 100644 index 0000000000..63c17828f4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseLLMEvaluator.ts @@ -0,0 +1,126 @@ +import OpenAI from 'openai' +import type { EvaluationResult } from './types' + +/** + * Parameters for LLM-based evaluation. + */ +export interface EvaluateParams { + /** The user's original request/prompt */ + userPrompt: string + /** The generated output to evaluate */ + generatedOutput: unknown + /** The expected/reference output */ + expectedOutput: unknown + /** Domain-specific system prompt for the evaluator */ + evaluatorSystemPrompt: string + /** API key for OpenRouter */ + apiKey: string + /** Model to use for evaluation (default: 'anthropic/claude-sonnet-4.5') */ + model?: string +} + +/** + * Base evaluator system prompt template. + * Domain-specific evaluators should build on this structure. + */ +export const BASE_EVALUATOR_RESPONSE_FORMAT = ` +## Response Format +You MUST respond with valid JSON only, no additional text: +{ + "resemblanceScore": <0-100 integer>, + "statement": "", + "missingRequirements": [""] +} + +Score guidelines: +- 90-100: Fully addresses user request, functionally equivalent to expected output +- 70-89: Addresses most user requirements, same overall structure with minor differences +- 50-69: Partially addresses user request, achieves similar goal but different approach +- 30-49: Missing significant requirements from user request +- 0-29: Does not address user request or significantly incorrect` + +/** + * Evaluates how well a generated output matches an expected output using an LLM. + * Returns a resemblance score (0-100), a qualitative statement, and any missing requirements. + * + * @param params Evaluation parameters including prompts, outputs, and API configuration + * @returns Evaluation result with score, statement, and missing requirements + */ +export async function evaluateWithLLM(params: EvaluateParams): Promise { + const { + userPrompt, + generatedOutput, + expectedOutput, + evaluatorSystemPrompt, + apiKey, + model = 'anthropic/claude-sonnet-4.5' + } = params + + const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey }) + + const userMessage = `## User's Original Request +${userPrompt} + +## Expected Reference Output +\`\`\`json +${JSON.stringify(expectedOutput, null, 2)} +\`\`\` + +## Generated Output +\`\`\`json +${JSON.stringify(generatedOutput, null, 2)} +\`\`\` + +Please evaluate how well the generated output: +1. Fulfills ALL requirements from the user's original request +2. Matches the structure and logic of the expected reference output` + + try { + const response = await client.chat.completions.create({ + model, + messages: [ + { role: 'system', content: evaluatorSystemPrompt }, + { role: 'user', content: userMessage } + ], + temperature: 0 + }) + + const content = response.choices[0]?.message?.content + if (!content) { + return { + success: false, + resemblanceScore: 0, + statement: 'No response from evaluator', + error: 'Empty response from LLM' + } + } + + // Parse JSON response - handle potential markdown code blocks + let jsonContent = content.trim() + if (jsonContent.startsWith('```')) { + // Remove markdown code block wrapper + jsonContent = jsonContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '') + } + + const parsed = JSON.parse(jsonContent) as { + resemblanceScore: number + statement: string + missingRequirements?: string[] + } + + return { + success: true, + resemblanceScore: Math.max(0, Math.min(100, Math.round(parsed.resemblanceScore))), + statement: parsed.statement, + missingRequirements: parsed.missingRequirements ?? [] + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err) + return { + success: false, + resemblanceScore: 0, + statement: 'Evaluation failed', + error: errorMessage + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalResultsWriter.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseResultsWriter.ts similarity index 72% rename from frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalResultsWriter.ts rename to frontend/src/lib/components/copilot/chat/__tests__/shared/baseResultsWriter.ts index edc3150ecd..0ecce615ba 100644 --- a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalResultsWriter.ts +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseResultsWriter.ts @@ -4,36 +4,48 @@ import { writeFile, mkdir } from 'fs/promises' import { join, dirname } from 'path' // @ts-ignore import { fileURLToPath } from 'url' -import type { EvalResult } from './evalRunner' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) +import type { BaseEvalResult } from './types' /** * Generates a timestamp string suitable for filenames. * Format: 2024-01-15T10-30-45-123Z (ISO but with dashes instead of colons) */ -function generateTimestamp(): string { +export function generateTimestamp(): string { return new Date().toISOString().replace(/:/g, '-') } +/** + * Parameters for writing comparison results. + */ +export interface WriteResultsParams { + /** User prompt that was tested */ + userPrompt: string + /** Results from all variants */ + results: BaseEvalResult[] + /** Directory to write results to */ + outputDir: string + /** Function to format domain-specific output for JSON files */ + formatOutput: (output: TOutput) => unknown + /** Label for the output type (e.g., 'flow', 'app') */ + outputLabel?: string +} + /** * Writes comparison results to files in the results folder. * Creates: - * - {timestamp}.md - Summary with prompt and results table - * - {timestamp}_{variant_name}.json - Flow JSON for each variant + * - summary.md - Summary with prompt and results table + * - {variant_name}.json - Full result with metadata for each variant + * - {variant_name}_{outputLabel}.json - Clean output for each variant */ -export async function writeComparisonResults( - userPrompt: string, - results: EvalResult[], - outputDir?: string -): Promise<{ summaryPath: string; flowPaths: string[] }> { - const resultsDir = outputDir ?? join(__dirname, 'results') +export async function writeComparisonResults( + params: WriteResultsParams +): Promise<{ summaryPath: string; outputPaths: string[] }> { + const { userPrompt, results, outputDir, formatOutput, outputLabel = 'output' } = params const timestamp = generateTimestamp() // Ensure results directory exists - await mkdir(resultsDir, { recursive: true }) - const resultFolder = join(resultsDir, timestamp) + await mkdir(outputDir, { recursive: true }) + const resultFolder = join(outputDir, timestamp) await mkdir(resultFolder, { recursive: true }) // Check if any results have evaluation data @@ -122,15 +134,15 @@ export async function writeComparisonResults( } } - const flowPaths: string[] = [] + const outputPaths: string[] = [] for (const result of results) { const resultFilename = `${result.variantName}.json` const resultPath = join(resultFolder, resultFilename) - flowPaths.push(resultPath) + outputPaths.push(resultPath) - const flowFilename = `${result.variantName}_flow.json` - const flowPath = join(resultFolder, flowFilename) + const outputFilename = `${result.variantName}_${outputLabel}.json` + const outputPath = join(resultFolder, outputFilename) // Write result JSON file (with metadata) const resultData = { @@ -144,25 +156,14 @@ export async function writeComparisonResults( } await writeFile(resultPath, JSON.stringify(resultData, null, 2)) - // Write flow definition JSON file (clean flow format) - const flowData = { - summary: result.flow.summary ?? '', - value: { - modules: result.flow.value.modules - }, - schema: result.flow.schema ?? { - $schema: 'https://json-schema.org/draft/2020-12/schema', - properties: {}, - required: [], - type: 'object' - } - } - await writeFile(flowPath, JSON.stringify(flowData, null, 2)) + // Write clean output JSON file (domain-specific format) + const outputData = formatOutput(result.output) + await writeFile(outputPath, JSON.stringify(outputData, null, 2)) } // Write summary markdown file const summaryPath = join(resultFolder, `summary.md`) await writeFile(summaryPath, summaryLines.join('\n')) - return { summaryPath, flowPaths } + return { summaryPath, outputPaths } } diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/baseVariants.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseVariants.ts new file mode 100644 index 0000000000..26d9bf57cc --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/baseVariants.ts @@ -0,0 +1,108 @@ +import type { + ChatCompletionFunctionTool, + ChatCompletionSystemMessageParam +} from 'openai/resources/chat/completions.mjs' +import type { ChatCompletionTool } from 'openai/resources/chat/completions.mjs' +import type { VariantConfig } from './types' + +/** + * Generic tool interface that matches the structure used across chat modules. + */ +export interface Tool { + def: ChatCompletionFunctionTool + fn: (params: { + args: Record + workspace: string + helpers: THelpers + toolCallbacks: { + setToolStatus: (...args: unknown[]) => void + removeToolStatus: (...args: unknown[]) => void + } + toolId: string + }) => Promise +} + +/** + * Domain-specific defaults for variant resolution. + */ +export interface VariantDefaults { + /** Function to prepare system message, optionally with custom prompt */ + prepareSystemMessage: (customPrompt?: string) => ChatCompletionSystemMessageParam + /** Available tools for the domain */ + tools: Tool[] +} + +/** + * Resolves system prompt from variant config. + * Returns the appropriate ChatCompletionSystemMessageParam based on config. + */ +export function resolveSystemPrompt( + variant: VariantConfig | undefined, + defaults: VariantDefaults, + fallbackCustomPrompt?: string +): ChatCompletionSystemMessageParam { + if (!variant?.systemPrompt || variant.systemPrompt.type === 'default') { + return defaults.prepareSystemMessage(fallbackCustomPrompt) + } + + if (variant.systemPrompt.type === 'default-with-custom') { + return defaults.prepareSystemMessage(variant.systemPrompt.custom) + } + + // type === 'custom' + return { + role: 'system', + content: variant.systemPrompt.content + } +} + +/** + * Resolves tools from variant config. + * Returns both the tool definitions (for API) and full tools (for execution). + */ +export function resolveTools( + variant: VariantConfig | undefined, + defaults: VariantDefaults +): { + toolDefs: ChatCompletionTool[] + tools: Tool[] +} { + if (!variant?.tools || variant.tools.type === 'default') { + return { + toolDefs: defaults.tools.map((t) => t.def), + tools: defaults.tools + } + } + + if (variant.tools.type === 'subset') { + const includeList = (variant.tools as { type: 'subset'; include: string[] }).include + const subset = defaults.tools.filter((t) => includeList.includes(t.def.function.name)) + return { + toolDefs: subset.map((t) => t.def), + tools: subset + } + } + + if (variant.tools.type === 'custom') { + // Custom tools are typed as unknown[] in base VariantConfig but domain-specific + // code should ensure they are the correct Tool type + const customTools = variant.tools.tools as Tool[] + return { + toolDefs: customTools.map((t) => t.def), + tools: customTools + } + } + + // Default fallback + return { + toolDefs: defaults.tools.map((t) => t.def), + tools: defaults.tools + } +} + +/** + * Resolves model from variant config with fallback. + */ +export function resolveModel(variant?: VariantConfig, fallback?: string): string { + return variant?.model ?? fallback ?? 'gpt-4o' +} diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/index.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/index.ts new file mode 100644 index 0000000000..0c1b3bc8cb --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/index.ts @@ -0,0 +1,28 @@ +// Types +export type { + TokenUsage, + ToolCallDetail, + EvaluationResult, + BaseEvalResult, + VariantConfig, + EvalRunnerOptions, + ToolCallbacks +} from './types' + +export { createNoOpToolCallbacks } from './types' + +// Variant resolution +export type { Tool, VariantDefaults } from './baseVariants' +export { resolveSystemPrompt, resolveTools, resolveModel } from './baseVariants' + +// Eval runner +export type { RawEvalResult, RunEvalParams } from './baseEvalRunner' +export { runEval } from './baseEvalRunner' + +// LLM evaluator +export type { EvaluateParams } from './baseLLMEvaluator' +export { evaluateWithLLM, BASE_EVALUATOR_RESPONSE_FORMAT } from './baseLLMEvaluator' + +// Results writer +export type { WriteResultsParams } from './baseResultsWriter' +export { writeComparisonResults, generateTimestamp } from './baseResultsWriter' diff --git a/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts b/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts new file mode 100644 index 0000000000..021e776440 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/__tests__/shared/types.ts @@ -0,0 +1,104 @@ +import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.mjs' + +/** + * Token usage tracking for LLM calls. + */ +export interface TokenUsage { + prompt: number + completion: number + total: number +} + +/** + * Details of a single tool call made during evaluation. + */ +export interface ToolCallDetail { + name: string + arguments: Record +} + +/** + * Result of LLM-based comparison/evaluation. + */ +export interface EvaluationResult { + success: boolean + resemblanceScore: number + statement: string + missingRequirements?: string[] + error?: string +} + +/** + * Base evaluation result that can be extended for domain-specific outputs. + * @template TOutput The domain-specific output type (e.g., flow definition, app files) + */ +export interface BaseEvalResult { + success: boolean + output: TOutput + error?: string + tokenUsage: TokenUsage + toolCallsCount: number + toolsCalled: string[] + toolCallDetails: ToolCallDetail[] + iterations: number + variantName: string + evaluationResult?: EvaluationResult + messages: ChatCompletionMessageParam[] +} + +/** + * Base configuration for a variant in eval testing. + * Allows customizing system prompt, tools, and model for comparison. + * + * Note: Domain-specific variants may extend this with custom tool configurations. + * See flow/flowEvalVariants.ts for an example with custom tools. + */ +export interface VariantConfig { + name: string + description?: string + + /** System prompt configuration */ + systemPrompt?: + | { type: 'default' } + | { type: 'default-with-custom'; custom: string } + | { type: 'custom'; content: string } + + /** Tools configuration - basic types supported by shared code */ + tools?: + | { type: 'default' } + | { type: 'subset'; include: string[] } + | { type: 'custom'; tools: unknown[] } + + /** Model to use (default: 'gpt-4o') */ + model?: string +} + +/** + * Options for running an evaluation. + */ +export interface EvalRunnerOptions { + /** Maximum iterations for tool call loop (default: 20) */ + maxIterations?: number + /** Model to use for LLM calls */ + model?: string + /** Workspace ID for tool calls */ + workspace?: string +} + +/** + * No-op tool callbacks for eval testing. + */ +export interface ToolCallbacks { + setToolStatus: (id: string, status: { content?: string; result?: string; error?: string }) => void + removeToolStatus: (id: string) => void +} + +/** + * Creates no-op tool callbacks for eval testing. + */ +export function createNoOpToolCallbacks(): ToolCallbacks { + return { + setToolStatus: () => {}, + removeToolStatus: () => {} + } +} diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalFlowComparison.ts b/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalFlowComparison.ts deleted file mode 100644 index fcb8c317d4..0000000000 --- a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalFlowComparison.ts +++ /dev/null @@ -1,145 +0,0 @@ -import OpenAI from 'openai' - -export interface EvalComparisonResult { - success: boolean - resemblanceScore: number - statement: string - missingRequirements?: string[] - error?: string -} - -interface ExpectedFlow { - summary?: string - value: { - modules: unknown[] - } - schema?: unknown -} - -const EVALUATOR_SYSTEM_PROMPT = `You are an expert evaluator for Windmill flow definitions. Your task is to evaluate a generated flow against: -1. The original user request/prompt -2. An expected reference flow - -## Windmill Flow Context -- Flows consist of modules (steps) that execute sequentially -- Module types include: rawscript, forloopflow, branchone, branchall, script, flow, aiagent -- Each module has an id, value (containing type and config), and may have input_transforms -- input_transforms connect modules using expressions like "results.previous_step". Valid input_transforms are: static, javascript. Valid variables in javascript expressions are: results, flow_input, flow_input.iter.value (for forloopflow), flow_input.iter.index (for forloopflow). -- forloopflow contains nested modules that execute per iteration with access to flow_input.iter.value -- branchone executes first matching branch, branchall executes all matching branches -- Branches have conditional expressions (expr) that determine execution -- aiagent modules contain tools array with tool definitions - -## Evaluation Criteria -1. **User Request Fulfillment**: Does the generated flow address ALL requirements from the user's original prompt? - - Are all requested steps present? - - Are the requested features implemented (loops, branches, specific logic)? - - Does the schema match what the user requested for inputs? -2. **Structure**: Are the module types and nesting structure appropriate for the task? -3. **Logic**: Does the flow accomplish the intended logical task? -4. **Connections**: Are input_transforms connecting data correctly between steps? -5. **Completeness**: Are all required steps present with no major omissions? -6. **Code Quality**: Is the code functionally correct (exact syntax doesn't need to match)? - -## Important Notes -- Minor differences in variable names, code formatting, or exact wording are acceptable -- Focus on functional equivalence, not character-by-character matching -- The generated flow should achieve the same outcome as described in the user request -- Extra helper steps or slightly different approaches can still score high if they accomplish the goal -- If the user requested specific module types (like aiagent), verify they are used correctly - -## Response Format -You MUST respond with valid JSON only, no additional text: -{ - "resemblanceScore": <0-100 integer>, - "statement": "", - "missingRequirements": [""] -} - -Score guidelines: -- 90-100: Fully addresses user request, functionally equivalent to expected flow -- 70-89: Addresses most user requirements, same overall structure with minor differences -- 50-69: Partially addresses user request, achieves similar goal but different approach -- 30-49: Missing significant requirements from user request -- 0-29: Does not address user request or significantly incorrect` - -/** - * Evaluates how well a generated flow matches an expected flow and user request using an LLM. - * Returns a resemblance score (0-100), a qualitative statement, and any missing requirements. - */ -export async function evaluateFlowComparison( - generatedFlow: ExpectedFlow, - expectedFlow: ExpectedFlow, - userPrompt: string -): Promise { - const model = 'anthropic/claude-sonnet-4.5' - // @ts-ignore - const apiKey = process.env.OPENROUTER_API_KEY - const client = new OpenAI({ baseURL: 'https://openrouter.ai/api/v1', apiKey }) - - const userMessage = `## User's Original Request -${userPrompt} - -## Expected Reference Flow -\`\`\`json -${JSON.stringify(expectedFlow, null, 2)} -\`\`\` - -## Generated Flow -\`\`\`json -${JSON.stringify(generatedFlow, null, 2)} -\`\`\` - -Please evaluate how well the generated flow: -1. Fulfills ALL requirements from the user's original request -2. Matches the structure and logic of the expected reference flow` - - try { - const response = await client.chat.completions.create({ - model, - messages: [ - { role: 'system', content: EVALUATOR_SYSTEM_PROMPT }, - { role: 'user', content: userMessage } - ], - temperature: 0 - }) - - const content = response.choices[0]?.message?.content - if (!content) { - return { - success: false, - resemblanceScore: 0, - statement: 'No response from evaluator', - error: 'Empty response from LLM' - } - } - - // Parse JSON response - handle potential markdown code blocks - let jsonContent = content.trim() - if (jsonContent.startsWith('```')) { - // Remove markdown code block wrapper - jsonContent = jsonContent.replace(/^```(?:json)?\n?/, '').replace(/\n?```$/, '') - } - - const parsed = JSON.parse(jsonContent) as { - resemblanceScore: number - statement: string - missingRequirements?: string[] - } - - return { - success: true, - resemblanceScore: Math.max(0, Math.min(100, Math.round(parsed.resemblanceScore))), - statement: parsed.statement, - missingRequirements: parsed.missingRequirements ?? [] - } - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - return { - success: false, - resemblanceScore: 0, - statement: 'Evaluation failed', - error: errorMessage - } - } -} diff --git a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalVariants.ts b/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalVariants.ts deleted file mode 100644 index f0bcd02f66..0000000000 --- a/frontend/src/lib/components/copilot/chat/flow/__tests__/eval/evalVariants.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' -import type { ChatCompletionTool } from 'openai/resources/chat/completions.mjs' -import { flowTools, prepareFlowSystemMessage } from '../../core' -import type { Tool } from '../../../shared' -import type { FlowAIChatHelpers } from '../../core' - -/** - * Configuration for a variant in eval testing. - * Allows customizing system prompt, tools, and model for comparison. - */ -export interface VariantConfig { - name: string - description?: string - - /** System prompt configuration */ - systemPrompt?: - | { type: 'default' } - | { type: 'default-with-custom'; custom: string } - | { type: 'custom'; content: string } - - /** Tools configuration */ - tools?: - | { type: 'default' } - | { type: 'subset'; include: string[] } - | { type: 'custom'; tools: Tool[] } - - /** Model to use (default: 'gpt-4o') */ - model?: string -} - -/** - * Resolves system prompt from variant config. - * Returns the appropriate ChatCompletionSystemMessageParam based on config. - */ -export function resolveSystemPrompt( - variant?: VariantConfig, - fallbackCustomPrompt?: string -): ChatCompletionSystemMessageParam { - if (!variant?.systemPrompt || variant.systemPrompt.type === 'default') { - return prepareFlowSystemMessage(fallbackCustomPrompt) - } - - if (variant.systemPrompt.type === 'default-with-custom') { - return prepareFlowSystemMessage(variant.systemPrompt.custom) - } - - // type === 'custom' - return { - role: 'system', - content: variant.systemPrompt.content - } -} - -/** - * Resolves tools from variant config. - * Returns both the tool definitions (for API) and full tools (for execution). - */ -export function resolveTools(variant?: VariantConfig): { - toolDefs: ChatCompletionTool[] - tools: Tool[] -} { - if (!variant?.tools || variant.tools.type === 'default') { - return { - toolDefs: flowTools.map((t) => t.def), - tools: flowTools - } - } - - if (variant.tools.type === 'subset') { - const subset = flowTools.filter( - (t) => - variant.tools!.type === 'subset' && - (variant.tools as { type: 'subset'; include: string[] }).include.includes( - t.def.function.name - ) - ) - return { - toolDefs: subset.map((t) => t.def), - tools: subset - } - } - - // type === 'custom' - const customTools = variant.tools.tools - - return { - toolDefs: customTools.map((t) => t.def), - tools: customTools - } -} - -/** - * Resolves model from variant config with fallback. - */ -export function resolveModel(variant?: VariantConfig, fallback?: string): string { - return variant?.model ?? fallback ?? 'gpt-4o' -}