diff --git a/.github/workflows/backend-release.yml b/.github/workflows/backend-release.yml index a2cac06..e3b345d 100644 --- a/.github/workflows/backend-release.yml +++ b/.github/workflows/backend-release.yml @@ -2,7 +2,7 @@ name: Backend Release on: push: - branches: [master] + branches: [master, develop] paths: - 'apps/backend/**' - 'packages/shared/**' @@ -18,17 +18,13 @@ on: type: boolean default: false -permissions: - contents: write - issues: write - pull-requests: write - id-token: write - jobs: test: name: Test & Build runs-on: ubuntu-latest if: ${{ !inputs.skip_tests }} + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@v4 @@ -60,8 +56,13 @@ jobs: release: name: Release & Publish needs: test - if: ${{ github.event_name != 'pull_request' && (always() && (needs.test.result == 'success' || inputs.skip_tests)) }} + if: ${{ github.ref == 'refs/heads/master' && github.event_name != 'pull_request' && (always() && (needs.test.result == 'success' || inputs.skip_tests)) }} runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + id-token: write steps: - name: Checkout uses: actions/checkout@v4 diff --git a/.github/workflows/extension-release.yml b/.github/workflows/extension-release.yml index 9edd6df..4727ac5 100644 --- a/.github/workflows/extension-release.yml +++ b/.github/workflows/extension-release.yml @@ -2,7 +2,7 @@ name: Extension Release on: push: - branches: [master] + branches: [master, develop] paths: - 'apps/extension/**' - 'packages/shared/**' @@ -65,7 +65,7 @@ jobs: release: name: Release needs: test - if: ${{ github.event_name != 'pull_request' && (always() && (needs.test.result == 'success' || inputs.skip_tests)) }} + if: ${{ github.ref == 'refs/heads/master' && github.event_name != 'pull_request' && (always() && (needs.test.result == 'success' || inputs.skip_tests)) }} runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.gitignore b/.gitignore index 68658b6..912eadf 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ dist/ .output/ *.tsbuildinfo +# TypeScript declaration files +*.d.ts +*.d.ts.map + # WXT .wxt/ diff --git a/apps/backend/package.json b/apps/backend/package.json index 9bf04b9..c26b31a 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -43,22 +43,22 @@ "test": "vitest", "test:coverage": "vitest --coverage", "typecheck": "tsc --noEmit", - "lint": "eslint src/", + "lint": "biome check src/", "clean": "rm -rf dist", "prepublishOnly": "pnpm run build", "release": "semantic-release" }, "dependencies": { "@fastify/cors": "^10.0.2", - "@github/copilot": "^1.0.7", - "@github/copilot-sdk": "^0.1.32", + "@github/copilot": "^1.0.20", + "@github/copilot-sdk": "^0.2.1", "better-sqlite3": "^11.7.0", - "drizzle-orm": "^0.38.4", - "fastify": "^5.8.1", + "drizzle-orm": "^0.41.0", + "fastify": "^5.8.4", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "sharp": "^0.34.5", - "zod": "^3.24.1" + "zod": "^3.25.0" }, "devDependencies": { "@devmentorai/shared": "workspace:*", @@ -66,18 +66,18 @@ "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", - "@semantic-release/github": "^12.0.5", + "@semantic-release/github": "^12.0.6", "@semantic-release/npm": "^13.1.4", "@semantic-release/release-notes-generator": "^14.1.0", "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.5", "@types/sharp": "^0.32.0", "conventional-changelog-conventionalcommits": "^9.1.0", - "drizzle-kit": "^0.30.2", + "drizzle-kit": "^0.31.0", "semantic-release": "^25.0.3", "tsup": "^8.5.1", "tsx": "^4.19.2", "typescript": "^5.7.3", - "vitest": "^2.1.8" + "vitest": "^4.1.4" } } diff --git a/apps/backend/src/cli.ts b/apps/backend/src/cli.ts index 0b9b82c..40c29f0 100644 --- a/apps/backend/src/cli.ts +++ b/apps/backend/src/cli.ts @@ -12,11 +12,11 @@ * doctor Check system requirements */ +import { doctorCommand } from './cli/doctor.js'; +import { logsCommand } from './cli/logs.js'; import { startCommand } from './cli/start.js'; -import { stopCommand } from './cli/stop.js'; import { statusCommand } from './cli/status.js'; -import { logsCommand } from './cli/logs.js'; -import { doctorCommand } from './cli/doctor.js'; +import { stopCommand } from './cli/stop.js'; const VERSION = '1.0.0'; @@ -60,7 +60,17 @@ async function main(): Promise { process.exit(0); } - const options = parseOptions(args.slice(command === 'start' || command === 'stop' || command === 'status' || command === 'logs' || command === 'doctor' ? 1 : 0)); + const options = parseOptions( + args.slice( + command === 'start' || + command === 'stop' || + command === 'status' || + command === 'logs' || + command === 'doctor' + ? 1 + : 0 + ) + ); try { switch (command) { @@ -102,8 +112,8 @@ function parseOptions(args: string[]): CliOptions { for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--port': - options.port = parseInt(args[++i], 10); - if (isNaN(options.port)) { + options.port = Number.parseInt(args[++i], 10); + if (Number.isNaN(options.port)) { console.error('Error: --port requires a valid number'); process.exit(1); } @@ -114,7 +124,7 @@ function parseOptions(args: string[]): CliOptions { break; case '--lines': case '-n': - options.lines = parseInt(args[++i], 10); + options.lines = Number.parseInt(args[++i], 10); break; } } diff --git a/apps/backend/src/cli/doctor.ts b/apps/backend/src/cli/doctor.ts index c213596..3f3e59b 100644 --- a/apps/backend/src/cli/doctor.ts +++ b/apps/backend/src/cli/doctor.ts @@ -6,8 +6,8 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import net from 'node:net'; -import { DATA_DIR, LOG_DIR, IMAGES_DIR } from '../lib/paths.js'; import { DEFAULT_CONFIG } from '@devmentorai/shared'; +import { DATA_DIR, IMAGES_DIR, LOG_DIR } from '../lib/paths.js'; const DEFAULT_PORT = DEFAULT_CONFIG.DEFAULT_PORT; @@ -90,9 +90,17 @@ function checkPort(port: number): Promise { const server = net.createServer(); server.once('error', (err: NodeJS.ErrnoException) => { if (err.code === 'EADDRINUSE') { - resolve({ name: `Port ${port}`, status: 'warn', message: `Port ${port} is in use (server may already be running)` }); + resolve({ + name: `Port ${port}`, + status: 'warn', + message: `Port ${port} is in use (server may already be running)`, + }); } else { - resolve({ name: `Port ${port}`, status: 'fail', message: `Cannot bind port ${port}: ${err.message}` }); + resolve({ + name: `Port ${port}`, + status: 'fail', + message: `Cannot bind port ${port}: ${err.message}`, + }); } }); server.once('listening', () => { @@ -106,12 +114,19 @@ function checkPort(port: number): Promise { function checkCopilotCli(): CheckResult { try { - const version = execSync('github-copilot --version 2>/dev/null || copilot --version 2>/dev/null', { - encoding: 'utf-8', - timeout: 5000, - }).trim(); + const version = execSync( + 'github-copilot --version 2>/dev/null || copilot --version 2>/dev/null', + { + encoding: 'utf-8', + timeout: 5000, + } + ).trim(); return { name: 'Copilot CLI', status: 'pass', message: version || 'installed' }; } catch { - return { name: 'Copilot CLI', status: 'warn', message: 'Not found — server will run in mock mode' }; + return { + name: 'Copilot CLI', + status: 'warn', + message: 'Not found — server will run in mock mode', + }; } } diff --git a/apps/backend/src/cli/logs.ts b/apps/backend/src/cli/logs.ts index bab5d04..c652680 100644 --- a/apps/backend/src/cli/logs.ts +++ b/apps/backend/src/cli/logs.ts @@ -4,8 +4,8 @@ */ import fs from 'node:fs'; -import { LOG_FILE } from '../lib/paths.js'; import type { CliOptions } from '../cli.js'; +import { LOG_FILE } from '../lib/paths.js'; export async function logsCommand(options: CliOptions): Promise { const lines = options.lines || 50; diff --git a/apps/backend/src/cli/start.ts b/apps/backend/src/cli/start.ts index 333e74d..b734e1c 100644 --- a/apps/backend/src/cli/start.ts +++ b/apps/backend/src/cli/start.ts @@ -3,10 +3,10 @@ * Starts the DevMentorAI server in background or foreground mode. */ +import { DEFAULT_CONFIG, checkForUpdate } from '@devmentorai/shared'; import type { CliOptions } from '../cli.js'; import { isServerRunning, spawnServer, waitForHealthy } from '../lib/daemon.js'; import { LOG_FILE } from '../lib/paths.js'; -import { DEFAULT_CONFIG, checkForUpdate } from '@devmentorai/shared'; import { BACKEND_VERSION } from '../version.js'; const DEFAULT_PORT = DEFAULT_CONFIG.DEFAULT_PORT; @@ -16,8 +16,8 @@ async function showUpdateNotice(): Promise { const info = await checkForUpdate('backend', BACKEND_VERSION); if (info.hasUpdate) { console.log(`\n ⚠ Update available: ${BACKEND_VERSION} → ${info.latestVersion}`); - console.log(` Run: npx devmentorai-server@latest`); - console.log(` Or: npm install -g devmentorai-server@latest`); + console.log(' Run: npx devmentorai-server@latest'); + console.log(' Or: npm install -g devmentorai-server@latest'); console.log(` ${info.releaseUrl}\n`); } } catch { @@ -30,7 +30,7 @@ async function showAuthNotice(port: number): Promise { const response = await fetch(`http://127.0.0.1:${port}/api/account/auth`); if (!response.ok) return; - const payload = await response.json() as { + const payload = (await response.json()) as { success?: boolean; data?: { isAuthenticated?: boolean; login?: string | null }; }; @@ -85,13 +85,13 @@ export async function startCommand(options: CliOptions): Promise { const healthy = await waitForHealthy(port); if (healthy) { - console.log(`✓ Server started successfully`); + console.log('✓ Server started successfully'); console.log(` → http://127.0.0.1:${port}`); console.log(` Logs: ${LOG_FILE}\n`); await showAuthNotice(port); await showUpdateNotice(); } else { - console.error(`✗ Server started but healthcheck failed`); + console.error('✗ Server started but healthcheck failed'); console.error(` Check logs: ${LOG_FILE}\n`); process.exit(1); } diff --git a/apps/backend/src/cli/status.ts b/apps/backend/src/cli/status.ts index 4a9a826..6514fb5 100644 --- a/apps/backend/src/cli/status.ts +++ b/apps/backend/src/cli/status.ts @@ -3,9 +3,9 @@ * Shows the current status of the DevMentorAI server. */ -import { isServerRunning, readPid, healthcheck } from '../lib/daemon.js'; -import { PID_FILE, LOG_FILE, DATA_DIR } from '../lib/paths.js'; import { DEFAULT_CONFIG } from '@devmentorai/shared'; +import { healthcheck, isServerRunning, readPid } from '../lib/daemon.js'; +import { DATA_DIR, LOG_FILE, PID_FILE } from '../lib/paths.js'; const DEFAULT_PORT = DEFAULT_CONFIG.DEFAULT_PORT; @@ -26,7 +26,7 @@ export async function statusCommand(): Promise { const pid = readPid(); const health = await healthcheck(DEFAULT_PORT); - console.log(` Status: ✓ running`); + console.log(' Status: ✓ running'); console.log(` PID: ${pid || 'unknown'}`); console.log(` Port: ${DEFAULT_PORT}`); console.log(` URL: http://127.0.0.1:${DEFAULT_PORT}`); @@ -38,7 +38,9 @@ export async function statusCommand(): Promise { console.log(` Uptime: ${formatUptime(data.uptime as number)}`); } if (data.copilotConnected !== undefined) { - console.log(` Copilot: ${data.copilotConnected ? '✓ connected' : '⊘ disconnected (mock mode)'}`); + console.log( + ` Copilot: ${data.copilotConnected ? '✓ connected' : '⊘ disconnected (mock mode)'}` + ); } } diff --git a/apps/backend/src/cli/stop.ts b/apps/backend/src/cli/stop.ts index a40460f..1cf04a7 100644 --- a/apps/backend/src/cli/stop.ts +++ b/apps/backend/src/cli/stop.ts @@ -3,8 +3,8 @@ * Stops the running DevMentorAI server. */ -import { isServerRunning, stopServer, readPid } from '../lib/daemon.js'; import { DEFAULT_CONFIG } from '@devmentorai/shared'; +import { isServerRunning, readPid, stopServer } from '../lib/daemon.js'; const DEFAULT_PORT = DEFAULT_CONFIG.DEFAULT_PORT; diff --git a/apps/backend/src/db/index.ts b/apps/backend/src/db/index.ts index 98880bd..579c8b1 100644 --- a/apps/backend/src/db/index.ts +++ b/apps/backend/src/db/index.ts @@ -1,7 +1,7 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import Database from 'better-sqlite3'; -import path from 'path'; -import os from 'os'; -import fs from 'fs'; const DB_DIR = path.join(os.homedir(), '.devmentorai'); const DB_PATH = path.join(DB_DIR, 'devmentorai.db'); @@ -15,10 +15,10 @@ export function initDatabase(): Database.Database { } const db = new Database(DB_PATH); - + // Enable WAL mode for better performance db.pragma('journal_mode = WAL'); - + // Create tables db.exec(` CREATE TABLE IF NOT EXISTS sessions ( @@ -64,6 +64,34 @@ export function initDatabase(): Database.Database { CREATE INDEX IF NOT EXISTS idx_session_contexts_extracted_at ON session_contexts(extracted_at); `); + // Migration: Add tone, explain_tradeoffs, reasoning_effort columns if they don't exist + try { + db.exec(` + ALTER TABLE sessions ADD COLUMN tone TEXT DEFAULT 'balanced'; + `); + console.log('[DB] Migration: Added tone column'); + } catch { + // Column already exists + } + + try { + db.exec(` + ALTER TABLE sessions ADD COLUMN explain_tradeoffs INTEGER DEFAULT 0; + `); + console.log('[DB] Migration: Added explain_tradeoffs column'); + } catch { + // Column already exists + } + + try { + db.exec(` + ALTER TABLE sessions ADD COLUMN reasoning_effort TEXT; + `); + console.log('[DB] Migration: Added reasoning_effort column'); + } catch { + // Column already exists + } + return db; } diff --git a/apps/backend/src/lib/daemon.ts b/apps/backend/src/lib/daemon.ts index 7291c2f..0790909 100644 --- a/apps/backend/src/lib/daemon.ts +++ b/apps/backend/src/lib/daemon.ts @@ -4,12 +4,12 @@ * Handles PID file, process spawning, and healthcheck for the background server. */ -import { fork, type ChildProcess } from 'node:child_process'; +import { type ChildProcess, fork } from 'node:child_process'; import fs from 'node:fs'; -import path from 'node:path'; import http from 'node:http'; -import { PID_FILE, LOG_FILE, LOG_DIR, ensureDir } from './paths.js'; +import path from 'node:path'; import { DEFAULT_CONFIG } from '@devmentorai/shared'; +import { LOG_DIR, LOG_FILE, PID_FILE, ensureDir } from './paths.js'; const DEFAULT_PORT = DEFAULT_CONFIG.DEFAULT_PORT; @@ -22,8 +22,8 @@ export function writePid(pid: number): void { export function readPid(): number | null { try { const content = fs.readFileSync(PID_FILE, 'utf-8').trim(); - const pid = parseInt(content, 10); - return isNaN(pid) ? null : pid; + const pid = Number.parseInt(content, 10); + return Number.isNaN(pid) ? null : pid; } catch { return null; } @@ -49,14 +49,19 @@ export function isProcessRunning(pid: number): boolean { } /** HTTP healthcheck against the running server */ -export function healthcheck(port: number = DEFAULT_PORT, timeoutMs: number = 3000): Promise<{ +export function healthcheck( + port: number = DEFAULT_PORT, + timeoutMs = 3000 +): Promise<{ ok: boolean; data?: Record; }> { return new Promise((resolve) => { const req = http.get(`http://127.0.0.1:${port}/api/health`, { timeout: timeoutMs }, (res) => { let body = ''; - res.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + res.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); res.on('end', () => { try { const data = JSON.parse(body); @@ -130,7 +135,10 @@ export function spawnServer(port: number = DEFAULT_PORT): ChildProcess { } /** Wait for the server to become healthy */ -export async function waitForHealthy(port: number = DEFAULT_PORT, maxWaitMs: number = 10000): Promise { +export async function waitForHealthy( + port: number = DEFAULT_PORT, + maxWaitMs = 10000 +): Promise { const start = Date.now(); const interval = 500; diff --git a/apps/backend/src/lib/paths.ts b/apps/backend/src/lib/paths.ts index 5e34d55..e4aa6c0 100644 --- a/apps/backend/src/lib/paths.ts +++ b/apps/backend/src/lib/paths.ts @@ -1,13 +1,13 @@ /** * Cross-Platform Path Utilities - * + * * Provides consistent path handling across Windows, macOS, and Linux. * Uses the same pattern as db/index.ts for data directory location. */ -import path from 'node:path'; -import os from 'node:os'; import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; /** Base data directory: ~/.devmentorai */ export const DATA_DIR = path.join(os.homedir(), '.devmentorai'); diff --git a/apps/backend/src/native/host.ts b/apps/backend/src/native/host.ts index 0bb6750..2be23a3 100644 --- a/apps/backend/src/native/host.ts +++ b/apps/backend/src/native/host.ts @@ -1,19 +1,19 @@ /** * Native Messaging Host for DevMentorAI - * + * * This module implements Chrome Native Messaging protocol for direct * communication between the extension and local Node.js backend. - * + * * Protocol: Messages are prefixed with 4-byte length (little-endian uint32) - * + * * Usage: * node native-host.js - * + * * The host reads JSON messages from stdin and writes responses to stdout. */ -import { createServer } from '../server.js'; import type { FastifyInstance } from 'fastify'; +import { createServer } from '../server.js'; interface NativeMessage { id: string; @@ -73,7 +73,7 @@ class NativeMessagingHost { } const messageLength = lengthBuffer.readUInt32LE(0); - + if (messageLength === 0) { resolve(null); return; @@ -81,11 +81,11 @@ class NativeMessagingHost { // Read message body let messageBuffer = Buffer.alloc(0); - + const readBody = () => { const remaining = messageLength - messageBuffer.length; const bodyChunk = process.stdin.read(remaining); - + if (bodyChunk === null) { process.stdin.once('readable', readBody); return; @@ -154,16 +154,16 @@ class NativeMessagingHost { try { // Route the request through Fastify's inject method const response = await this.app.inject({ - method: message.method as any, + method: message.method as 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', url: message.path, - payload: message.body as any, + payload: message.body as Record | undefined, headers: { 'content-type': 'application/json', }, }); // Check if this is a streaming response - const contentType = (response.headers as any)['content-type']; + const contentType = response.headers['content-type']; if (message.type === 'stream' && contentType?.includes('text/event-stream')) { // Handle SSE streaming await this.handleStreamResponse(message.id, String(response.payload)); @@ -200,7 +200,7 @@ class NativeMessagingHost { this.activeStreams.set(id, controller); const lines = payload.split('\n'); - + for (const line of lines) { if (controller.signal.aborted) break; @@ -253,7 +253,7 @@ class NativeMessagingHost { // Connection closed break; } - + // Process message asynchronously but catch synchronous errors from the promise initialization try { this.handleMessage(message).catch((err) => { diff --git a/apps/backend/src/native/install-native-host.ts b/apps/backend/src/native/install-native-host.ts index 8d864dd..139cbb6 100644 --- a/apps/backend/src/native/install-native-host.ts +++ b/apps/backend/src/native/install-native-host.ts @@ -1,19 +1,19 @@ #!/usr/bin/env node /** * Native Messaging Host Installation Script - * + * * This script installs the Native Messaging host manifest in the appropriate * location for Chrome/Chromium browsers on different operating systems. - * + * * Usage: * node install-native-host.js * node install-native-host.js --uninstall */ -import * as fs from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import { fileURLToPath } from 'url'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -40,13 +40,14 @@ function getManifestPaths(): ManifestPaths { chrome: path.join(home, '.config/google-chrome/NativeMessagingHosts'), chromium: path.join(home, '.config/chromium/NativeMessagingHosts'), }; - case 'win32': + case 'win32': { // Windows uses registry, but we'll use the user-level manifest location const appData = process.env.LOCALAPPDATA || path.join(home, 'AppData/Local'); return { chrome: path.join(appData, 'Google/Chrome/User Data/NativeMessagingHosts'), chromium: path.join(appData, 'Chromium/User Data/NativeMessagingHosts'), }; + } default: throw new Error(`Unsupported platform: ${platform}`); } @@ -55,7 +56,7 @@ function getManifestPaths(): ManifestPaths { function createManifest(extensionId: string): object { // Get the path to the native host executable const hostPath = path.resolve(__dirname, 'host.js'); - + // On Windows, we need a batch wrapper let executablePath: string; if (os.platform() === 'win32') { @@ -157,7 +158,9 @@ if (args.includes('--uninstall')) { const extensionId = args[0]; if (!/^[a-z]{32}$/i.test(extensionId)) { console.error('Error: Invalid extension ID format'); - console.error('Extension ID should be 32 lowercase letters (e.g., abcdefghijklmnopqrstuvwxyzabcdef)'); + console.error( + 'Extension ID should be 32 lowercase letters (e.g., abcdefghijklmnopqrstuvwxyzabcdef)' + ); process.exit(1); } install(extensionId); diff --git a/apps/backend/src/native/manifest.template.json b/apps/backend/src/native/manifest.template.json index 4960487..8bb45c2 100644 --- a/apps/backend/src/native/manifest.template.json +++ b/apps/backend/src/native/manifest.template.json @@ -3,7 +3,5 @@ "description": "DevMentorAI Native Messaging Host - Enables direct communication between the DevMentorAI browser extension and the local backend", "path": "PLACEHOLDER_PATH", "type": "stdio", - "allowed_origins": [ - "chrome-extension://PLACEHOLDER_EXTENSION_ID/" - ] + "allowed_origins": ["chrome-extension://PLACEHOLDER_EXTENSION_ID/"] } diff --git a/apps/backend/src/routes/account.ts b/apps/backend/src/routes/account.ts index d0d9ca7..b0e637b 100644 --- a/apps/backend/src/routes/account.ts +++ b/apps/backend/src/routes/account.ts @@ -1,5 +1,5 @@ -import type { FastifyInstance } from 'fastify'; import type { ApiResponse, CopilotAuthStatus, CopilotQuotaStatus } from '@devmentorai/shared'; +import type { FastifyInstance } from 'fastify'; export async function accountRoutes(fastify: FastifyInstance): Promise { fastify.get<{ diff --git a/apps/backend/src/routes/chat.ts b/apps/backend/src/routes/chat.ts index 9fa9839..ed944d7 100644 --- a/apps/backend/src/routes/chat.ts +++ b/apps/backend/src/routes/chat.ts @@ -1,23 +1,23 @@ -import type { FastifyInstance } from 'fastify'; -import { z } from 'zod'; -import type { SessionEvent } from '@github/copilot-sdk'; import type { ApiResponse, + ImageAttachment, Message, SendMessageRequest, StreamEvent, - ImageAttachment, } from '@devmentorai/shared'; +import type { SessionEvent } from '@github/copilot-sdk'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; import { buildContextAwarePrompt, buildSimplePrompt, - validateContext, sanitizeContext, + validateContext, } from '../services/context-prompt-builder.js'; import { + type ProcessedImage, processMessageImages, toImageAttachments, - type ProcessedImage, } from '../services/thumbnail-service.js'; // Schema for simple context (backward compatible) @@ -25,92 +25,100 @@ const simpleContextSchema = z.object({ pageUrl: z.string().optional(), pageTitle: z.string().optional(), selectedText: z.string().optional(), - action: z.enum([ - 'explain', - 'translate', - 'rewrite', - 'fix_grammar', - 'summarize', - 'expand', - 'analyze_config', - 'diagnose_error', - ]).optional(), + action: z + .enum([ + 'explain', + 'translate', + 'rewrite', + 'fix_grammar', + 'summarize', + 'expand', + 'analyze_config', + 'diagnose_error', + ]) + .optional(), }); // Schema for full context payload -const fullContextSchema = z.object({ - metadata: z.object({ - captureTimestamp: z.string(), - captureMode: z.enum(['auto', 'manual']), - browserInfo: z.object({ - userAgent: z.string(), - viewport: z.object({ width: z.number(), height: z.number() }), - language: z.string(), +const fullContextSchema = z + .object({ + metadata: z.object({ + captureTimestamp: z.string(), + captureMode: z.enum(['auto', 'manual']), + browserInfo: z.object({ + userAgent: z.string(), + viewport: z.object({ width: z.number(), height: z.number() }), + language: z.string(), + }), }), - }), - page: z.object({ - url: z.string(), - urlParsed: z.object({ - protocol: z.string(), - hostname: z.string(), - pathname: z.string(), - search: z.string(), - hash: z.string(), + page: z.object({ + url: z.string(), + urlParsed: z.object({ + protocol: z.string(), + hostname: z.string(), + pathname: z.string(), + search: z.string(), + hash: z.string(), + }), + title: z.string(), + platform: z.object({ + type: z.string(), + confidence: z.number(), + indicators: z.array(z.string()), + specificProduct: z.string().optional(), + }), }), - title: z.string(), - platform: z.object({ - type: z.string(), - confidence: z.number(), - indicators: z.array(z.string()), - specificProduct: z.string().optional(), + text: z.object({ + selectedText: z.string().optional(), + visibleText: z.string(), + headings: z.array( + z.object({ + level: z.number(), + text: z.string(), + }) + ), + errors: z.array( + z.object({ + type: z.enum(['error', 'warning', 'info']), + message: z.string(), + source: z.enum(['console', 'ui', 'network', 'dom']).optional(), + severity: z.enum(['critical', 'high', 'medium', 'low']), + context: z.string().optional(), + }) + ), + metadata: z.object({ + totalLength: z.number(), + truncated: z.boolean(), + }), }), - }), - text: z.object({ - selectedText: z.string().optional(), - visibleText: z.string(), - headings: z.array(z.object({ - level: z.number(), - text: z.string(), - })), - errors: z.array(z.object({ - type: z.enum(['error', 'warning', 'info']), - message: z.string(), - source: z.enum(['console', 'ui', 'network', 'dom']).optional(), - severity: z.enum(['critical', 'high', 'medium', 'low']), - context: z.string().optional(), - })), - metadata: z.object({ - totalLength: z.number(), - truncated: z.boolean(), + structure: z.object({ + relevantSections: z.array(z.any()), + errorContainers: z.array(z.any()), + activeElements: z.any(), + metadata: z.any(), }), - }), - structure: z.object({ - relevantSections: z.array(z.any()), - errorContainers: z.array(z.any()), - activeElements: z.any(), - metadata: z.any(), - }), - session: z.object({ - sessionId: z.string(), - sessionType: z.string(), - intent: z.object({ - primary: z.string(), - keywords: z.array(z.string()), - implicitSignals: z.array(z.string()), - explicitGoal: z.string().optional(), + session: z.object({ + sessionId: z.string(), + sessionType: z.string(), + intent: z.object({ + primary: z.string(), + keywords: z.array(z.string()), + implicitSignals: z.array(z.string()), + explicitGoal: z.string().optional(), + }), + previousMessages: z.object({ + count: z.number(), + lastN: z.array(z.any()), + }), }), - previousMessages: z.object({ - count: z.number(), - lastN: z.array(z.any()), + privacy: z.object({ + redactedFields: z.array(z.string()), + sensitiveDataDetected: z.boolean(), + consentGiven: z.boolean(), + dataRetention: z.enum(['session', 'none']), }), - }), - privacy: z.object({ - redactedFields: z.array(z.string()), - sensitiveDataDetected: z.boolean(), - consentGiven: z.boolean(), - dataRetention: z.enum(['session', 'none']), - }), -}).passthrough(); + }) + .passthrough(); // Schema for image payload (inline data URL - legacy / small images) const imagePayloadSchema = z.object({ @@ -142,12 +150,20 @@ const sendMessageSchema = z.object({ }); export async function chatRoutes(fastify: FastifyInstance) { + interface ToolExecutionEventData { + toolName?: string; + toolCallId?: string; + } + // Helper to build the appropriate prompt based on context type // Returns userPrompt (enriched with context) and promptType // systemPrompt is always null - we don't override Copilot's default - const buildPrompt = ( - body: { prompt: string; context?: any; fullContext?: any; useContextAwareMode?: boolean } - ): { userPrompt: string; promptType: string } => { + const buildPrompt = (body: { + prompt: string; + context?: unknown; + fullContext?: unknown; + useContextAwareMode?: boolean; + }): { userPrompt: string; promptType: string } => { // If full context is provided and context-aware mode is enabled if (body.fullContext && body.useContextAwareMode !== false) { if (validateContext(body.fullContext)) { @@ -156,13 +172,18 @@ export async function chatRoutes(fastify: FastifyInstance) { return { userPrompt, promptType: 'context-aware' }; } } - + // Fall back to simple prompt + const simpleContext = body.context as { + pageUrl?: string; + pageTitle?: string; + selectedText?: string; + } | undefined; const { userPrompt } = buildSimplePrompt( body.prompt, - body.context?.pageUrl, - body.context?.pageTitle, - body.context?.selectedText + simpleContext?.pageUrl, + simpleContext?.pageTitle, + simpleContext?.selectedText ); return { userPrompt, promptType: 'simple' }; }; @@ -197,11 +218,13 @@ export async function chatRoutes(fastify: FastifyInstance) { sessionId, 'user', body.prompt, - body.context ? { - pageUrl: body.context.pageUrl, - selectedText: body.context.selectedText, - action: body.context.action, - } : undefined + body.context + ? { + pageUrl: body.context.pageUrl, + selectedText: body.context.selectedText, + action: body.context.action, + } + : undefined ); // Persist context if fullContext is provided (Phase 5) @@ -230,11 +253,7 @@ export async function chatRoutes(fastify: FastifyInstance) { ); // Save assistant message - const assistantMessage = fastify.sessionService.addMessage( - sessionId, - 'assistant', - response - ); + const assistantMessage = fastify.sessionService.addMessage(sessionId, 'assistant', response); return reply.send({ success: true, @@ -286,19 +305,21 @@ export async function chatRoutes(fastify: FastifyInstance) { // Build backend URL with port for image serving const host = request.headers.host || `${request.hostname}:3847`; const backendUrl = `http://${host}`; - + // We'll need the message ID before processing images // First save the user message without images const userMessage = fastify.sessionService.addMessage( sessionId, 'user', body.prompt, - body.context ? { - pageUrl: body.context.pageUrl, - selectedText: body.context.selectedText, - action: body.context.action, - contextAware: body.useContextAwareMode, - } : { contextAware: body.useContextAwareMode } + body.context + ? { + pageUrl: body.context.pageUrl, + selectedText: body.context.selectedText, + action: body.context.action, + contextAware: body.useContextAwareMode, + } + : { contextAware: body.useContextAwareMode } ); // Process images after we have the message ID @@ -313,10 +334,10 @@ export async function chatRoutes(fastify: FastifyInstance) { })); // Build metadata for message storage - processedImages = body.preUploadedImages.map(img => ({ + processedImages = body.preUploadedImages.map((img) => ({ id: img.id, source: 'screenshot' as const, - mimeType: (img.mimeType || 'image/jpeg') as any, + mimeType: (img.mimeType || 'image/jpeg') as ImageAttachment['mimeType'], dimensions: img.dimensions || { width: 0, height: 0 }, fileSize: img.fileSize || 0, timestamp: new Date().toISOString(), @@ -331,7 +352,9 @@ export async function chatRoutes(fastify: FastifyInstance) { } else if (body.images && body.images.length > 0) { // Inline image upload (legacy path for small images) try { - console.log(`[ChatRoute] Processing ${body.images.length} inline images for message ${userMessage.id}`); + console.log( + `[ChatRoute] Processing ${body.images.length} inline images for message ${userMessage.id}` + ); processedImagesRaw = await processMessageImages( sessionId, userMessage.id, @@ -339,28 +362,31 @@ export async function chatRoutes(fastify: FastifyInstance) { backendUrl ); processedImages = toImageAttachments(processedImagesRaw); - + // Update message metadata with images fastify.sessionService.updateMessageMetadata(userMessage.id, { ...userMessage.metadata, images: processedImages, }); - + copilotAttachments = processedImagesRaw.map((img, index) => ({ type: 'file' as const, path: img.fullImagePath, displayName: `image_${index + 1}.${img.mimeType.split('/')[1] || 'jpg'}`, })); - + console.log(`[ChatRoute] Processed ${processedImages.length} images successfully`); } catch (err) { console.error('[ChatRoute] Failed to process images:', err); // Continue without images - don't fail the message } } - + if (copilotAttachments.length > 0) { - console.log(`[ChatRoute] Built ${copilotAttachments.length} attachments for Copilot:`, copilotAttachments); + console.log( + `[ChatRoute] Built ${copilotAttachments.length} attachments for Copilot:`, + copilotAttachments + ); } // Persist context if fullContext is provided (Phase 5) @@ -403,19 +429,15 @@ export async function chatRoutes(fastify: FastifyInstance) { } }; - const endStream = (reason: string = 'completed') => { + const endStream = (reason = 'completed') => { if (streamEnded) return; streamEnded = true; - + console.log(`[ChatRoute] Stream ending: ${reason}. Content length: ${fullContent.length}`); - + // Save message if we have content if (fullContent && !assistantMessageId) { - const message = fastify.sessionService.addMessage( - sessionId, - 'assistant', - fullContent - ); + const message = fastify.sessionService.addMessage(sessionId, 'assistant', fullContent); assistantMessageId = message.id; } @@ -455,10 +477,10 @@ export async function chatRoutes(fastify: FastifyInstance) { // Handle Copilot events const handleEvent = (event: SessionEvent) => { if (streamEnded) return; - + console.log('[ChatRoute] Received event:', event.type); lastActivityTime = Date.now(); - + switch (event.type) { case 'assistant.message_delta': fullContent += event.data.deltaContent || ''; @@ -477,20 +499,26 @@ export async function chatRoutes(fastify: FastifyInstance) { break; case 'tool.execution_start': - sendSSE({ - type: 'tool_start', - data: { - toolName: (event.data as any).toolName, - toolCallId: (event.data as any).toolCallId, - }, - }); + { + const toolData = event.data as ToolExecutionEventData; + sendSSE({ + type: 'tool_start', + data: { + toolName: toolData.toolName, + toolCallId: toolData.toolCallId, + }, + }); + } break; case 'tool.execution_complete': - sendSSE({ - type: 'tool_complete', - data: { toolCallId: (event.data as any).toolCallId }, - }); + { + const toolData = event.data as ToolExecutionEventData; + sendSSE({ + type: 'tool_complete', + data: { toolCallId: toolData.toolCallId }, + }); + } break; case 'session.idle': @@ -524,7 +552,8 @@ export async function chatRoutes(fastify: FastifyInstance) { sendSSE({ type: 'error', data: { - error: streamError instanceof Error ? streamError.message : 'Failed to start stream', + error: + streamError instanceof Error ? streamError.message : 'Failed to start stream', }, }); endStream('copilot_error'); @@ -548,7 +577,6 @@ export async function chatRoutes(fastify: FastifyInstance) { // Wait for streaming to complete before allowing Fastify to close the connection await streamComplete; - } catch (error) { if (error instanceof z.ZodError) { return reply.code(400).send({ @@ -560,7 +588,7 @@ export async function chatRoutes(fastify: FastifyInstance) { }, }); } - + // Send error via SSE if (!reply.raw.headersSent) { reply.raw.setHeader('Content-Type', 'text/event-stream'); diff --git a/apps/backend/src/routes/health.ts b/apps/backend/src/routes/health.ts index 36a3d8d..d813ee3 100644 --- a/apps/backend/src/routes/health.ts +++ b/apps/backend/src/routes/health.ts @@ -1,6 +1,6 @@ -import type { FastifyInstance } from 'fastify'; import type { ApiResponse, HealthResponse } from '@devmentorai/shared'; import { checkForUpdate } from '@devmentorai/shared'; +import type { FastifyInstance } from 'fastify'; import { BACKEND_VERSION } from '../version.js'; const startTime = Date.now(); @@ -18,7 +18,7 @@ async function refreshUpdateInfo() { } // Check on startup and every hour -refreshUpdateInfo(); +void refreshUpdateInfo(); setInterval(refreshUpdateInfo, 60 * 60 * 1000); export async function healthRoutes(fastify: FastifyInstance) { @@ -26,16 +26,20 @@ export async function healthRoutes(fastify: FastifyInstance) { Reply: ApiResponse; }>('/health', async (_request, reply) => { const copilotService = fastify.copilotService; - + const healthData: HealthResponse = { status: copilotService.isReady() ? 'healthy' : 'degraded', version: BACKEND_VERSION, copilotConnected: copilotService.isReady() && !copilotService.isMockMode(), uptime: Math.floor((Date.now() - startTime) / 1000), timestamp: new Date().toISOString(), - ...(cachedUpdateInfo || {}), }; + if (cachedUpdateInfo) { + healthData.latestVersion = cachedUpdateInfo.latestVersion; + healthData.updateAvailable = cachedUpdateInfo.updateAvailable; + } + return reply.send({ success: true, data: healthData, diff --git a/apps/backend/src/routes/images.ts b/apps/backend/src/routes/images.ts index c3fdd75..f59b949 100644 --- a/apps/backend/src/routes/images.ts +++ b/apps/backend/src/routes/images.ts @@ -1,19 +1,19 @@ /** * Images Route - * + * * Serves thumbnail and full images stored on disk. * Also provides an upload endpoint for pre-uploading images before chat. - * + * * Routes: * GET /api/images/:sessionId/:messageId/thumb_:index.jpg - Thumbnail * GET /api/images/:sessionId/:messageId/image_:index.:ext - Full image * POST /api/images/upload/:sessionId/:messageId - Pre-upload images */ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { z } from 'zod'; import fs from 'node:fs'; import path from 'node:path'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; import { IMAGES_DIR } from '../lib/paths.js'; import { getThumbnailFilePath, @@ -29,11 +29,11 @@ interface ImageParams { // MIME types for supported image formats const MIME_TYPES: Record = { - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'png': 'image/png', - 'webp': 'image/webp', - 'gif': 'image/gif', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + gif: 'image/gif', }; // Schema for identifying a single image in an upload payload @@ -57,72 +57,71 @@ export async function imagesRoutes(fastify: FastifyInstance) { fastify.post<{ Params: { sessionId: string; messageId: string }; Body: z.infer; - }>( - '/upload/:sessionId/:messageId', - async (request, reply) => { - try { - const { sessionId, messageId } = request.params; - const body = uploadBodySchema.parse(request.body); - - // Build backend URL for image serving - const host = request.headers.host || `${request.hostname}:3847`; - const backendUrl = `http://${host}`; - - console.log(`[ImagesRoute] Pre-uploading ${body.images.length} images for ${sessionId}/${messageId}`); - - const processedImagesRaw = await processMessageImages( - sessionId, - messageId, - body.images, - backendUrl - ); - - const processedImages = toImageAttachments(processedImagesRaw); - - // Return the processed image metadata + absolute paths for Copilot SDK attachments - const responseImages = processedImagesRaw.map((img, i) => ({ - id: img.id, - thumbnailUrl: img.thumbnailUrl, - fullImageUrl: img.fullImageUrl, - fullImagePath: img.fullImagePath, - mimeType: img.mimeType, - dimensions: img.dimensions, - fileSize: img.fileSize, - })); - - console.log(`[ImagesRoute] Pre-upload complete: ${responseImages.length} images processed`); - - return reply.send({ - success: true, - data: { images: responseImages }, - }); - } catch (error) { - if (error instanceof z.ZodError) { - return reply.code(400).send({ - success: false, - error: { - code: 'VALIDATION_ERROR', - message: 'Invalid upload body', - details: error.errors, - }, - }); - } - console.error('[ImagesRoute] Upload failed:', error); - return reply.code(500).send({ + }>('/upload/:sessionId/:messageId', async (request, reply) => { + try { + const { sessionId, messageId } = request.params; + const body = uploadBodySchema.parse(request.body); + + // Build backend URL for image serving + const host = request.headers.host || `${request.hostname}:3847`; + const backendUrl = `http://${host}`; + + console.log( + `[ImagesRoute] Pre-uploading ${body.images.length} images for ${sessionId}/${messageId}` + ); + + const processedImagesRaw = await processMessageImages( + sessionId, + messageId, + body.images, + backendUrl + ); + + const processedImages = toImageAttachments(processedImagesRaw); + + // Return the processed image metadata + absolute paths for Copilot SDK attachments + const responseImages = processedImagesRaw.map((img, i) => ({ + id: img.id, + thumbnailUrl: img.thumbnailUrl, + fullImageUrl: img.fullImageUrl, + fullImagePath: img.fullImagePath, + mimeType: img.mimeType, + dimensions: img.dimensions, + fileSize: img.fileSize, + })); + + console.log(`[ImagesRoute] Pre-upload complete: ${responseImages.length} images processed`); + + return reply.send({ + success: true, + data: { images: responseImages }, + }); + } catch (error) { + if (error instanceof z.ZodError) { + return reply.code(400).send({ success: false, error: { - code: 'UPLOAD_ERROR', - message: error instanceof Error ? error.message : 'Failed to process images', + code: 'VALIDATION_ERROR', + message: 'Invalid upload body', + details: error.errors, }, }); } + console.error('[ImagesRoute] Upload failed:', error); + return reply.code(500).send({ + success: false, + error: { + code: 'UPLOAD_ERROR', + message: error instanceof Error ? error.message : 'Failed to process images', + }, + }); } - ); + }); /** * GET /api/images/:sessionId/:messageId/:filename * Serves thumbnail or full image - * + * * Filename patterns: * - thumb_0.jpg (thumbnail) * - image_0.jpg (full image) @@ -145,7 +144,7 @@ export async function imagesRoutes(fastify: FastifyInstance) { // Check for thumbnail: thumb_0.jpg const thumbMatch = filename.match(/^thumb_(\d+)\.jpg$/); if (thumbMatch) { - const imageIndex = parseInt(thumbMatch[1], 10); + const imageIndex = Number.parseInt(thumbMatch[1], 10); filePath = getThumbnailFilePath(sessionId, messageId, imageIndex); mimeType = 'image/jpeg'; } @@ -172,7 +171,7 @@ export async function imagesRoutes(fastify: FastifyInstance) { // Read and send the file try { const fileBuffer = fs.readFileSync(filePath); - + return reply .code(200) .header('Content-Type', mimeType) diff --git a/apps/backend/src/routes/models.ts b/apps/backend/src/routes/models.ts index ec154e2..3a70ea4 100644 --- a/apps/backend/src/routes/models.ts +++ b/apps/backend/src/routes/models.ts @@ -1,5 +1,5 @@ -import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { ApiResponse, ModelInfo, ModelPricingTier } from '@devmentorai/shared'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; const TIER_ORDER: ModelPricingTier[] = ['free', 'cheap', 'standard', 'premium']; @@ -25,7 +25,9 @@ function sortModelsByTierAndName(models: ModelInfo[]): ModelInfo[] { }); } -async function getModelsPayload(fastify: FastifyInstance): Promise<{ models: ModelInfo[]; default: string }> { +async function getModelsPayload( + fastify: FastifyInstance +): Promise<{ models: ModelInfo[]; default: string }> { const response = await fastify.copilotService.listModels(); if (!response.models || response.models.length === 0) { diff --git a/apps/backend/src/routes/sessions.ts b/apps/backend/src/routes/sessions.ts index 5c29c86..279942f 100644 --- a/apps/backend/src/routes/sessions.ts +++ b/apps/backend/src/routes/sessions.ts @@ -1,13 +1,13 @@ -import type { FastifyInstance } from 'fastify'; -import { z } from 'zod'; import type { ApiResponse, + CreateSessionRequest, + Message, PaginatedResponse, Session, - CreateSessionRequest, UpdateSessionRequest, - Message, } from '@devmentorai/shared'; +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; import { deleteSessionImages } from '../services/thumbnail-service.js'; const createSessionSchema = z.object({ @@ -15,12 +15,23 @@ const createSessionSchema = z.object({ type: z.enum(['devops', 'writing', 'development', 'general']), model: z.string().optional(), systemPrompt: z.string().optional(), + tone: z.enum(['concise', 'friendly', 'professional', 'technical', 'balanced']).optional(), + explainTradeoffs: z.boolean().optional(), + reasoningEffort: z.enum(['low', 'medium', 'high']).optional(), }); const updateSessionSchema = z.object({ name: z.string().min(1).max(100).optional(), status: z.enum(['active', 'paused', 'closed']).optional(), model: z.string().min(1).optional(), + tone: z.enum(['concise', 'friendly', 'professional', 'technical', 'balanced']).optional(), + explainTradeoffs: z.boolean().optional(), + reasoningEffort: z.enum(['low', 'medium', 'high']).nullable().optional(), +}); + +const switchModelSchema = z.object({ + model: z.string().min(1), + reasoningEffort: z.enum(['low', 'medium', 'high']).nullable().optional(), }); export async function sessionRoutes(fastify: FastifyInstance) { @@ -48,16 +59,20 @@ export async function sessionRoutes(fastify: FastifyInstance) { try { const body = createSessionSchema.parse(request.body); console.log('[sessionRoutes] Creating session with body:', body); - + // Create in database const session = fastify.sessionService.createSession(body); - + // Create Copilot session await fastify.copilotService.createCopilotSession( session.id, session.type, session.model, - session.systemPrompt + session.systemPrompt, + false, // enableMcp + session.tone, + session.explainTradeoffs, + session.reasoningEffort ); return reply.code(201).send({ @@ -122,16 +137,25 @@ export async function sessionRoutes(fastify: FastifyInstance) { }); } - if (body.model && body.model !== currentSession.model) { + const shouldReconfigureModel = body.model !== undefined || body.reasoningEffort !== undefined; + const nextModel = body.model ?? currentSession.model; + + if (shouldReconfigureModel) { await fastify.copilotService.switchSessionModel( currentSession.id, currentSession.type, - body.model, - currentSession.systemPrompt + nextModel, + currentSession.systemPrompt, + body.reasoningEffort ?? undefined ); } - const session = fastify.sessionService.updateSession(request.params.id, body); + const session = fastify.sessionService.updateSession(request.params.id, { + ...body, + ...(body.model !== undefined && body.reasoningEffort === undefined + ? { reasoningEffort: null } + : {}), + }); if (!session) { return reply.code(404).send({ @@ -168,7 +192,7 @@ export async function sessionRoutes(fastify: FastifyInstance) { Reply: ApiResponse<{ deleted: boolean; sessionId: string }>; }>('/sessions/:id', async (request, reply) => { const sessionId = request.params.id; - + console.log(`[SessionRoute] Deleting session: ${sessionId}`); // First check if session exists @@ -187,7 +211,7 @@ export async function sessionRoutes(fastify: FastifyInstance) { try { await fastify.copilotService.destroySession(sessionId); } catch (error) { - console.error(`[SessionRoute] Error destroying Copilot session:`, error); + console.error('[SessionRoute] Error destroying Copilot session:', error); // Continue with DB deletion even if Copilot cleanup fails } @@ -195,18 +219,18 @@ export async function sessionRoutes(fastify: FastifyInstance) { try { deleteSessionImages(sessionId); } catch (error) { - console.error(`[SessionRoute] Error deleting session images:`, error); + console.error('[SessionRoute] Error deleting session images:', error); // Continue with DB deletion even if image cleanup fails } // Delete from database (CASCADE will delete messages) const deleted = fastify.sessionService.deleteSession(sessionId); - + console.log(`[SessionRoute] Session ${sessionId} deleted: ${deleted}`); - return reply.code(200).send({ + return reply.code(200).send({ success: true, - data: { deleted, sessionId } + data: { deleted, sessionId }, }); }); @@ -237,16 +261,80 @@ export async function sessionRoutes(fastify: FastifyInstance) { session.id, session.type, session.model, - session.systemPrompt + session.systemPrompt, + false, + session.tone, + session.explainTradeoffs, + session.reasoningEffort ); } // Update status to active const updatedSession = fastify.sessionService.updateSession(sessionId, { status: 'active' }); + if (!updatedSession) { + return reply.code(500).send({ + success: false, + error: { + code: 'INTERNAL_ERROR', + message: 'Failed to update session status', + }, + }); + } + + return reply.send({ + success: true, + data: updatedSession, + }); + }); + + // Switch model for existing session (SDK v0.2.x+ setModel support) + fastify.post<{ + Params: { id: string }; + Body: { model: string; reasoningEffort?: 'low' | 'medium' | 'high' | null }; + Reply: ApiResponse; + }>('/sessions/:id/switch-model', async (request, reply) => { + const sessionId = request.params.id; + const body = switchModelSchema.parse(request.body); + + const session = fastify.sessionService.getSession(sessionId); + if (!session) { + return reply.code(404).send({ + success: false, + error: { + code: 'NOT_FOUND', + message: 'Session not found', + }, + }); + } + + // Switch model in Copilot session using SDK v0.2.x setModel() + await fastify.copilotService.switchSessionModel( + sessionId, + session.type, + body.model, + session.systemPrompt, + body.reasoningEffort ?? undefined + ); + + const updatedSession = fastify.sessionService.updateSession(sessionId, { + model: body.model, + reasoningEffort: body.reasoningEffort ?? null, + }); + + if (!updatedSession) { + return reply.code(500).send({ + success: false, + error: { + code: 'INTERNAL_ERROR', + message: 'Failed to persist switched model', + }, + }); + } + return reply.send({ success: true, - data: updatedSession!, + data: updatedSession, }); }); diff --git a/apps/backend/src/routes/tools.ts b/apps/backend/src/routes/tools.ts index aaa80aa..60df08c 100644 --- a/apps/backend/src/routes/tools.ts +++ b/apps/backend/src/routes/tools.ts @@ -1,119 +1,124 @@ /** * Tools API Routes - * + * * Exposes custom DevOps tools for direct execution * and provides tool metadata for the extension. */ +import type { SessionType } from '@devmentorai/shared'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { CopilotService } from '../services/copilot.service.js'; -import type { SessionType } from '@devmentorai/shared'; interface ToolExecuteBody { toolName: string; params: Record; } -export function registerToolsRoutes( - app: FastifyInstance, - copilotService: CopilotService -): void { - +export function registerToolsRoutes(app: FastifyInstance, copilotService: CopilotService): void { // List available tools - app.get('/api/tools', async ( - request: FastifyRequest<{ Querystring: { type?: SessionType } }>, - reply: FastifyReply - ) => { - const sessionType = request.query.type || 'devops'; - const tools = copilotService.getAvailableTools(sessionType); - - return reply.send({ - success: true, - data: tools, - }); - }); - - // Execute a tool directly - app.post('/api/tools/execute', async ( - request: FastifyRequest<{ Body: ToolExecuteBody }>, - reply: FastifyReply - ) => { - const { toolName, params } = request.body; - - if (!toolName) { - return reply.status(400).send({ - success: false, - error: 'toolName is required', + app.get( + '/api/tools', + async ( + request: FastifyRequest<{ Querystring: { type?: SessionType } }>, + reply: FastifyReply + ) => { + const sessionType = request.query.type || 'devops'; + const tools = copilotService.getAvailableTools(sessionType); + + return reply.send({ + success: true, + data: tools, }); } - - const result = await copilotService.executeTool(toolName, params || {}); - - if (!result.success) { - return reply.status(400).send({ - success: false, - error: result.error, + ); + + // Execute a tool directly + app.post( + '/api/tools/execute', + async (request: FastifyRequest<{ Body: ToolExecuteBody }>, reply: FastifyReply) => { + const { toolName, params } = request.body; + + if (!toolName) { + return reply.status(400).send({ + success: false, + error: 'toolName is required', + }); + } + + const result = await copilotService.executeTool(toolName, params || {}); + + if (!result.success) { + return reply.status(400).send({ + success: false, + error: result.error, + }); + } + + return reply.send({ + success: true, + data: { + toolName, + result: result.result, + }, }); } - - return reply.send({ - success: true, - data: { - toolName, - result: result.result, - }, - }); - }); - + ); + // Analyze config endpoint (convenience wrapper) - app.post('/api/tools/analyze-config', async ( - request: FastifyRequest<{ Body: { content: string; type?: string } }>, - reply: FastifyReply - ) => { - const { content, type } = request.body; - - if (!content) { - return reply.status(400).send({ - success: false, - error: 'content is required', + app.post( + '/api/tools/analyze-config', + async ( + request: FastifyRequest<{ Body: { content: string; type?: string } }>, + reply: FastifyReply + ) => { + const { content, type } = request.body; + + if (!content) { + return reply.status(400).send({ + success: false, + error: 'content is required', + }); + } + + const result = await copilotService.executeTool('analyze_config', { + content, + type: type || 'auto', + }); + + return reply.send({ + success: result.success, + data: result.result, + error: result.error, }); } - - const result = await copilotService.executeTool('analyze_config', { - content, - type: type || 'auto', - }); - - return reply.send({ - success: result.success, - data: result.result, - error: result.error, - }); - }); - + ); + // Analyze error endpoint (convenience wrapper) - app.post('/api/tools/analyze-error', async ( - request: FastifyRequest<{ Body: { error: string; context?: string } }>, - reply: FastifyReply - ) => { - const { error, context } = request.body; - - if (!error) { - return reply.status(400).send({ - success: false, - error: 'error is required', + app.post( + '/api/tools/analyze-error', + async ( + request: FastifyRequest<{ Body: { error: string; context?: string } }>, + reply: FastifyReply + ) => { + const { error, context } = request.body; + + if (!error) { + return reply.status(400).send({ + success: false, + error: 'error is required', + }); + } + + const result = await copilotService.executeTool('analyze_error', { + error, + context: context || 'general', + }); + + return reply.send({ + success: result.success, + data: result.result, + error: result.error, }); } - - const result = await copilotService.executeTool('analyze_error', { - error, - context: context || 'general', - }); - - return reply.send({ - success: result.success, - data: result.result, - error: result.error, - }); - }); + ); } diff --git a/apps/backend/src/routes/updates.ts b/apps/backend/src/routes/updates.ts index 7c81e5e..a44d230 100644 --- a/apps/backend/src/routes/updates.ts +++ b/apps/backend/src/routes/updates.ts @@ -1,6 +1,6 @@ -import type { FastifyInstance } from 'fastify'; import type { ApiResponse } from '@devmentorai/shared'; -import { checkForUpdate, type UpdateInfo } from '@devmentorai/shared'; +import { type UpdateInfo, checkForUpdate } from '@devmentorai/shared'; +import type { FastifyInstance } from 'fastify'; interface UpdatesResponse { backend: UpdateInfo; diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts index a8034d1..b12b33d 100644 --- a/apps/backend/src/server.ts +++ b/apps/backend/src/server.ts @@ -1,17 +1,17 @@ -import Fastify from 'fastify'; +import { DEFAULT_CONFIG } from '@devmentorai/shared'; import cors from '@fastify/cors'; -import { healthRoutes } from './routes/health.js'; -import { sessionRoutes } from './routes/sessions.js'; -import { chatRoutes } from './routes/chat.js'; -import { modelsRoutes } from './routes/models.js'; +import Fastify from 'fastify'; +import { initDatabase } from './db/index.js'; import { accountRoutes } from './routes/account.js'; +import { chatRoutes } from './routes/chat.js'; +import { healthRoutes } from './routes/health.js'; import { imagesRoutes } from './routes/images.js'; -import { updatesRoutes } from './routes/updates.js'; +import { modelsRoutes } from './routes/models.js'; +import { sessionRoutes } from './routes/sessions.js'; import { registerToolsRoutes } from './routes/tools.js'; +import { updatesRoutes } from './routes/updates.js'; import { CopilotService } from './services/copilot.service.js'; import { SessionService } from './services/session.service.js'; -import { initDatabase } from './db/index.js'; -import { DEFAULT_CONFIG } from '@devmentorai/shared'; const PORT = Number.parseInt(process.env.DEVMENTORAI_PORT || '', 10) || DEFAULT_CONFIG.DEFAULT_PORT; const HOST = '0.0.0.0'; @@ -25,7 +25,7 @@ const DEBUG_MODE = true; function truncate(str: string | undefined | null, maxLen = 500): string { if (!str) return ''; if (str.length <= maxLen) return str; - return str.slice(0, maxLen) + `... [truncated ${str.length - maxLen} chars]`; + return `${str.slice(0, maxLen)}... [truncated ${str.length - maxLen} chars]`; } export async function createServer() { @@ -48,7 +48,7 @@ export async function createServer() { // Observability middleware - log all requests and responses if (DEBUG_MODE) { fastify.log.info('🔍 Debug mode enabled - logging all requests and responses'); - + // Log after body is parsed fastify.addHook('preHandler', async (request) => { const body = request.body ? truncate(JSON.stringify(request.body)) : null; @@ -67,7 +67,7 @@ export async function createServer() { fastify.addHook('onSend', async (request, reply, payload) => { const statusCode = reply.statusCode; let responseBody: string | null = null; - + // Skip logging SSE streams (too verbose) if (reply.getHeader('content-type') === 'text/event-stream') { responseBody = '[SSE Stream]'; @@ -76,7 +76,7 @@ export async function createServer() { } else if (Buffer.isBuffer(payload)) { responseBody = truncate(payload.toString()); } - + fastify.log.debug({ type: '← RESPONSE', method: request.method, @@ -84,7 +84,7 @@ export async function createServer() { statusCode, body: responseBody, }); - + return payload; }); @@ -106,7 +106,7 @@ export async function createServer() { // Initialize services const sessionService = new SessionService(db); const copilotService = new CopilotService(sessionService); - + try { await copilotService.initialize(); fastify.log.info('CopilotService initialized'); @@ -134,7 +134,7 @@ export async function createServer() { await fastify.register(accountRoutes, { prefix: '/api' }); await fastify.register(updatesRoutes, { prefix: '/api' }); await fastify.register(imagesRoutes, { prefix: '/api/images' }); - + // Register tools routes (not prefixed - has /api in route definitions) registerToolsRoutes(fastify, copilotService); @@ -146,10 +146,7 @@ async function main() { let shuttingDown = false; // Graceful shutdown - const shutdown = async ( - reason: 'SIGINT' | 'SIGTERM' | 'UNCAUGHT_EXCEPTION', - error?: unknown, - ) => { + const shutdown = async (reason: 'SIGINT' | 'SIGTERM' | 'UNCAUGHT_EXCEPTION', error?: unknown) => { if (shuttingDown) return; shuttingDown = true; diff --git a/apps/backend/src/services/context-prompt-builder.ts b/apps/backend/src/services/context-prompt-builder.ts index 6879be6..71bdaa0 100644 --- a/apps/backend/src/services/context-prompt-builder.ts +++ b/apps/backend/src/services/context-prompt-builder.ts @@ -1,8 +1,8 @@ /** * Context Prompt Builder Service - * + * * Builds context-enriched prompts for the Copilot SDK based on extracted page context. - * + * * PHASE 3 DESIGN PRINCIPLE - AUTONOMOUS AGENT: * ============================================ * 1. We provide PURELY FACTUAL context (what's on the page) @@ -11,19 +11,19 @@ * 4. We do NOT use keyword matching (multilingual support, agent autonomy) * 5. The agent (Copilot) is fully autonomous in deciding: * - What the user wants - * - How to respond + * - How to respond * - What context is relevant - * + * * CustomAgents (DevOps Mentor, etc.) are set at session creation and provide * domain expertise. This function ONLY adds situational context from the page. */ import type { ContextPayload, - PlatformDetection, ExtractedError, HTMLSection, Heading, + PlatformDetection, } from '@devmentorai/shared'; // ============================================================================ @@ -62,7 +62,7 @@ Common diagnostic locations: Metric graphs, Alert conditions, Integration status grafana: `**Platform:** Grafana Common diagnostic locations: Query editor, Data source config, Alert rules`, - generic: ``, + generic: '', }; // ============================================================================ @@ -75,8 +75,8 @@ Common diagnostic locations: Query editor, Data source config, Alert rules`, function formatPageContext(page: ContextPayload['page']): string { const { platform } = page; const platformLabel = platform.specificProduct || platform.type.toUpperCase(); - - let section = `## Page Context\n`; + + let section = '## Page Context\n'; section += `- **Platform:** ${platformLabel}`; if (platform.confidence < 0.8) { section += ` (confidence: ${Math.round(platform.confidence * 100)}%)`; @@ -84,7 +84,7 @@ function formatPageContext(page: ContextPayload['page']): string { section += '\n'; section += `- **URL:** ${page.url}\n`; section += `- **Title:** ${page.title}\n`; - + return section; } @@ -95,7 +95,7 @@ function formatPageContext(page: ContextPayload['page']): string { function formatErrors(errors: ExtractedError[]): string { if (errors.length === 0) return ''; - let section = `## Errors and Alerts on Page\n`; + let section = '## Errors and Alerts on Page\n'; section += `${errors.length} issue(s) detected:\n\n`; for (const error of errors) { @@ -115,12 +115,12 @@ function formatErrors(errors: ExtractedError[]): string { function formatHeadings(headings: Heading[]): string { if (headings.length === 0) return ''; - let section = `## Page Structure (Headings)\n`; + let section = '## Page Structure (Headings)\n'; for (const heading of headings.slice(0, 10)) { const indent = ' '.repeat(heading.level - 1); section += `${indent}- ${heading.text}\n`; } - return section + '\n'; + return `${section}\n`; } /** @@ -129,7 +129,7 @@ function formatHeadings(headings: Heading[]): string { function formatRelevantSections(sections: HTMLSection[]): string { if (sections.length === 0) return ''; - let section = `## Relevant UI Elements\n`; + let section = '## Relevant UI Elements\n'; for (const s of sections.slice(0, 5)) { section += `### ${s.purpose.replace('-', ' ').toUpperCase()}\n`; section += `\`\`\`\n${s.textContent.slice(0, 300)}\n\`\`\`\n\n`; @@ -143,7 +143,7 @@ function formatRelevantSections(sections: HTMLSection[]): string { function formatCodeBlocks(codeBlocks?: HTMLSection[]): string { if (!codeBlocks || codeBlocks.length === 0) return ''; - let section = `## Code Snippets Found on Page\n`; + let section = '## Code Snippets Found on Page\n'; for (const block of codeBlocks.slice(0, 3)) { const lang = block.attributes.detectedLanguage || 'text'; section += `### ${lang.toUpperCase()}\n`; @@ -158,7 +158,7 @@ function formatCodeBlocks(codeBlocks?: HTMLSection[]): string { function formatTables(tables?: HTMLSection[]): string { if (!tables || tables.length === 0) return ''; - let section = `## Data Tables\n`; + let section = '## Data Tables\n'; for (const table of tables.slice(0, 2)) { const rows = table.attributes.rowCount || '?'; const cols = table.attributes.columnCount || '?'; @@ -171,15 +171,17 @@ function formatTables(tables?: HTMLSection[]): string { /** * Format console logs for the prompt (Phase 2) - FACTUAL ONLY */ -function formatConsoleLogs(logs?: Array<{ level: string; message: string; timestamp: string; stackTrace?: string }>): string { +function formatConsoleLogs( + logs?: Array<{ level: string; message: string; timestamp: string; stackTrace?: string }> +): string { if (!logs || logs.length === 0) return ''; - const errors = logs.filter(l => l.level === 'error' || l.level === 'warn'); + const errors = logs.filter((l) => l.level === 'error' || l.level === 'warn'); if (errors.length === 0) return ''; - let section = `## Browser Console Logs\n`; + let section = '## Browser Console Logs\n'; section += `${errors.length} error/warning message(s):\n\n`; - + for (const log of errors.slice(0, 5)) { section += `- **${log.level.toUpperCase()}:** ${log.message.slice(0, 200)}\n`; if (log.stackTrace) { @@ -193,14 +195,16 @@ function formatConsoleLogs(logs?: Array<{ level: string; message: string; timest /** * Format network errors for the prompt (Phase 2) - FACTUAL ONLY */ -function formatNetworkErrors(errors?: Array<{ url: string; method: string; status?: number; errorMessage?: string }>): string { +function formatNetworkErrors( + errors?: Array<{ url: string; method: string; status?: number; errorMessage?: string }> +): string { if (!errors || errors.length === 0) return ''; - let section = `## Network Requests Failed\n`; + let section = '## Network Requests Failed\n'; section += `${errors.length} failed request(s):\n\n`; - + for (const err of errors.slice(0, 5)) { - const status = err.status ? `HTTP ${err.status}` : (err.errorMessage || 'Failed'); + const status = err.status ? `HTTP ${err.status}` : err.errorMessage || 'Failed'; section += `- **${err.method}** ${err.url.slice(0, 80)}${err.url.length > 80 ? '...' : ''}\n`; section += ` Status: ${status}\n\n`; } @@ -213,7 +217,7 @@ function formatNetworkErrors(errors?: Array<{ url: string; method: string; statu function formatModalContent(modal?: HTMLSection): string { if (!modal) return ''; - let section = `## Active Modal/Dialog\n`; + let section = '## Active Modal/Dialog\n'; const title = modal.attributes.title || 'Modal'; section += `### ${title}\n`; section += `\`\`\`\n${modal.textContent.slice(0, 400)}\n\`\`\`\n\n`; @@ -226,13 +230,13 @@ function formatModalContent(modal?: HTMLSection): string { function formatPlatformSpecificContext(platformContext?: Record): string { if (!platformContext || Object.keys(platformContext).length === 0) return ''; - let section = `## Platform-Specific Details\n`; + let section = '## Platform-Specific Details\n'; for (const [key, value] of Object.entries(platformContext)) { if (value !== undefined && value !== null) { section += `- **${key}:** ${String(value)}\n`; } } - return section + '\n'; + return `${section}\n`; } /** @@ -248,41 +252,45 @@ function formatUIState(uiState: { modalOpen: boolean; formValidationErrors: number; }): string { - let section = `## UI State\n`; + let section = '## UI State\n'; section += `- **Page State:** ${uiState.pageState}\n`; - + const issues: string[] = []; - if (uiState.loadingIndicators > 0) issues.push(`${uiState.loadingIndicators} loading indicator(s)`); + if (uiState.loadingIndicators > 0) + issues.push(`${uiState.loadingIndicators} loading indicator(s)`); if (uiState.errorStates > 0) issues.push(`${uiState.errorStates} error state(s)`); if (uiState.emptyStates > 0) issues.push(`${uiState.emptyStates} empty state(s)`); if (uiState.toastNotifications > 0) issues.push(`${uiState.toastNotifications} notification(s)`); - if (uiState.formValidationErrors > 0) issues.push(`${uiState.formValidationErrors} form validation error(s)`); + if (uiState.formValidationErrors > 0) + issues.push(`${uiState.formValidationErrors} form validation error(s)`); if (uiState.disabledButtons > 0) issues.push(`${uiState.disabledButtons} disabled button(s)`); - if (uiState.modalOpen) issues.push(`modal/dialog open`); - + if (uiState.modalOpen) issues.push('modal/dialog open'); + if (issues.length > 0) { section += `- **UI Issues:** ${issues.join(', ')}\n`; } - - return section + '\n'; + + return `${section}\n`; } /** * Format runtime JavaScript errors for the prompt */ -function formatRuntimeErrors(errors: Array<{ - message: string; - source?: string; - lineno?: number; - colno?: number; - stack?: string; - type: string; -}>): string { +function formatRuntimeErrors( + errors: Array<{ + message: string; + source?: string; + lineno?: number; + colno?: number; + stack?: string; + type: string; + }> +): string { if (errors.length === 0) return ''; - let section = `## JavaScript Runtime Errors\n`; + let section = '## JavaScript Runtime Errors\n'; section += `${errors.length} runtime error(s) detected:\n\n`; - + for (const err of errors.slice(0, 5)) { const errType = err.type === 'unhandledrejection' ? 'Promise Rejection' : 'JS Error'; section += `- **${errType}:** ${err.message.slice(0, 200)}\n`; @@ -302,14 +310,17 @@ function formatRuntimeErrors(errors: Array<{ */ function formatSelectedText(selectedText?: string): string { if (!selectedText) return ''; - + return `## User Selected Text\nThe user has highlighted this text on the page:\n\`\`\`\n${selectedText}\n\`\`\`\n\n`; } /** * Format session history for the prompt - FACTUAL ONLY */ -function formatSessionHistory(messages: { count: number; lastN: Array<{ role: string; content: string }> }): string { +function formatSessionHistory(messages: { + count: number; + lastN: Array<{ role: string; content: string }>; +}): string { if (messages.count === 0 || messages.lastN.length === 0) return ''; let section = `## Recent Conversation (${messages.count} messages total)\n`; @@ -355,17 +366,17 @@ const DEFAULT_OPTIONS: ContextAwarePromptOptions = { /** * Build a context-enriched user prompt from extracted page context. - * + * * PHASE 3 - AUTONOMOUS AGENT DESIGN: * ================================== * - Returns ONLY factual context (what's on the page) * - Does NOT tell the agent what to do * - Does NOT interpret user intent * - The agent decides everything based on the context and user message - * + * * CustomAgents (DevOps Mentor, etc.) are set at session creation and provide * domain expertise. This function ONLY adds situational context. - * + * * IMPORTANT: The context comes from the user's authenticated browser session. * The agent should use this context to answer questions about private/internal * pages that are not publicly accessible. @@ -376,30 +387,35 @@ export function buildContextAwarePrompt( options: ContextAwarePromptOptions = {} ): { systemPrompt: string | null; userPrompt: string } { const opts = { ...DEFAULT_OPTIONS, ...options }; - + // Build the enriched user prompt with FACTUAL context sections let enrichedPrompt = ''; - + // Context header - explains that this data comes from user's browser enrichedPrompt += `# Browser Context (from user's authenticated session)\n\n`; enrichedPrompt += `The following context was extracted from the user's browser. `; - enrichedPrompt += `This may include private or authenticated content that is not publicly accessible. `; + enrichedPrompt += + 'This may include private or authenticated content that is not publicly accessible. '; enrichedPrompt += `Use this context to answer the user's question - DO NOT attempt to fetch URLs externally.\n\n`; // Page context (always included) enrichedPrompt += formatPageContext(context.page); // UI State (if available) - helps understand page state - if ((context.page as any).uiState) { - enrichedPrompt += formatUIState((context.page as any).uiState); + const pageWithUiState = context.page as ContextPayload['page'] & { + uiState?: Parameters[0]; + }; + + if (pageWithUiState.uiState) { + enrichedPrompt += formatUIState(pageWithUiState.uiState); } // Platform-specific notes (factual locations, NOT instructions) if (opts.includePlatformNotes) { - const platformNotes = PLATFORM_CONTEXT_NOTES[context.page.platform.type] || - PLATFORM_CONTEXT_NOTES.generic; + const platformNotes = + PLATFORM_CONTEXT_NOTES[context.page.platform.type] || PLATFORM_CONTEXT_NOTES.generic; if (platformNotes) { - enrichedPrompt += platformNotes + '\n\n'; + enrichedPrompt += `${platformNotes}\n\n`; } } @@ -424,8 +440,12 @@ export function buildContextAwarePrompt( } // Runtime JavaScript errors (new) - if ((context.text as any).runtimeErrors?.length > 0) { - enrichedPrompt += formatRuntimeErrors((context.text as any).runtimeErrors); + const textWithRuntimeErrors = context.text as ContextPayload['text'] & { + runtimeErrors?: Parameters[0]; + }; + + if (textWithRuntimeErrors.runtimeErrors?.length) { + enrichedPrompt += formatRuntimeErrors(textWithRuntimeErrors.runtimeErrors); } // Selected text (user highlighted something specific) @@ -488,13 +508,13 @@ function truncatePrompt(prompt: string, maxLength: number, userMessage: string): // Find a good truncation point const truncatedContext = prompt.slice(0, availableLength); const lastNewline = truncatedContext.lastIndexOf('\n'); - - return truncatedContext.slice(0, lastNewline) + '\n\n[Context truncated for length]\n\n' + userMessageSection; + + return `${truncatedContext.slice(0, lastNewline)}\n\n[Context truncated for length]\n\n${userMessageSection}`; } /** * Build a simple prompt without full context (fallback) - * + * * This also does NOT replace Copilot's system prompt. * Just enriches the user message with available context. */ @@ -505,16 +525,17 @@ export function buildSimplePrompt( selectedText?: string ): { systemPrompt: string | null; userPrompt: string } { let userPrompt = ''; - + // Add any available context to the user message if (pageUrl || pageTitle || selectedText) { - userPrompt += `**Context:**\n`; + userPrompt += '**Context:**\n'; if (pageUrl) userPrompt += `- Page URL: ${pageUrl}\n`; if (pageTitle) userPrompt += `- Page Title: ${pageTitle}\n`; - if (selectedText) userPrompt += `- Selected Text: "${selectedText.slice(0, 500)}${selectedText.length > 500 ? '...' : ''}"\n`; + if (selectedText) + userPrompt += `- Selected Text: "${selectedText.slice(0, 500)}${selectedText.length > 500 ? '...' : ''}"\n`; userPrompt += '\n**Question:** '; } - + userPrompt += userMessage; // Return null for systemPrompt - we don't override Copilot's default @@ -526,15 +547,15 @@ export function buildSimplePrompt( */ export function validateContext(context: unknown): context is ContextPayload { if (!context || typeof context !== 'object') return false; - + const c = context as Record; - + // Check required fields if (!c.metadata || typeof c.metadata !== 'object') return false; if (!c.page || typeof c.page !== 'object') return false; if (!c.text || typeof c.text !== 'object') return false; if (!c.session || typeof c.session !== 'object') return false; - + return true; } diff --git a/apps/backend/src/services/copilot.service.ts b/apps/backend/src/services/copilot.service.ts index 3c0382e..ccab5cc 100644 --- a/apps/backend/src/services/copilot.service.ts +++ b/apps/backend/src/services/copilot.service.ts @@ -1,19 +1,24 @@ -import { CopilotClient, type SessionEvent, type Tool as CopilotTool, approveAll } from '@github/copilot-sdk'; -import { SessionService } from './session.service.js'; -import { getAgentConfig, SESSION_TYPE_CONFIGS } from '@devmentorai/shared'; +import { SESSION_TYPE_CONFIGS, getAgentConfig } from '@devmentorai/shared'; import type { - SessionType, - MessageContext, - ModelInfo, CopilotAuthStatus, CopilotQuotaStatus, + MessageContext, + ModelInfo, ModelPricingTier, + SessionType, } from '@devmentorai/shared'; +import { + CopilotClient, + type Tool as CopilotTool, + type SessionEvent, + approveAll, +} from '@github/copilot-sdk'; import { devopsTools, getToolByName } from '../tools/devops-tools.js'; +import { SessionService } from './session.service.js'; interface CopilotSession { sessionId: string; - session: Awaited>; + session: Awaited> | null; type: SessionType; } @@ -203,16 +208,47 @@ export class CopilotService { sessionId: string, type: SessionType, model: string, - systemPrompt?: string + systemPrompt?: string, + reasoningEffort?: 'low' | 'medium' | 'high' ): Promise { const existing = this.sessions.get(sessionId); + const persistedSession = this.sessionService.getSession(sessionId); + + // If session exists and supports setModel (SDK v0.2.x+), use it for seamless model switching + if (existing?.session && !this.mockMode) { + try { + const session = existing.session as unknown as { + setModel?: (model: string, options?: { reasoningEffort?: string }) => Promise; + }; + + if (session.setModel) { + console.log(`[CopilotService] Switching model for session ${sessionId} to ${model}`); + await session.setModel(model, reasoningEffort ? { reasoningEffort } : undefined); + + // Update stored session info + this.sessions.set(sessionId, { ...existing, type }); + console.log(`[CopilotService] Model switched successfully for session ${sessionId}`); + return; + } + } catch (error) { + console.warn( + `[CopilotService] setModel failed for session ${sessionId}, falling back to recreate:`, + error + ); + // Fall through to recreate session + } + } + // Fallback: destroy and recreate session (legacy behavior) if (existing?.session && !this.mockMode) { try { await existing.session.abort().catch(() => undefined); await existing.session.destroy(); } catch (error) { - console.warn(`[CopilotService] Failed to destroy previous session before model switch: ${sessionId}`, error); + console.warn( + `[CopilotService] Failed to destroy previous session before model switch: ${sessionId}`, + error + ); } } @@ -226,7 +262,16 @@ export class CopilotService { } } - await this.createCopilotSession(sessionId, type, model, systemPrompt); + await this.createCopilotSession( + sessionId, + type, + model, + systemPrompt ?? persistedSession?.systemPrompt, + false, + persistedSession?.tone, + persistedSession?.explainTradeoffs, + reasoningEffort + ); } private normalizeModel(raw: RawSdkModel): ModelInfo { @@ -245,7 +290,9 @@ export class CopilotService { ? raw.billing.multiplier : undefined; const supportedReasoningEfforts = Array.isArray(raw.supportedReasoningEfforts) - ? raw.supportedReasoningEfforts.filter((effort): effort is string => typeof effort === 'string') + ? raw.supportedReasoningEfforts.filter( + (effort): effort is string => typeof effort === 'string' + ) : undefined; return { @@ -278,9 +325,18 @@ export class CopilotService { private normalizeQuota(raw: Record): CopilotQuotaStatus { const used = this.readNumber(raw, ['used', 'consumed', 'usage', 'quotaUsed', 'totalUsed']); - const included = this.readNumber(raw, ['included', 'limit', 'quota', 'total', 'quotaTotal', 'allowed']); + const included = this.readNumber(raw, [ + 'included', + 'limit', + 'quota', + 'total', + 'quotaTotal', + 'allowed', + ]); const computedRemaining = - typeof included === 'number' && typeof used === 'number' ? Math.max(included - used, 0) : null; + typeof included === 'number' && typeof used === 'number' + ? Math.max(included - used, 0) + : null; const remaining = this.readNumber(raw, ['remaining', 'left', 'available']) ?? computedRemaining; const computedPercentageUsed = @@ -288,7 +344,8 @@ export class CopilotService { ? Math.min(100, (used / included) * 100) : null; const percentageUsed = - this.readNumber(raw, ['percentageUsed', 'usedPercent', 'percentUsed']) ?? computedPercentageUsed; + this.readNumber(raw, ['percentageUsed', 'usedPercent', 'percentUsed']) ?? + computedPercentageUsed; const percentageRemaining = this.readNumber(raw, ['percentageRemaining', 'remainingPercent', 'percentRemaining']) ?? (typeof percentageUsed === 'number' ? Math.max(0, 100 - percentageUsed) : null); @@ -330,13 +387,16 @@ export class CopilotService { type: SessionType, model: string, systemPrompt?: string, - enableMcp: boolean = false + enableMcp = false, + tone?: string, + explainTradeoffs?: boolean, + reasoningEffort?: 'low' | 'medium' | 'high' ): Promise { if (this.mockMode || !this.client) { // Create mock session this.sessions.set(sessionId, { sessionId, - session: null as any, + session: null, type, }); return; @@ -352,40 +412,179 @@ export class CopilotService { // Build MCP server config if enabled const mcpServers = enableMcp ? MCP_SERVERS : undefined; - const session = await this.client.createSession({ + // Build customized system message + const customizedSystemMessage = this.buildCustomizedSystemMessage( + type, + systemPrompt, + tone, + explainTradeoffs + ); + + // Build session config - use unknown to allow SDK-specific extensions + const sessionConfig: unknown = { sessionId, model: model || typeConfig.defaultModel, streaming: true, customAgents: agentConfig ? [agentConfig] : undefined, - systemMessage: systemPrompt ? { content: systemPrompt } : undefined, + systemMessage: customizedSystemMessage, tools, mcpServers, onPermissionRequest: approveAll, - }); + // Add reasoning effort if provided and supported by SDK + ...(reasoningEffort ? { reasoningEffort } : {}), + }; + + const session = await this.client.createSession( + sessionConfig as Parameters[0] + ); this.sessions.set(sessionId, { sessionId, session, type }); } + /** + * Build customized system message with Copilot SDK v0.2.x customize mode. + * This allows surgical modifications to specific sections without replacing the entire prompt. + */ + private buildCustomizedSystemMessage( + type: SessionType, + systemPrompt?: string, + tone?: string, + explainTradeoffs?: boolean + ): unknown { + // If legacy systemPrompt provided, use it as base content (for backward compatibility) + if (systemPrompt) { + return { content: systemPrompt }; + } + + // Build tone instructions + const toneInstructions = this.getToneInstructions(tone); + + // Build guidelines based on session type + const guidelines = this.buildGuidelines(type, explainTradeoffs); + + // Build safety instructions (common to all types) + const safetyInstructions = this.getSafetyInstructions(); + + // Return system message with customize mode + return { + mode: 'customize', + sections: { + identity: { + action: (current: string) => current.replace('GitHub Copilot', 'DevMentorAI Assistant'), + }, + tone: { + action: 'append', + content: toneInstructions, + }, + environment_context: { + action: 'append', + content: `Session Type: ${type}. You are assisting developers, DevOps engineers, QA professionals, or learners with their daily tasks.`, + }, + guidelines: { + action: 'append', + content: guidelines, + }, + safety: { + action: 'append', + content: safetyInstructions, + }, + }, + }; + } + + /** + * Get tone instructions based on user preference + */ + private getToneInstructions(tone?: string): string { + switch (tone) { + case 'concise': + return 'Be concise and direct. Get straight to the point without unnecessary elaboration.'; + case 'friendly': + return 'Be friendly, approachable, and encouraging. Use a warm, conversational tone while maintaining professionalism.'; + case 'professional': + return 'Be professional and formal. Use precise language and maintain a business-appropriate tone.'; + case 'technical': + return 'Be technical and detailed. Provide in-depth explanations suitable for technical professionals. Include specific terminology and technical details.'; + default: + return 'Be helpful and clear. Balance technical accuracy with accessibility. Explain concepts thoroughly without being overly verbose.'; + } + } + + /** + * Build guidelines based on session type + */ + private buildGuidelines(type: SessionType, explainTradeoffs?: boolean): string { + const baseGuidelines: Record = { + devops: ` + - Emphasize infrastructure-as-code principles and automation + - Prioritize security, scalability, and observability in all recommendations + - Cite official cloud provider documentation (AWS, Azure, GCP, Kubernetes) when making claims + - Always explain the 'why' behind infrastructure decisions + - Include command examples for CLI tools (kubectl, terraform, aws-cli, etc.) when applicable`, + + writing: ` + - Focus on clarity, conciseness, and effective communication + - Adapt writing style to the context (technical documentation, emails, blog posts, etc.) + - Maintain the original intent and tone of the author + - Cite style guides (AP, Chicago, technical writing standards) when relevant`, + + development: ` + - Prioritize clean code principles, SOLID principles, and best practices + - Explain design patterns and architectural decisions clearly + - Include code examples that follow language conventions and style guides + - Reference official language/framework documentation when appropriate`, + + general: ` + - Provide balanced, well-reasoned answers + - Include examples when they add clarity to the explanation + - Cite authoritative sources when making factual claims + - Adapt depth of explanation to the apparent expertise level of the user`, + }; + + let guidelines = baseGuidelines[type] || baseGuidelines.general; + + if (explainTradeoffs) { + guidelines += + '\n - Always explain the pros and cons of different approaches when presenting multiple options.'; + } + + return guidelines.trim(); + } + + /** + * Get safety instructions (common to all session types) + */ + private getSafetyInstructions(): string { + return ` + - Never request or store sensitive data such as passwords, API keys, tokens, or credentials + - Always warn before suggesting destructive commands (rm -rf, database deletions, resource terminations, etc.) + - Remind users to make backups or use version control before critical changes + - Distinguish between staging/testing and production environments when discussing deployments + `.trim(); + } + /** * Convert our Tool definitions to Copilot SDK Tool format. * The SDK expects tools with a `handler` function — it calls the handler * directly and uses the return value as the tool result (no sendToolResult needed). */ - private buildSdkTools(): CopilotTool[] { - return devopsTools.map((tool): CopilotTool => ({ - name: tool.name, - description: tool.description, - parameters: tool.parameters, - handler: async (args: Record) => { - console.log(`[CopilotService] Executing tool ${tool.name}`); - try { - return await tool.handler(args); - } catch (error) { - console.error(`[CopilotService] Tool ${tool.name} failed:`, error); - return `Error: ${error instanceof Error ? error.message : 'Unknown error'}`; - } - }, - })); + private buildSdkTools(): CopilotTool>[] { + return devopsTools.map( + (tool): CopilotTool> => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + handler: async (args: Record) => { + console.log(`[CopilotService] Executing tool ${tool.name}`); + try { + return await tool.handler(args); + } catch (error) { + console.error(`[CopilotService] Tool ${tool.name} failed:`, error); + return `Error: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + }, + }) + ); } async resumeCopilotSession(sessionId: string): Promise { @@ -395,7 +594,9 @@ export class CopilotService { // First, try to resume existing session try { - const session = await this.client.resumeSession(sessionId, { onPermissionRequest: approveAll }); + const session = await this.client.resumeSession(sessionId, { + onPermissionRequest: approveAll, + }); // Try to get type from DB, fallback to 'general' const dbSession = this.sessionService.getSession(sessionId); this.sessions.set(sessionId, { sessionId, session, type: dbSession?.type || 'general' }); @@ -436,46 +637,50 @@ export class CopilotService { context?: MessageContext, onEvent?: (event: SessionEvent) => void ): Promise { - return this.withRetry(async () => { - const copilotSession = this.sessions.get(sessionId); - - // Note: The prompt should already be enriched with context by the route - // We only add basic context fallback if the prompt doesn't contain it - let fullPrompt = prompt; - if (context?.selectedText && !prompt.includes(context.selectedText)) { - fullPrompt = `Context (selected text):\n${context.selectedText}\n\nUser request: ${prompt}`; - } - if (context?.pageUrl && !prompt.includes(context.pageUrl)) { - fullPrompt = `Page: ${context.pageUrl}\n${fullPrompt}`; - } - - if (this.mockMode || !copilotSession?.session) { - // Mock response - return this.generateMockResponse(prompt, context); - } + return this.withRetry( + async () => { + const copilotSession = this.sessions.get(sessionId); + + // Note: The prompt should already be enriched with context by the route + // We only add basic context fallback if the prompt doesn't contain it + let fullPrompt = prompt; + if (context?.selectedText && !prompt.includes(context.selectedText)) { + fullPrompt = `Context (selected text):\n${context.selectedText}\n\nUser request: ${prompt}`; + } + if (context?.pageUrl && !prompt.includes(context.pageUrl)) { + fullPrompt = `Page: ${context.pageUrl}\n${fullPrompt}`; + } - let responseContent = ''; + if (this.mockMode || !copilotSession?.session) { + // Mock response + return this.generateMockResponse(prompt, context); + } - // Set up event listener - if (onEvent) { - copilotSession.session.on(onEvent); - } + let responseContent = ''; - copilotSession.session.on((event: SessionEvent) => { - if (event.type === 'assistant.message') { - responseContent = event.data.content || ''; + // Set up event listener + if (onEvent) { + copilotSession.session.on(onEvent); } - }); - // Send message - no systemMessage override to preserve Copilot's intelligence - // The customAgents from session creation provide the persona/expertise - const response = await copilotSession.session.sendAndWait({ prompt: fullPrompt }); - console.log(`[CopilotService] Received response for session ${sessionId}`); - console.log('[CopilotService] Response payload:', response?.data); - console.log(`[CopilotService] responseContent: ${responseContent}...`); - - return response?.data.content || responseContent; - }, 3, 1000); + copilotSession.session.on((event: SessionEvent) => { + if (event.type === 'assistant.message') { + responseContent = event.data.content || ''; + } + }); + + // Send message - no systemMessage override to preserve Copilot's intelligence + // The customAgents from session creation provide the persona/expertise + const response = await copilotSession.session.sendAndWait({ prompt: fullPrompt }); + console.log(`[CopilotService] Received response for session ${sessionId}`); + console.log('[CopilotService] Response payload:', response?.data); + console.log(`[CopilotService] responseContent: ${responseContent}...`); + + return response?.data.content || responseContent; + }, + 3, + 1000 + ); } async streamMessage( @@ -495,7 +700,9 @@ export class CopilotService { copilotSession = this.sessions.get(sessionId); console.log(`[CopilotService] Session ${sessionId} auto-resumed successfully`); } else { - console.warn(`[CopilotService] Failed to auto-resume session ${sessionId}, falling back to mock`); + console.warn( + `[CopilotService] Failed to auto-resume session ${sessionId}, falling back to mock` + ); } } @@ -515,9 +722,12 @@ export class CopilotService { console.log('[CopilotService] Starting real stream for session', sessionId); if (attachments && attachments.length > 0) { - console.log(`[CopilotService] Sending ${attachments.length} attachments:`, attachments.map(a => a.path)); + console.log( + `[CopilotService] Sending ${attachments.length} attachments:`, + attachments.map((a) => a.path) + ); } - + // Set up event listener if (onEvent) { copilotSession.session.on(onEvent); @@ -537,32 +747,35 @@ export class CopilotService { */ private async withRetry( operation: () => Promise, - maxRetries: number = 3, - delayMs: number = 1000 + maxRetries = 3, + delayMs = 1000 ): Promise { let lastError: Error | undefined; - + let currentDelayMs = delayMs; + for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await operation(); } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); - + // Don't retry on certain errors const nonRetryableErrors = ['authentication', 'invalid_session', 'rate_limit']; const errorMessage = (lastError?.message || '').toLowerCase(); - if (nonRetryableErrors.some(e => errorMessage.includes(e))) { + if (nonRetryableErrors.some((e) => errorMessage.includes(e))) { throw lastError; } - + if (attempt < maxRetries) { - console.warn(`[CopilotService] Attempt ${attempt} failed, retrying in ${delayMs}ms...`); - await new Promise(resolve => setTimeout(resolve, delayMs)); - delayMs *= 2; // Exponential backoff + console.warn( + `[CopilotService] Attempt ${attempt} failed, retrying in ${currentDelayMs}ms...` + ); + await new Promise((resolve) => setTimeout(resolve, currentDelayMs)); + currentDelayMs *= 2; } } } - + throw lastError; } @@ -571,7 +784,7 @@ export class CopilotService { */ getAvailableTools(type: SessionType): Array<{ name: string; description: string }> { if (type === 'devops') { - return devopsTools.map(t => ({ name: t.name, description: t.description })); + return devopsTools.map((t) => ({ name: t.name, description: t.description })); } return []; } @@ -580,21 +793,21 @@ export class CopilotService { * Execute a tool directly (for testing or standalone use) */ async executeTool( - toolName: string, + toolName: string, params: Record ): Promise<{ success: boolean; result?: string; error?: string }> { const tool = getToolByName(toolName); if (!tool) { return { success: false, error: `Unknown tool: ${toolName}` }; } - + try { const result = await tool.handler(params); return { success: true, result }; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error) + return { + success: false, + error: error instanceof Error ? error.message : String(error), }; } } @@ -608,7 +821,7 @@ export class CopilotService { async destroySession(sessionId: string): Promise { const copilotSession = this.sessions.get(sessionId); - + if (copilotSession?.session && !this.mockMode) { try { // Abort any pending requests first @@ -621,7 +834,7 @@ export class CopilotService { // Continue with cleanup even if destroy fails } } - + // Delete session data from disk using SDK's deleteSession if (this.client && !this.mockMode) { try { @@ -630,10 +843,12 @@ export class CopilotService { } catch (error) { // Session might not exist on disk, that's OK console.log('[CopilotService] deleteSession error:', error); - console.log(`[CopilotService] Could not delete session files (may not exist): ${sessionId}`); + console.log( + `[CopilotService] Could not delete session files (may not exist): ${sessionId}` + ); } } - + // Remove from in-memory map this.sessions.delete(sessionId); console.log(`[CopilotService] Session ${sessionId} removed from memory`); @@ -667,19 +882,19 @@ export class CopilotService { // Mock implementations for development without Copilot CLI private generateMockResponse(prompt: string, context?: MessageContext): string { const action = context?.action; - + if (action === 'explain') { return `**Explanation:**\n\nThis appears to be ${context?.selectedText?.slice(0, 50)}...\n\nIn essence, this code/text demonstrates a common pattern used in software development. The key points are:\n\n1. It handles a specific use case\n2. It follows best practices\n3. It can be extended for additional functionality`; } - + if (action === 'translate') { return `**Translation:**\n\n${context?.selectedText || 'No text provided'}`; } - + if (action === 'rewrite') { return `**Rewritten:**\n\n${context?.selectedText || prompt}`; } - + if (action === 'fix_grammar') { return `**Corrected:**\n\n${context?.selectedText || prompt}`; } @@ -697,8 +912,8 @@ export class CopilotService { // Simulate streaming with delays for (let i = 0; i < words.length; i++) { - await new Promise(resolve => setTimeout(resolve, 50)); - + await new Promise((resolve) => setTimeout(resolve, 50)); + onEvent?.({ type: 'assistant.message_delta', data: { deltaContent: words[i] + (i < words.length - 1 ? ' ' : '') }, diff --git a/apps/backend/src/services/session.service.ts b/apps/backend/src/services/session.service.ts index 99c90d1..8ca63bc 100644 --- a/apps/backend/src/services/session.service.ts +++ b/apps/backend/src/services/session.service.ts @@ -1,21 +1,21 @@ -import type { Database } from 'better-sqlite3'; import { - generateSessionId, - generateMessageId, formatDate, + generateMessageId, + generateSessionId, getAgentConfig, getDefaultModel, } from '@devmentorai/shared'; import type { - Session, - SessionType, - SessionStatus, CreateSessionRequest, - UpdateSessionRequest, Message, MessageMetadata, PaginatedResponse, + Session, + SessionStatus, + SessionType, + UpdateSessionRequest, } from '@devmentorai/shared'; +import type { Database } from 'better-sqlite3'; interface DbSession { id: string; @@ -25,6 +25,9 @@ interface DbSession { model: string; system_prompt: string | null; custom_agent: string | null; + tone: string | null; + explain_tradeoffs: number | null; // SQLite boolean as 0/1 + reasoning_effort: string | null; // 'low' | 'medium' | 'high' message_count: number; created_at: string; updated_at: string; @@ -45,7 +48,7 @@ export class SessionService { // Sessions listSessions(page = 1, pageSize = 50): PaginatedResponse { const offset = (page - 1) * pageSize; - + const countStmt = this.db.prepare('SELECT COUNT(*) as count FROM sessions'); const count = (countStmt.get() as { count: number }).count; @@ -57,7 +60,7 @@ export class SessionService { const rows = stmt.all(pageSize, offset) as DbSession[]; return { - items: rows.map(this.mapDbSession), + items: rows.map((row) => this.mapDbSession(row)), total: count, page, pageSize, @@ -76,12 +79,12 @@ export class SessionService { const now = formatDate(); const agentConfig = getAgentConfig(request.type); const model = request.model || getDefaultModel(request.type); - + const stmt = this.db.prepare(` - INSERT INTO sessions (id, name, type, model, system_prompt, custom_agent, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO sessions (id, name, type, model, system_prompt, custom_agent, tone, explain_tradeoffs, reasoning_effort, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); - + stmt.run( id, request.name, @@ -89,41 +92,54 @@ export class SessionService { model, request.systemPrompt || agentConfig?.prompt || null, agentConfig?.name || null, + request.tone || 'balanced', + request.explainTradeoffs ? 1 : 0, + request.reasoningEffort || null, now, now ); - return this.getSession(id)!; + const createdSession = this.getSession(id); + + if (!createdSession) { + throw new Error(`Failed to load created session: ${id}`); + } + + return createdSession; } updateSession(id: string, request: UpdateSessionRequest): Session | null { const session = this.getSession(id); if (!session) return null; - const updates: string[] = []; - const values: (string | number)[] = []; + const updateFields: Array<{ sql: string; value: string | number | null }> = []; if (request.name !== undefined) { - updates.push('name = ?'); - values.push(request.name); + updateFields.push({ sql: 'name = ?', value: request.name }); } if (request.status !== undefined) { - updates.push('status = ?'); - values.push(request.status); + updateFields.push({ sql: 'status = ?', value: request.status }); } if (request.model !== undefined) { - updates.push('model = ?'); - values.push(request.model); + updateFields.push({ sql: 'model = ?', value: request.model }); + } + if (request.tone !== undefined) { + updateFields.push({ sql: 'tone = ?', value: request.tone }); + } + if (request.explainTradeoffs !== undefined) { + updateFields.push({ sql: 'explain_tradeoffs = ?', value: request.explainTradeoffs ? 1 : 0 }); + } + if (request.reasoningEffort !== undefined) { + updateFields.push({ sql: 'reasoning_effort = ?', value: request.reasoningEffort }); } - if (updates.length === 0) return session; + if (updateFields.length === 0) return session; - updates.push('updated_at = ?'); - values.push(formatDate()); - values.push(id); + const updates = updateFields.map((f) => f.sql); + const values = [...updateFields.map((f) => f.value), formatDate(), id]; const stmt = this.db.prepare(` - UPDATE sessions SET ${updates.join(', ')} WHERE id = ? + UPDATE sessions SET ${updates.join(', ')}, updated_at = ? WHERE id = ? `); stmt.run(...values); @@ -149,7 +165,9 @@ export class SessionService { listMessages(sessionId: string, page = 1, pageSize = 100): PaginatedResponse { const offset = (page - 1) * pageSize; - const countStmt = this.db.prepare('SELECT COUNT(*) as count FROM messages WHERE session_id = ?'); + const countStmt = this.db.prepare( + 'SELECT COUNT(*) as count FROM messages WHERE session_id = ?' + ); const count = (countStmt.get(sessionId) as { count: number }).count; const stmt = this.db.prepare(` @@ -161,7 +179,7 @@ export class SessionService { const rows = stmt.all(sessionId, pageSize, offset) as DbMessage[]; return { - items: rows.map(row => this.mapDbMessage(row)), + items: rows.map((row) => this.mapDbMessage(row)), total: count, page, pageSize, @@ -182,15 +200,8 @@ export class SessionService { INSERT INTO messages (id, session_id, role, content, timestamp, metadata) VALUES (?, ?, ?, ?, ?, ?) `); - - stmt.run( - id, - sessionId, - role, - content, - timestamp, - metadata ? JSON.stringify(metadata) : null - ); + + stmt.run(id, sessionId, role, content, timestamp, metadata ? JSON.stringify(metadata) : null); this.incrementMessageCount(sessionId); @@ -237,7 +248,16 @@ export class SessionService { VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); - stmt.run(id, sessionId, messageId || null, contextJson, pageUrl || null, pageTitle || null, platform || null, now); + stmt.run( + id, + sessionId, + messageId || null, + contextJson, + pageUrl || null, + pageTitle || null, + platform || null, + now + ); return id; } @@ -245,7 +265,13 @@ export class SessionService { /** * Get the most recent context for a session */ - getLatestContext(sessionId: string): { id: string; contextJson: string; pageUrl?: string; platform?: string; extractedAt: string } | null { + getLatestContext(sessionId: string): { + id: string; + contextJson: string; + pageUrl?: string; + platform?: string; + extractedAt: string; + } | null { const stmt = this.db.prepare(` SELECT id, context_json, page_url, platform, extracted_at FROM session_contexts @@ -254,7 +280,15 @@ export class SessionService { LIMIT 1 `); - const row = stmt.get(sessionId) as { id: string; context_json: string; page_url: string | null; platform: string | null; extracted_at: string } | undefined; + const row = stmt.get(sessionId) as + | { + id: string; + context_json: string; + page_url: string | null; + platform: string | null; + extracted_at: string; + } + | undefined; if (!row) return null; @@ -270,7 +304,10 @@ export class SessionService { /** * Get context history for a session */ - getContextHistory(sessionId: string, limit: number = 10): Array<{ + getContextHistory( + sessionId: string, + limit = 10 + ): Array<{ id: string; messageId?: string; pageUrl?: string; @@ -295,7 +332,7 @@ export class SessionService { extracted_at: string; }>; - return rows.map(row => ({ + return rows.map((row) => ({ id: row.id, messageId: row.message_id || undefined, pageUrl: row.page_url || undefined, @@ -308,14 +345,18 @@ export class SessionService { /** * Get a specific context by ID */ - getContext(contextId: string): { id: string; sessionId: string; contextJson: string; extractedAt: string } | null { + getContext( + contextId: string + ): { id: string; sessionId: string; contextJson: string; extractedAt: string } | null { const stmt = this.db.prepare(` SELECT id, session_id, context_json, extracted_at FROM session_contexts WHERE id = ? `); - const row = stmt.get(contextId) as { id: string; session_id: string; context_json: string; extracted_at: string } | undefined; + const row = stmt.get(contextId) as + | { id: string; session_id: string; context_json: string; extracted_at: string } + | undefined; if (!row) return null; @@ -330,7 +371,7 @@ export class SessionService { /** * Clean up old contexts for a session (keep only last N) */ - cleanupOldContexts(sessionId: string, keepCount: number = 20): number { + cleanupOldContexts(sessionId: string, keepCount = 20): number { // Get IDs to keep const keepStmt = this.db.prepare(` SELECT id FROM session_contexts @@ -338,7 +379,9 @@ export class SessionService { ORDER BY extracted_at DESC LIMIT ? `); - const idsToKeep = (keepStmt.all(sessionId, keepCount) as Array<{ id: string }>).map(r => r.id); + const idsToKeep = (keepStmt.all(sessionId, keepCount) as Array<{ id: string }>).map( + (r) => r.id + ); if (idsToKeep.length === 0) return 0; @@ -357,7 +400,9 @@ export class SessionService { * Get context count for a session */ getContextCount(sessionId: string): number { - const stmt = this.db.prepare('SELECT COUNT(*) as count FROM session_contexts WHERE session_id = ?'); + const stmt = this.db.prepare( + 'SELECT COUNT(*) as count FROM session_contexts WHERE session_id = ?' + ); const result = stmt.get(sessionId) as { count: number }; return result.count; } @@ -371,6 +416,9 @@ export class SessionService { model: row.model, systemPrompt: row.system_prompt || undefined, customAgent: row.custom_agent || undefined, + tone: (row.tone as Session['tone']) || undefined, + explainTradeoffs: this.mapDbBoolean(row.explain_tradeoffs), + reasoningEffort: (row.reasoning_effort as Session['reasoningEffort']) || undefined, messageCount: row.message_count, createdAt: row.created_at, updatedAt: row.updated_at, @@ -378,8 +426,8 @@ export class SessionService { } private mapDbMessage(row: DbMessage): Message { - let metadata = row.metadata ? JSON.parse(row.metadata) : undefined; - + const metadata = row.metadata ? JSON.parse(row.metadata) : undefined; + // Fix legacy image URLs that have incorrect format if (metadata?.images) { metadata.images = metadata.images.map((img: Record) => ({ @@ -388,7 +436,7 @@ export class SessionService { fullImageUrl: this.fixImageUrl(img.fullImageUrl as string | undefined), })); } - + return { id: row.id, sessionId: row.session_id, @@ -399,6 +447,15 @@ export class SessionService { }; } + /** + * Map SQLite boolean (0/1) to TypeScript boolean or undefined + */ + private mapDbBoolean(value: number | null): boolean | undefined { + if (value === 1) return true; + if (value === 0) return false; + return undefined; + } + /** * Fix legacy image URLs that have incorrect format * - Adds missing port (localhost -> localhost:3847) @@ -406,19 +463,19 @@ export class SessionService { */ private fixImageUrl(url: string | undefined): string | undefined { if (!url) return url; - + let fixed = url; - + // Fix missing port: http://localhost/api -> http://localhost:3847/api if (fixed.includes('http://localhost/api')) { fixed = fixed.replace('http://localhost/api', 'http://localhost:3847/api'); } - + // Fix duplicate images path: /api/images/images/ -> /api/images/ if (fixed.includes('/api/images/images/')) { fixed = fixed.replace('/api/images/images/', '/api/images/'); } - + return fixed; } } diff --git a/apps/backend/src/services/thumbnail-service.ts b/apps/backend/src/services/thumbnail-service.ts index 4437888..57ba197 100644 --- a/apps/backend/src/services/thumbnail-service.ts +++ b/apps/backend/src/services/thumbnail-service.ts @@ -1,25 +1,25 @@ /** * ThumbnailService - * + * * Handles image processing and thumbnail generation. * Stores thumbnails AND full images on disk and provides URLs for serving. */ -import sharp from 'sharp'; import fs from 'node:fs'; import path from 'node:path'; +import type { ImageAttachment, ImageMimeType, ImageSource } from '@devmentorai/shared'; +import sharp from 'sharp'; import { + deleteDir, + ensureDir, + fileExists, getMessageImagesDir, getSessionImagesDir, getThumbnailPath, - toRelativePath, toImageRelativePath, + toRelativePath, toUrlPath, - ensureDir, - deleteDir, - fileExists, } from '../lib/paths.js'; -import type { ImageAttachment, ImageSource, ImageMimeType } from '@devmentorai/shared'; /** Thumbnail generation settings */ const THUMBNAIL_CONFIG = { @@ -64,10 +64,10 @@ export interface ProcessedImage { function parseDataUrl(dataUrl: string): { buffer: Buffer; mimeType: string } | null { const match = dataUrl.match(/^data:(image\/[^;]+);base64,(.+)$/); if (!match) return null; - + const [, mimeType, base64Data] = match; const buffer = Buffer.from(base64Data, 'base64'); - + return { buffer, mimeType }; } @@ -124,14 +124,19 @@ function getExtensionForMimeType(mimeType: string): string { /** * Get the file path for a full image */ -export function getFullImagePath(sessionId: string, messageId: string, index: number, extension: string): string { +export function getFullImagePath( + sessionId: string, + messageId: string, + index: number, + extension: string +): string { const messageDir = getMessageImagesDir(sessionId, messageId); return path.join(messageDir, `image_${index}.${extension}`); } /** * Process images for a message, generating and storing thumbnails AND full images - * + * * @param sessionId - The session ID * @param messageId - The message ID * @param images - Array of image inputs with data URLs @@ -154,7 +159,7 @@ export async function processMessageImages( for (let index = 0; index < images.length; index++) { const image = images[index]; - + try { // Parse the data URL const parsed = parseDataUrl(image.dataUrl); @@ -185,7 +190,7 @@ export async function processMessageImages( // Generate relative paths for DB storage (from DATA_DIR) const thumbnailRelativePath = toRelativePath(thumbnailAbsPath); - + // Generate URL paths for serving (from IMAGES_DIR, no "images/" prefix) const thumbnailUrlPath = toUrlPath(toImageRelativePath(thumbnailAbsPath)); const fullImageUrlPath = toUrlPath(toImageRelativePath(fullImageAbsPath)); @@ -203,7 +208,9 @@ export async function processMessageImages( fullImageUrl: `${backendUrl}/api/images/${fullImageUrlPath}`, }); - console.log(`[ThumbnailService] Processed image ${index + 1}/${images.length} for message ${messageId}`); + console.log( + `[ThumbnailService] Processed image ${index + 1}/${images.length} for message ${messageId}` + ); } catch (error) { console.error(`[ThumbnailService] Failed to process image ${image.id}:`, error); // Continue with other images even if one fails @@ -215,7 +222,7 @@ export async function processMessageImages( /** * Get the absolute path for a thumbnail from URL path segments - * + * * @param sessionId - The session ID * @param messageId - The message ID * @param index - The image index @@ -232,7 +239,7 @@ export function getThumbnailFilePath( /** * Delete all images for a session (cleanup) - * + * * @param sessionId - The session ID */ export function deleteSessionImages(sessionId: string): void { @@ -243,7 +250,7 @@ export function deleteSessionImages(sessionId: string): void { /** * Delete all images for a message (cleanup) - * + * * @param sessionId - The session ID * @param messageId - The message ID */ @@ -257,7 +264,7 @@ export function deleteMessageImages(sessionId: string, messageId: string): void * Convert processed images to ImageAttachment format for storage */ export function toImageAttachments(processedImages: ProcessedImage[]): ImageAttachment[] { - return processedImages.map(img => ({ + return processedImages.map((img) => ({ id: img.id, source: img.source, mimeType: img.mimeType, diff --git a/apps/backend/src/tools/devops-tools.ts b/apps/backend/src/tools/devops-tools.ts index 5aa9f84..cd09234 100644 --- a/apps/backend/src/tools/devops-tools.ts +++ b/apps/backend/src/tools/devops-tools.ts @@ -1,6 +1,6 @@ /** * Custom DevOps Analysis Tools for DevMentorAI - * + * * These tools extend Copilot's capabilities with specialized DevOps functionality: * - Configuration file analysis * - Infrastructure best practices checking @@ -8,33 +8,33 @@ * - Cost estimation helpers */ -import * as fs from 'fs/promises'; -import * as path from 'path'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; export interface Tool { name: string; description: string; parameters: { type: 'object'; - properties: Record; + properties: Record< + string, + { + type: string; + description: string; + enum?: string[]; + } + >; required: string[]; }; handler: (params: Record) => Promise; } // Allowed directories for file access (security sandbox) -const ALLOWED_DIRECTORIES = [ - process.env.HOME || '/home', - '/tmp', -]; +const ALLOWED_DIRECTORIES = [process.env.HOME || '/home', '/tmp']; function isPathAllowed(filePath: string): boolean { const resolved = path.resolve(filePath); - return ALLOWED_DIRECTORIES.some(dir => resolved.startsWith(dir)); + return ALLOWED_DIRECTORIES.some((dir) => resolved.startsWith(dir)); } /** @@ -42,7 +42,8 @@ function isPathAllowed(filePath: string): boolean { */ export const readFileTool: Tool = { name: 'read_file', - description: 'Read the contents of a local file. Use this to analyze configuration files, logs, or code.', + description: + 'Read the contents of a local file. Use this to analyze configuration files, logs, or code.', parameters: { type: 'object', properties: { @@ -68,12 +69,11 @@ export const readFileTool: Tool = { try { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.split('\n'); - + if (lines.length > maxLines) { - return lines.slice(0, maxLines).join('\n') + - `\n\n[Truncated: ${lines.length - maxLines} more lines]`; + return `${lines.slice(0, maxLines).join('\n')}\n\n[Truncated: ${lines.length - maxLines} more lines]`; } - + return content; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { @@ -106,26 +106,26 @@ export const listDirectoryTool: Tool = { }, handler: async (params) => { const dirPath = params.path as string; - const recursive = params.recursive as boolean || false; + const recursive = (params.recursive as boolean) || false; if (!isPathAllowed(dirPath)) { return `Error: Access denied. Path "${dirPath}" is outside allowed directories.`; } - async function listDir(dir: string, depth: number = 0): Promise { + async function listDir(dir: string, depth = 0): Promise { if (depth > 3) return []; - + const entries = await fs.readdir(dir, { withFileTypes: true }); const results: string[] = []; - + for (const entry of entries) { const prefix = ' '.repeat(depth); const fullPath = path.join(dir, entry.name); - + if (entry.isDirectory()) { results.push(`${prefix}📁 ${entry.name}/`); if (recursive) { - results.push(...await listDir(fullPath, depth + 1)); + results.push(...(await listDir(fullPath, depth + 1))); } } else { const stats = await fs.stat(fullPath); @@ -133,7 +133,7 @@ export const listDirectoryTool: Tool = { results.push(`${prefix}📄 ${entry.name} (${size})`); } } - + return results; } @@ -162,7 +162,8 @@ function formatSize(bytes: number): string { */ export const analyzeConfigTool: Tool = { name: 'analyze_config', - description: 'Analyze a configuration file for DevOps best practices. Supports Kubernetes, Docker, Terraform, and CloudFormation.', + description: + 'Analyze a configuration file for DevOps best practices. Supports Kubernetes, Docker, Terraform, and CloudFormation.', parameters: { type: 'object', properties: { @@ -180,7 +181,7 @@ export const analyzeConfigTool: Tool = { }, handler: async (params) => { const content = params.content as string; - let configType = params.type as string || 'auto'; + let configType = (params.type as string) || 'auto'; // Auto-detect config type if (configType === 'auto') { @@ -207,23 +208,23 @@ export const analyzeConfigTool: Tool = { analyzeGitHubActions(content, issues, suggestions); break; default: - return `Could not determine configuration type. Please specify the type parameter.`; + return 'Could not determine configuration type. Please specify the type parameter.'; } let result = `## Configuration Analysis (${configType})\n\n`; - + if (issues.length > 0) { - result += `### ⚠️ Issues Found\n`; + result += '### ⚠️ Issues Found\n'; issues.forEach((issue, i) => { result += `${i + 1}. ${issue}\n`; }); result += '\n'; } else { - result += `### ✅ No Critical Issues Found\n\n`; + result += '### ✅ No Critical Issues Found\n\n'; } if (suggestions.length > 0) { - result += `### 💡 Suggestions\n`; + result += '### 💡 Suggestions\n'; suggestions.forEach((suggestion, i) => { result += `${i + 1}. ${suggestion}\n`; }); @@ -235,9 +236,11 @@ export const analyzeConfigTool: Tool = { function detectConfigType(content: string): string { if (content.includes('apiVersion:') && content.includes('kind:')) return 'kubernetes'; - if (content.includes('FROM ') || content.includes('COPY ') || content.includes('RUN ')) return 'docker'; + if (content.includes('FROM ') || content.includes('COPY ') || content.includes('RUN ')) + return 'docker'; if (content.includes('resource "') || content.includes('provider "')) return 'terraform'; - if (content.includes('AWSTemplateFormatVersion') || content.includes('Resources:')) return 'cloudformation'; + if (content.includes('AWSTemplateFormatVersion') || content.includes('Resources:')) + return 'cloudformation'; if (content.includes('jobs:') && content.includes('runs-on:')) return 'github-actions'; return 'unknown'; } @@ -245,24 +248,26 @@ function detectConfigType(content: string): string { function analyzeKubernetes(content: string, issues: string[], suggestions: string[]): void { // Resource limits if (!content.includes('resources:') || !content.includes('limits:')) { - issues.push('Missing resource limits. Pods without limits can consume excessive cluster resources.'); + issues.push( + 'Missing resource limits. Pods without limits can consume excessive cluster resources.' + ); } - + // Security context if (!content.includes('securityContext:')) { suggestions.push('Consider adding securityContext to restrict container privileges.'); } - + // Image tag if (content.includes(':latest')) { issues.push('Using :latest tag. Pin specific image versions for reproducible deployments.'); } - + // Probes if (!content.includes('livenessProbe:') && !content.includes('readinessProbe:')) { suggestions.push('Add health probes (livenessProbe/readinessProbe) for better reliability.'); } - + // Namespace if (!content.includes('namespace:')) { suggestions.push('Explicitly specify namespace to avoid deploying to default namespace.'); @@ -276,36 +281,45 @@ function analyzeKubernetes(content: string, issues: string[], suggestions: strin function analyzeDocker(content: string, issues: string[], suggestions: string[]): void { const lines = content.split('\n'); - + // Base image - const fromLine = lines.find(l => l.trim().startsWith('FROM ')); + const fromLine = lines.find((l) => l.trim().startsWith('FROM ')); if (fromLine?.includes(':latest')) { issues.push('Using :latest base image. Pin a specific version for reproducible builds.'); } - + // Multiple RUN commands - const runCount = lines.filter(l => l.trim().startsWith('RUN ')).length; + const runCount = lines.filter((l) => l.trim().startsWith('RUN ')).length; if (runCount > 5) { - suggestions.push(`${runCount} separate RUN commands. Consider combining them to reduce image layers.`); + suggestions.push( + `${runCount} separate RUN commands. Consider combining them to reduce image layers.` + ); } - + // COPY vs ADD if (content.includes('ADD ') && !content.includes('.tar') && !content.includes('http')) { - suggestions.push('Use COPY instead of ADD for simple file copying. ADD has extra features that may be unnecessary.'); + suggestions.push( + 'Use COPY instead of ADD for simple file copying. ADD has extra features that may be unnecessary.' + ); } - + // .dockerignore reminder if (content.includes('COPY . ') || content.includes('COPY ./ ')) { - suggestions.push('Copying entire context. Ensure .dockerignore is configured to exclude unnecessary files.'); + suggestions.push( + 'Copying entire context. Ensure .dockerignore is configured to exclude unnecessary files.' + ); } - + // Non-root user if (!content.includes('USER ')) { suggestions.push('No USER directive. Consider running as non-root user for security.'); } - + // Multi-stage builds - if (!content.includes('AS ') && content.includes('npm install') || content.includes('go build')) { + if ( + (!content.includes('AS ') && content.includes('npm install')) || + content.includes('go build') + ) { suggestions.push('Consider multi-stage builds to reduce final image size.'); } } @@ -313,35 +327,34 @@ function analyzeDocker(content: string, issues: string[], suggestions: string[]) function analyzeTerraform(content: string, issues: string[], suggestions: string[]): void { // Version constraints if (!content.includes('required_version') && !content.includes('required_providers')) { - issues.push('Missing version constraints. Pin Terraform and provider versions for reproducibility.'); + issues.push( + 'Missing version constraints. Pin Terraform and provider versions for reproducibility.' + ); } - + // Hardcoded values - const hardcodedPatterns = [ - /ami-[a-z0-9]+/, - /subnet-[a-z0-9]+/, - /sg-[a-z0-9]+/, - /vpc-[a-z0-9]+/, - ]; + const hardcodedPatterns = [/ami-[a-z0-9]+/, /subnet-[a-z0-9]+/, /sg-[a-z0-9]+/, /vpc-[a-z0-9]+/]; for (const pattern of hardcodedPatterns) { if (pattern.test(content)) { - suggestions.push('Hardcoded AWS resource IDs detected. Consider using data sources or variables.'); + suggestions.push( + 'Hardcoded AWS resource IDs detected. Consider using data sources or variables.' + ); break; } } - + // State backend if (!content.includes('backend "')) { suggestions.push('No remote backend configured. Use S3/GCS/Azure for team collaboration.'); } - + // Sensitive variables if (content.includes('password') || content.includes('secret') || content.includes('api_key')) { if (!content.includes('sensitive = true')) { issues.push('Potentially sensitive variables without sensitive = true flag.'); } } - + // Encryption if (content.includes('aws_s3_bucket') && !content.includes('server_side_encryption')) { suggestions.push('S3 bucket without explicit encryption configuration.'); @@ -355,15 +368,17 @@ function analyzeCloudFormation(content: string, issues: string[], suggestions: s issues.push('Stateful resources without DeletionPolicy. Data may be lost on stack deletion.'); } } - + // UpdateReplacePolicy if (!content.includes('UpdateReplacePolicy')) { suggestions.push('Consider adding UpdateReplacePolicy for stateful resources.'); } - + // Stack drift detection hint - suggestions.push('Run `aws cloudformation detect-stack-drift` regularly to catch manual changes.'); - + suggestions.push( + 'Run `aws cloudformation detect-stack-drift` regularly to catch manual changes.' + ); + // Parameters without constraints if (content.includes('Parameters:') && !content.includes('AllowedValues')) { suggestions.push('Consider adding AllowedValues constraints to parameters.'); @@ -375,23 +390,25 @@ function analyzeGitHubActions(content: string, issues: string[], suggestions: st if (content.includes('uses: ') && !content.includes('@v') && !content.includes('@sha')) { issues.push('Actions without version pinning. Pin to specific versions or SHA for security.'); } - + // Secrets in env if (content.includes('${{ secrets.') && content.includes('echo ')) { issues.push('Potential secret exposure in echo/print commands.'); } - + // Permissions if (!content.includes('permissions:')) { suggestions.push('Explicitly define job permissions for better security (least privilege).'); } - + // Caching - if ((content.includes('npm ') || content.includes('pip ') || content.includes('go ')) && - !content.includes('actions/cache')) { + if ( + (content.includes('npm ') || content.includes('pip ') || content.includes('go ')) && + !content.includes('actions/cache') + ) { suggestions.push('Consider adding caching to speed up workflows.'); } - + // Timeout if (!content.includes('timeout-minutes:')) { suggestions.push('Add timeout-minutes to prevent stuck workflows from running indefinitely.'); @@ -421,88 +438,119 @@ export const analyzeErrorTool: Tool = { }, handler: async (params) => { const error = params.error as string; - const context = params.context as string || 'general'; + const context = (params.context as string) || 'general'; const analysis: string[] = []; const possibleCauses: string[] = []; const solutions: string[] = []; - // Common patterns + // Common patterns - collect all causes and solutions first, then apply + const causesToAdd: string[] = []; + const solutionsToAdd: string[] = []; + if (error.includes('permission denied') || error.includes('EACCES')) { - possibleCauses.push('Insufficient file system permissions'); - solutions.push('Check file/directory permissions with `ls -la`'); - solutions.push('Try running with appropriate user or `sudo` if necessary'); + causesToAdd.push('Insufficient file system permissions'); + solutionsToAdd.push( + 'Check file/directory permissions with `ls -la`', + 'Try running with appropriate user or `sudo` if necessary' + ); } if (error.includes('connection refused') || error.includes('ECONNREFUSED')) { - possibleCauses.push('Target service is not running or not listening on expected port'); - solutions.push('Verify the service is running: `systemctl status `'); - solutions.push('Check if the port is open: `netstat -tlnp | grep `'); + causesToAdd.push('Target service is not running or not listening on expected port'); + solutionsToAdd.push( + 'Verify the service is running: `systemctl status `', + 'Check if the port is open: `netstat -tlnp | grep `' + ); } if (error.includes('out of memory') || error.includes('OOMKilled')) { - possibleCauses.push('Application exceeded memory limits'); - solutions.push('Increase memory limits if possible'); - solutions.push('Profile memory usage to find leaks'); - solutions.push('Consider horizontal scaling'); + causesToAdd.push('Application exceeded memory limits'); + solutionsToAdd.push( + 'Increase memory limits if possible', + 'Profile memory usage to find leaks', + 'Consider horizontal scaling' + ); } if (error.includes('timeout') || error.includes('ETIMEDOUT')) { - possibleCauses.push('Network latency or unreachable endpoint'); - possibleCauses.push('Slow database queries or API responses'); - solutions.push('Check network connectivity: `ping`, `traceroute`'); - solutions.push('Review and optimize slow queries'); - solutions.push('Consider increasing timeout values'); + causesToAdd.push( + 'Network latency or unreachable endpoint', + 'Slow database queries or API responses' + ); + solutionsToAdd.push( + 'Check network connectivity: `ping`, `traceroute`', + 'Review and optimize slow queries', + 'Consider increasing timeout values' + ); } // Context-specific patterns if (context === 'kubernetes') { if (error.includes('CrashLoopBackOff')) { - possibleCauses.push('Container fails to start or crashes immediately'); - solutions.push('Check container logs: `kubectl logs --previous`'); - solutions.push('Verify image exists and is pullable'); - solutions.push('Check resource limits and requests'); + causesToAdd.push('Container fails to start or crashes immediately'); + solutionsToAdd.push( + 'Check container logs: `kubectl logs --previous`', + 'Verify image exists and is pullable', + 'Check resource limits and requests' + ); } if (error.includes('ImagePullBackOff')) { - possibleCauses.push('Cannot pull container image'); - solutions.push('Verify image name and tag'); - solutions.push('Check image registry credentials (imagePullSecrets)'); + causesToAdd.push('Cannot pull container image'); + solutionsToAdd.push( + 'Verify image name and tag', + 'Check image registry credentials (imagePullSecrets)' + ); } } if (context === 'docker') { if (error.includes('no space left on device')) { - possibleCauses.push('Docker disk space exhausted'); - solutions.push('Clean up: `docker system prune -a`'); - solutions.push('Remove unused images: `docker image prune`'); + causesToAdd.push('Docker disk space exhausted'); + solutionsToAdd.push( + 'Clean up: `docker system prune -a`', + 'Remove unused images: `docker image prune`' + ); } } + // Apply collected items + possibleCauses.push(...causesToAdd); + solutions.push(...solutionsToAdd); + if (possibleCauses.length === 0) { - analysis.push('No specific pattern matched. Consider:'); - analysis.push('- Searching the error message in documentation'); - analysis.push('- Checking application logs for more context'); - analysis.push('- Verifying configuration and environment variables'); + analysis.push( + 'No specific pattern matched. Consider:', + '- Searching the error message in documentation', + '- Checking application logs for more context', + '- Verifying configuration and environment variables' + ); } - let result = `## Error Analysis\n\n`; + let result = '## Error Analysis\n\n'; result += `**Context:** ${context}\n\n`; - + if (possibleCauses.length > 0) { - result += `### Possible Causes\n`; - possibleCauses.forEach(cause => result += `- ${cause}\n`); + result += '### Possible Causes\n'; + for (const cause of possibleCauses) { + result += `- ${cause}\n`; + } result += '\n'; } - + if (solutions.length > 0) { - result += `### Suggested Solutions\n`; - solutions.forEach((solution, i) => result += `${i + 1}. ${solution}\n`); + result += '### Suggested Solutions\n'; + for (const [index, solution] of solutions.entries()) { + result += `${index + 1}. ${solution}\n`; + } result += '\n'; } - + if (analysis.length > 0) { - result += `### Notes\n`; - analysis.forEach(note => result += `${note}\n`); + result += '### Notes\n'; + for (const note of analysis) { + result += `${note}\n`; + } } return result; @@ -514,7 +562,8 @@ export const analyzeErrorTool: Tool = { */ export const fetchUrlTool: Tool = { name: 'fetch_url', - description: 'Fetch the text content of a public URL. Use this to read documentation, github issues, or other public web pages when the user shares a URL.', + description: + 'Fetch the text content of a public URL. Use this to read documentation, github issues, or other public web pages when the user shares a URL.', parameters: { type: 'object', properties: { @@ -527,7 +576,7 @@ export const fetchUrlTool: Tool = { }, handler: async (params) => { const targetUrl = params.url as string; - + if (!targetUrl.startsWith('http://') && !targetUrl.startsWith('https://')) { return `Error: Only HTTP/HTTPS URLs are supported, got "${targetUrl}"`; } @@ -536,36 +585,40 @@ export const fetchUrlTool: Tool = { // Abort after 10 seconds to avoid hanging const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); - + const response = await fetch(targetUrl, { signal: controller.signal, headers: { 'User-Agent': 'DevMentorAI/1.0', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8', + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8', }, }); - + clearTimeout(timeoutId); - + if (!response.ok) { return `Error: Server responded with status ${response.status} ${response.statusText}`; } - - const contentType = response.headers.get('content-type') || ''; + const text = await response.text(); - - // If it's an HTML page, we should ideally strip tags, but for a simple tool, + + // If it's an HTML page, we should ideally strip tags, but for a simple tool, // returning the raw text/HTML up to a reasonable limit is a good start. // We'll limit the response size to avoid overwhelming the LLM context. const MAX_LENGTH = 15000; - + if (text.length > MAX_LENGTH) { - return text.substring(0, MAX_LENGTH) + `\n\n[Content truncated at ${MAX_LENGTH} characters]`; + return `${text.substring(0, MAX_LENGTH)}\n\n[Content truncated at ${MAX_LENGTH} characters]`; } - + return text; } catch (error) { - if ((error as any).name === 'AbortError') { + if ( + typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'AbortError' + ) { return `Error: Request to ${targetUrl} timed out after 10 seconds.`; } return `Error fetching URL: ${error instanceof Error ? error.message : String(error)}`; @@ -585,5 +638,5 @@ export const devopsTools: Tool[] = [ ]; export function getToolByName(name: string): Tool | undefined { - return devopsTools.find(tool => tool.name === name); + return devopsTools.find((tool) => tool.name === name); } diff --git a/apps/backend/test-banner.ts b/apps/backend/test-banner.ts index 597439f..ea59e31 100644 --- a/apps/backend/test-banner.ts +++ b/apps/backend/test-banner.ts @@ -1,3 +1,3 @@ export const banner = { - js: "import { createRequire as __createRequire } from 'module';\nconst require = __createRequire(import.meta.url);" + js: "import { createRequire as __createRequire } from 'module';\nconst require = __createRequire(import.meta.url);", }; diff --git a/apps/backend/test-require.js b/apps/backend/test-require.js index 5f593fd..2acb663 100644 --- a/apps/backend/test-require.js +++ b/apps/backend/test-require.js @@ -1,3 +1,3 @@ -import { createRequire } from 'module'; +import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); globalThis.require = require; diff --git a/apps/backend/tests/cli/cli.test.ts b/apps/backend/tests/cli/cli.test.ts index ddd89db..9f36f34 100644 --- a/apps/backend/tests/cli/cli.test.ts +++ b/apps/backend/tests/cli/cli.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect } from 'vitest'; import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { describe, expect, it } from 'vitest'; const CLI_PATH = path.resolve(__dirname, '../../dist/cli.js'); const CLI_EXISTS = fs.existsSync(CLI_PATH); diff --git a/apps/backend/tests/cli/daemon.test.ts b/apps/backend/tests/cli/daemon.test.ts index a929c54..3996b4d 100644 --- a/apps/backend/tests/cli/daemon.test.ts +++ b/apps/backend/tests/cli/daemon.test.ts @@ -1,16 +1,16 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; -import path from 'node:path'; import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Must use inline values in vi.mock factory (hoisted above variable declarations) -const TEST_BASE = path.join(os.tmpdir(), 'devmentorai-test-' + process.pid); +const TEST_BASE = path.join(os.tmpdir(), `devmentorai-test-${process.pid}`); vi.mock('../../src/lib/paths.js', () => { const _path = require('node:path'); const _os = require('node:os'); const _fs = require('node:fs'); - const base = _path.join(_os.tmpdir(), 'devmentorai-test-' + process.pid); + const base = _path.join(_os.tmpdir(), `devmentorai-test-${process.pid}`); return { PID_FILE: _path.join(base, 'server.pid'), LOG_FILE: _path.join(base, 'logs', 'server.log'), @@ -25,12 +25,12 @@ vi.mock('../../src/lib/paths.js', () => { }); import { - writePid, - readPid, - removePid, - isProcessRunning, healthcheck, + isProcessRunning, isServerRunning, + readPid, + removePid, + writePid, } from '../../src/lib/daemon.js'; describe('daemon utilities', () => { diff --git a/apps/backend/tests/services/context-prompt-builder.test.ts b/apps/backend/tests/services/context-prompt-builder.test.ts index 2d8b640..fc4381b 100644 --- a/apps/backend/tests/services/context-prompt-builder.test.ts +++ b/apps/backend/tests/services/context-prompt-builder.test.ts @@ -1,8 +1,8 @@ /** * Context Prompt Builder Tests - * + * * Tests for the context-aware prompt building service. - * + * * PHASE 3: These tests verify that: * - Prompts contain FACTUAL context only * - NO intent instructions are included (agent decides) @@ -10,14 +10,14 @@ * - systemPrompt is always null (don't override Copilot) */ -import { describe, it, expect } from 'vitest'; +import type { ContextPayload } from '@devmentorai/shared'; +import { describe, expect, it } from 'vitest'; import { buildContextAwarePrompt, buildSimplePrompt, - validateContext, sanitizeContext, + validateContext, } from '../../src/services/context-prompt-builder.ts'; -import type { ContextPayload } from '@devmentorai/shared'; // Helper to create a minimal valid context function createMockContext(overrides: Partial = {}): ContextPayload { @@ -50,7 +50,11 @@ function createMockContext(overrides: Partial = {}): ContextPayl visibleText: 'Create virtual machine\nSize: Standard_D2s_v3\nStatus: Running', headings: [ { level: 1, text: 'Virtual Machines', hierarchy: 'Virtual Machines' }, - { level: 2, text: 'Create a virtual machine', hierarchy: 'Virtual Machines > Create a virtual machine' }, + { + level: 2, + text: 'Create a virtual machine', + hierarchy: 'Virtual Machines > Create a virtual machine', + }, ], errors: [], }, @@ -324,25 +328,25 @@ describe('Context Prompt Builder', () => { it('should return false for missing metadata', () => { const context = createMockContext(); - delete (context as Record).metadata; + (context as Record).metadata = undefined; expect(validateContext(context)).toBe(false); }); it('should return false for missing page', () => { const context = createMockContext(); - delete (context as Record).page; + (context as Record).page = undefined; expect(validateContext(context)).toBe(false); }); it('should return false for missing text', () => { const context = createMockContext(); - delete (context as Record).text; + (context as Record).text = undefined; expect(validateContext(context)).toBe(false); }); it('should return false for missing session', () => { const context = createMockContext(); - delete (context as Record).session; + (context as Record).session = undefined; expect(validateContext(context)).toBe(false); }); }); @@ -399,11 +403,13 @@ describe('Context Prompt Builder', () => { const sanitized = sanitizeContext(context); // URL encoding may encode the brackets, so check for both possibilities - const hasTokenRedacted = sanitized.page.url.includes('token=[REDACTED]') || - sanitized.page.url.includes('token=%5BREDACTED%5D'); - const hasKeyRedacted = sanitized.page.url.includes('key=[REDACTED]') || - sanitized.page.url.includes('key=%5BREDACTED%5D'); - + const hasTokenRedacted = + sanitized.page.url.includes('token=[REDACTED]') || + sanitized.page.url.includes('token=%5BREDACTED%5D'); + const hasKeyRedacted = + sanitized.page.url.includes('key=[REDACTED]') || + sanitized.page.url.includes('key=%5BREDACTED%5D'); + expect(hasTokenRedacted).toBe(true); expect(hasKeyRedacted).toBe(true); expect(sanitized.page.url).not.toContain('secret123'); @@ -451,9 +457,7 @@ describe('Context Prompt Builder', () => { selectedText: undefined, visibleText: 'Error occurred', headings: [], - errors: [ - { message: 'Critical system failure', type: 'error', severity: 'critical' }, - ], + errors: [{ message: 'Critical system failure', type: 'error', severity: 'critical' }], }, }); @@ -527,8 +531,16 @@ describe('Context Prompt Builder', () => { it('should include console error logs in prompt factually', () => { const context = createMockContext(); context.text.consoleLogs = [ - { level: 'error', message: 'Uncaught TypeError: Cannot read property of undefined', timestamp: '2024-01-01T12:00:00Z' }, - { level: 'warn', message: 'Deprecation warning: This API will be removed', timestamp: '2024-01-01T12:00:01Z' }, + { + level: 'error', + message: 'Uncaught TypeError: Cannot read property of undefined', + timestamp: '2024-01-01T12:00:00Z', + }, + { + level: 'warn', + message: 'Deprecation warning: This API will be removed', + timestamp: '2024-01-01T12:00:01Z', + }, ]; const result = buildContextAwarePrompt(context, 'What are the errors?'); @@ -542,11 +554,12 @@ describe('Context Prompt Builder', () => { it('should include stack trace for errors', () => { const context = createMockContext(); context.text.consoleLogs = [ - { - level: 'error', - message: 'Error in component', + { + level: 'error', + message: 'Error in component', timestamp: '2024-01-01T12:00:00Z', - stackTrace: 'at Component.render (app.js:123)\n at Object.updateComponent (react.js:456)' + stackTrace: + 'at Component.render (app.js:123)\n at Object.updateComponent (react.js:456)', }, ]; @@ -575,8 +588,20 @@ describe('Context Prompt Builder', () => { it('should include network errors in prompt factually', () => { const context = createMockContext(); context.text.networkErrors = [ - { url: 'https://api.example.com/users', method: 'GET', status: 404, statusText: 'Not Found', timestamp: '2024-01-01T12:00:00Z' }, - { url: 'https://api.example.com/posts', method: 'POST', status: 500, statusText: 'Internal Server Error', timestamp: '2024-01-01T12:00:01Z' }, + { + url: 'https://api.example.com/users', + method: 'GET', + status: 404, + statusText: 'Not Found', + timestamp: '2024-01-01T12:00:00Z', + }, + { + url: 'https://api.example.com/posts', + method: 'POST', + status: 500, + statusText: 'Internal Server Error', + timestamp: '2024-01-01T12:00:01Z', + }, ]; const result = buildContextAwarePrompt(context, 'Why are requests failing?'); @@ -591,7 +616,12 @@ describe('Context Prompt Builder', () => { it('should include network errors without status', () => { const context = createMockContext(); context.text.networkErrors = [ - { url: 'https://api.example.com/data', method: 'GET', errorMessage: 'Network request failed', timestamp: '2024-01-01T12:00:00Z' }, + { + url: 'https://api.example.com/data', + method: 'GET', + errorMessage: 'Network request failed', + timestamp: '2024-01-01T12:00:00Z', + }, ]; const result = buildContextAwarePrompt(context, 'Network issue'); @@ -604,17 +634,17 @@ describe('Context Prompt Builder', () => { it('should include code blocks in prompt', () => { const context = createMockContext(); context.structure.codeBlocks = [ - { - purpose: 'code-block', + { + purpose: 'code-block', outerHTML: '
const x = 1;
', textContent: 'const x = 1;', - attributes: { detectedLanguage: 'javascript' } + attributes: { detectedLanguage: 'javascript' }, }, - { - purpose: 'code-block', + { + purpose: 'code-block', outerHTML: '
apiVersion: v1
', textContent: 'apiVersion: v1\nkind: Pod', - attributes: { detectedLanguage: 'yaml' } + attributes: { detectedLanguage: 'yaml' }, }, ]; @@ -632,11 +662,11 @@ describe('Context Prompt Builder', () => { it('should include tables in prompt', () => { const context = createMockContext(); context.structure.tables = [ - { - purpose: 'table', + { + purpose: 'table', outerHTML: '...
', textContent: 'Name | Status | Age\nPod-1 | Running | 5d', - attributes: { rowCount: '10', columnCount: '3' } + attributes: { rowCount: '10', columnCount: '3' }, }, ]; @@ -655,7 +685,7 @@ describe('Context Prompt Builder', () => { purpose: 'modal', outerHTML: '
...
', textContent: 'Confirm deletion\n\nAre you sure you want to delete this resource?', - attributes: { title: 'Delete Resource' } + attributes: { title: 'Delete Resource' }, }; const result = buildContextAwarePrompt(context, 'What is this dialog asking?'); @@ -682,8 +712,8 @@ describe('Context Prompt Builder', () => { specificContext: { subscriptionId: 'sub-12345', resourceGroup: 'my-resource-group', - bladeName: 'VirtualMachines' - } + bladeName: 'VirtualMachines', + }, }, }, }); @@ -702,7 +732,12 @@ describe('Context Prompt Builder', () => { url: 'https://console.aws.amazon.com/ec2', hostname: 'console.aws.amazon.com', title: 'EC2 Dashboard', - urlParsed: { protocol: 'https:', pathname: '/ec2', search: '?region=us-east-1', hash: '' }, + urlParsed: { + protocol: 'https:', + pathname: '/ec2', + search: '?region=us-east-1', + hash: '', + }, platform: { type: 'aws', confidence: 0.95, @@ -711,8 +746,8 @@ describe('Context Prompt Builder', () => { specificContext: { service: 'ec2', region: 'us-east-1', - activeAlarms: 2 - } + activeAlarms: 2, + }, }, }, }); @@ -730,19 +765,27 @@ describe('Context Prompt Builder', () => { it('should exclude Phase 2 content when options are disabled', () => { const context = createMockContext(); context.text.consoleLogs = [ - { level: 'error', message: 'Test error', timestamp: '2024-01-01T12:00:00Z' } + { level: 'error', message: 'Test error', timestamp: '2024-01-01T12:00:00Z' }, ]; context.text.networkErrors = [ - { url: 'https://api.example.com', method: 'GET', status: 500, timestamp: '2024-01-01T12:00:00Z' } + { + url: 'https://api.example.com', + method: 'GET', + status: 500, + timestamp: '2024-01-01T12:00:00Z', + }, ]; context.structure.codeBlocks = [ - { purpose: 'code-block', outerHTML: '', textContent: 'code', attributes: {} } + { purpose: 'code-block', outerHTML: '', textContent: 'code', attributes: {} }, ]; context.structure.tables = [ - { purpose: 'table', outerHTML: '', textContent: 'data', attributes: {} } + { purpose: 'table', outerHTML: '
', textContent: 'data', attributes: {} }, ]; context.structure.modal = { - purpose: 'modal', outerHTML: '
', textContent: 'modal', attributes: {} + purpose: 'modal', + outerHTML: '
', + textContent: 'modal', + attributes: {}, }; const result = buildContextAwarePrompt(context, 'Test', { diff --git a/apps/backend/tests/services/copilot.service.test.ts b/apps/backend/tests/services/copilot.service.test.ts index b278270..778fd15 100644 --- a/apps/backend/tests/services/copilot.service.test.ts +++ b/apps/backend/tests/services/copilot.service.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CopilotService } from '../../src/services/copilot.service'; import { SessionService } from '../../src/services/session.service'; @@ -20,7 +20,7 @@ describe('CopilotService', () => { beforeEach(() => { db = new Database(':memory:'); - + // Initialize schema db.exec(` CREATE TABLE IF NOT EXISTS sessions ( @@ -60,16 +60,16 @@ describe('CopilotService', () => { describe('initialization', () => { it('should initialize in mock mode when Copilot CLI is not available', async () => { await copilotService.initialize(); - + expect(copilotService.isReady()).toBe(true); expect(copilotService.isMockMode()).toBe(true); }); it('should report ready state after initialization', async () => { expect(copilotService.isReady()).toBe(false); - + await copilotService.initialize(); - + expect(copilotService.isReady()).toBe(true); }); }); @@ -80,23 +80,15 @@ describe('CopilotService', () => { }); it('should create a mock session for DevOps type', async () => { - await copilotService.createCopilotSession( - 'test-session-1', - 'devops', - 'gpt-4.1' - ); - + await copilotService.createCopilotSession('test-session-1', 'devops', 'gpt-4.1'); + // Should not throw in mock mode expect(copilotService.isMockMode()).toBe(true); }); it('should create a mock session for Writing type', async () => { - await copilotService.createCopilotSession( - 'test-session-2', - 'writing', - 'gpt-4.1' - ); - + await copilotService.createCopilotSession('test-session-2', 'writing', 'gpt-4.1'); + expect(copilotService.isMockMode()).toBe(true); }); @@ -107,23 +99,19 @@ describe('CopilotService', () => { 'gpt-4.1', 'Custom system prompt for testing' ); - + expect(copilotService.isMockMode()).toBe(true); }); it('should resume session in mock mode', async () => { const result = await copilotService.resumeCopilotSession('any-session-id'); - + expect(result).toBe(true); }); it('should destroy session without error', async () => { - await copilotService.createCopilotSession( - 'test-session-4', - 'devops', - 'gpt-4.1' - ); - + await copilotService.createCopilotSession('test-session-4', 'devops', 'gpt-4.1'); + // Should not throw await copilotService.destroySession('test-session-4'); }); @@ -132,74 +120,59 @@ describe('CopilotService', () => { describe('mock message handling', () => { beforeEach(async () => { await copilotService.initialize(); - await copilotService.createCopilotSession( - 'mock-session', - 'devops', - 'gpt-4.1' - ); + await copilotService.createCopilotSession('mock-session', 'devops', 'gpt-4.1'); }); it('should generate mock response for explain action', async () => { - const response = await copilotService.sendMessage( - 'mock-session', - 'Explain this code', - { action: 'explain', selectedText: 'const x = 1;' } - ); - + const response = await copilotService.sendMessage('mock-session', 'Explain this code', { + action: 'explain', + selectedText: 'const x = 1;', + }); + expect(response).toContain('Explanation'); }); it('should generate mock response for translate action', async () => { - const response = await copilotService.sendMessage( - 'mock-session', - 'Translate this', - { action: 'translate', selectedText: 'Hello world' } - ); - + const response = await copilotService.sendMessage('mock-session', 'Translate this', { + action: 'translate', + selectedText: 'Hello world', + }); + expect(response).toContain('Translation'); }); it('should generate mock response for rewrite action', async () => { - const response = await copilotService.sendMessage( - 'mock-session', - 'Rewrite this text', - { action: 'rewrite', selectedText: 'Original text' } - ); - + const response = await copilotService.sendMessage('mock-session', 'Rewrite this text', { + action: 'rewrite', + selectedText: 'Original text', + }); + expect(response).toContain('Rewritten'); }); it('should generate mock response for fix_grammar action', async () => { - const response = await copilotService.sendMessage( - 'mock-session', - 'Fix grammar', - { action: 'fix_grammar', selectedText: 'Me has error' } - ); - + const response = await copilotService.sendMessage('mock-session', 'Fix grammar', { + action: 'fix_grammar', + selectedText: 'Me has error', + }); + expect(response).toContain('Corrected'); }); it('should generate generic mock response for regular prompts', async () => { - const response = await copilotService.sendMessage( - 'mock-session', - 'What is Kubernetes?' - ); - + const response = await copilotService.sendMessage('mock-session', 'What is Kubernetes?'); + expect(response).toContain('Mock Response'); expect(response).toContain('Copilot SDK is not available'); }); it('should include context in response when provided', async () => { - const response = await copilotService.sendMessage( - 'mock-session', - 'Analyze this', - { - pageUrl: 'https://example.com', - pageTitle: 'Example Page', - selectedText: 'Some selected text' - } - ); - + const response = await copilotService.sendMessage('mock-session', 'Analyze this', { + pageUrl: 'https://example.com', + pageTitle: 'Example Page', + selectedText: 'Some selected text', + }); + expect(response).toBeDefined(); expect(typeof response).toBe('string'); }); @@ -208,30 +181,23 @@ describe('CopilotService', () => { describe('streaming', () => { beforeEach(async () => { await copilotService.initialize(); - await copilotService.createCopilotSession( - 'stream-session', - 'devops', - 'gpt-4.1' - ); + await copilotService.createCopilotSession('stream-session', 'devops', 'gpt-4.1'); }); it('should stream mock response with events', async () => { const events: any[] = []; - - await copilotService.streamMessage( - 'stream-session', - 'Hello', - undefined, - (event) => events.push(event) + + await copilotService.streamMessage('stream-session', 'Hello', undefined, (event) => + events.push(event) ); - + // Should receive delta events and final message expect(events.length).toBeGreaterThan(0); - - const deltaEvents = events.filter(e => e.type === 'assistant.message_delta'); - const messageEvent = events.find(e => e.type === 'assistant.message'); - const idleEvent = events.find(e => e.type === 'session.idle'); - + + const deltaEvents = events.filter((e) => e.type === 'assistant.message_delta'); + const messageEvent = events.find((e) => e.type === 'assistant.message'); + const idleEvent = events.find((e) => e.type === 'session.idle'); + expect(deltaEvents.length).toBeGreaterThan(0); expect(messageEvent).toBeDefined(); expect(idleEvent).toBeDefined(); @@ -239,15 +205,15 @@ describe('CopilotService', () => { it('should include selected text context in streaming', async () => { const events: any[] = []; - + await copilotService.streamMessage( 'stream-session', 'Explain', { action: 'explain', selectedText: 'test code' }, (event) => events.push(event) ); - - const messageEvent = events.find(e => e.type === 'assistant.message'); + + const messageEvent = events.find((e) => e.type === 'assistant.message'); expect(messageEvent?.data.content).toContain('Explanation'); }); @@ -263,12 +229,7 @@ describe('CopilotService', () => { }); await expect( - copilotService.streamMessage( - 'real-stream-session', - 'Trigger stream', - undefined, - vi.fn() - ) + copilotService.streamMessage('real-stream-session', 'Trigger stream', undefined, vi.fn()) ).rejects.toThrow('Mock send failure'); }); }); @@ -279,12 +240,8 @@ describe('CopilotService', () => { }); it('should abort request without error in mock mode', async () => { - await copilotService.createCopilotSession( - 'abort-session', - 'devops', - 'gpt-4.1' - ); - + await copilotService.createCopilotSession('abort-session', 'devops', 'gpt-4.1'); + // Should not throw await copilotService.abortRequest('abort-session'); }); @@ -295,15 +252,11 @@ describe('CopilotService', () => { }); it('should shutdown cleanly', async () => { - await copilotService.createCopilotSession( - 'shutdown-session', - 'devops', - 'gpt-4.1' - ); - + await copilotService.createCopilotSession('shutdown-session', 'devops', 'gpt-4.1'); + // Should not throw await copilotService.shutdown(); - + expect(copilotService.isReady()).toBe(false); }); }); @@ -315,19 +268,12 @@ describe('CopilotService', () => { it('should support all session types', async () => { const types = ['devops', 'writing', 'development', 'general'] as const; - + for (const type of types) { - await copilotService.createCopilotSession( - `session-${type}`, - type, - 'gpt-4.1' - ); - - const response = await copilotService.sendMessage( - `session-${type}`, - 'Test message' - ); - + await copilotService.createCopilotSession(`session-${type}`, type, 'gpt-4.1'); + + const response = await copilotService.sendMessage(`session-${type}`, 'Test message'); + expect(response).toBeDefined(); expect(typeof response).toBe('string'); } diff --git a/apps/backend/tests/services/session.service.test.ts b/apps/backend/tests/services/session.service.test.ts index d9d3c85..d1b5029 100644 --- a/apps/backend/tests/services/session.service.test.ts +++ b/apps/backend/tests/services/session.service.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SessionService } from '../../src/services/session.service.js'; describe('SessionService', () => { @@ -9,7 +9,7 @@ describe('SessionService', () => { beforeEach(() => { // Create in-memory database for testing db = new Database(':memory:'); - + // Initialize schema db.exec(` CREATE TABLE sessions ( @@ -20,6 +20,9 @@ describe('SessionService', () => { model TEXT NOT NULL DEFAULT 'gpt-4.1', system_prompt TEXT, custom_agent TEXT, + tone TEXT DEFAULT 'balanced', + explain_tradeoffs INTEGER DEFAULT 0, + reasoning_effort TEXT, message_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) @@ -104,8 +107,8 @@ describe('SessionService', () => { const retrieved = service.getSession(created.id); expect(retrieved).not.toBeNull(); - expect(retrieved!.id).toBe(created.id); - expect(retrieved!.name).toBe('Test'); + expect(retrieved?.id).toBe(created.id); + expect(retrieved?.name).toBe('Test'); }); }); @@ -153,7 +156,7 @@ describe('SessionService', () => { }); const updated = service.updateSession(session.id, { name: 'New Name' }); - expect(updated!.name).toBe('New Name'); + expect(updated?.name).toBe('New Name'); }); it('should update session status', () => { @@ -163,7 +166,7 @@ describe('SessionService', () => { }); const updated = service.updateSession(session.id, { status: 'paused' }); - expect(updated!.status).toBe('paused'); + expect(updated?.status).toBe('paused'); }); it('should update session model', () => { @@ -173,7 +176,7 @@ describe('SessionService', () => { }); const updated = service.updateSession(session.id, { model: 'gpt-5-mini' }); - expect(updated!.model).toBe('gpt-5-mini'); + expect(updated?.model).toBe('gpt-5-mini'); }); it('should return null for non-existent session', () => { @@ -208,7 +211,7 @@ describe('SessionService', () => { }); const message = service.addMessage(session.id, 'user', 'Hello!'); - + expect(message.id).toMatch(/^msg_/); expect(message.sessionId).toBe(session.id); expect(message.role).toBe('user'); @@ -221,13 +224,13 @@ describe('SessionService', () => { type: 'devops', }); - expect(service.getSession(session.id)!.messageCount).toBe(0); + expect(service.getSession(session.id)?.messageCount).toBe(0); service.addMessage(session.id, 'user', 'Hello!'); - expect(service.getSession(session.id)!.messageCount).toBe(1); + expect(service.getSession(session.id)?.messageCount).toBe(1); service.addMessage(session.id, 'assistant', 'Hi there!'); - expect(service.getSession(session.id)!.messageCount).toBe(2); + expect(service.getSession(session.id)?.messageCount).toBe(2); }); it('should list messages in order', () => { diff --git a/apps/backend/tests/services/thumbnail-service.test.ts b/apps/backend/tests/services/thumbnail-service.test.ts index e020880..1102313 100644 --- a/apps/backend/tests/services/thumbnail-service.test.ts +++ b/apps/backend/tests/services/thumbnail-service.test.ts @@ -1,31 +1,33 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'node:fs'; -import path from 'node:path'; import os from 'node:os'; -import { - processMessageImages, - getThumbnailFilePath, - deleteSessionImages, - deleteMessageImages, - toImageAttachments, - type ImageInput, - type ProcessedImage, -} from '../../src/services/thumbnail-service.js'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DATA_DIR, IMAGES_DIR, - getSessionImagesDir, - getMessageImagesDir, deleteDir, + getMessageImagesDir, + getSessionImagesDir, } from '../../src/lib/paths.js'; +import { + type ImageInput, + type ProcessedImage, + deleteMessageImages, + deleteSessionImages, + getThumbnailFilePath, + processMessageImages, + toImageAttachments, +} from '../../src/services/thumbnail-service.js'; // Test fixtures // Small 1x1 red pixel PNG encoded as base64 -const RED_PIXEL_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jH/wAAAABJRU5ErkJggg=='; +const RED_PIXEL_PNG = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jH/wAAAABJRU5ErkJggg=='; const PNG_DATA_URL = `data:image/png;base64,${RED_PIXEL_PNG}`; // Another valid PNG (slightly different red pixel) -const RED_PIXEL_PNG_2 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAEBgIApD5fRAAAAABJRU5ErkJggg=='; +const RED_PIXEL_PNG_2 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAEBgIApD5fRAAAAABJRU5ErkJggg=='; const PNG_DATA_URL_2 = `data:image/png;base64,${RED_PIXEL_PNG_2}`; const TEST_SESSION_ID = 'test_session_12345'; @@ -57,12 +59,14 @@ describe('ThumbnailService', () => { }); it('should process a single PNG image', async () => { - const images: ImageInput[] = [{ - id: 'img_1', - dataUrl: PNG_DATA_URL, - mimeType: 'image/png', - source: 'paste', - }]; + const images: ImageInput[] = [ + { + id: 'img_1', + dataUrl: PNG_DATA_URL, + mimeType: 'image/png', + source: 'paste', + }, + ]; const result = await processMessageImages( TEST_SESSION_ID, @@ -112,12 +116,14 @@ describe('ThumbnailService', () => { }); it('should handle screenshot source type', async () => { - const images: ImageInput[] = [{ - id: 'screenshot_1', - dataUrl: PNG_DATA_URL, - mimeType: 'image/png', - source: 'screenshot', - }]; + const images: ImageInput[] = [ + { + id: 'screenshot_1', + dataUrl: PNG_DATA_URL, + mimeType: 'image/png', + source: 'screenshot', + }, + ]; const result = await processMessageImages( TEST_SESSION_ID, @@ -148,18 +154,20 @@ describe('ThumbnailService', () => { }); it('should create directory structure correctly', async () => { - const images: ImageInput[] = [{ - id: 'img_1', - dataUrl: PNG_DATA_URL, - mimeType: 'image/png', - source: 'paste', - }]; + const images: ImageInput[] = [ + { + id: 'img_1', + dataUrl: PNG_DATA_URL, + mimeType: 'image/png', + source: 'paste', + }, + ]; await processMessageImages(TEST_SESSION_ID, TEST_MESSAGE_ID, images, TEST_BACKEND_URL); const messageDir = getMessageImagesDir(TEST_SESSION_ID, TEST_MESSAGE_ID); expect(fs.existsSync(messageDir)).toBe(true); - + const files = fs.readdirSync(messageDir); expect(files).toContain('thumb_0.jpg'); }); @@ -173,12 +181,14 @@ describe('ThumbnailService', () => { it('should return path for existing file', async () => { // First create a thumbnail - const images: ImageInput[] = [{ - id: 'img_1', - dataUrl: PNG_DATA_URL, - mimeType: 'image/png', - source: 'paste', - }]; + const images: ImageInput[] = [ + { + id: 'img_1', + dataUrl: PNG_DATA_URL, + mimeType: 'image/png', + source: 'paste', + }, + ]; await processMessageImages(TEST_SESSION_ID, TEST_MESSAGE_ID, images, TEST_BACKEND_URL); @@ -194,12 +204,14 @@ describe('ThumbnailService', () => { describe('deleteSessionImages', () => { it('should delete all images for a session', async () => { // Create images in multiple messages - const images: ImageInput[] = [{ - id: 'img_1', - dataUrl: PNG_DATA_URL, - mimeType: 'image/png', - source: 'paste', - }]; + const images: ImageInput[] = [ + { + id: 'img_1', + dataUrl: PNG_DATA_URL, + mimeType: 'image/png', + source: 'paste', + }, + ]; await processMessageImages(TEST_SESSION_ID, 'msg_1', images, TEST_BACKEND_URL); await processMessageImages(TEST_SESSION_ID, 'msg_2', images, TEST_BACKEND_URL); @@ -222,12 +234,14 @@ describe('ThumbnailService', () => { describe('deleteMessageImages', () => { it('should delete images for a specific message only', async () => { - const images: ImageInput[] = [{ - id: 'img_1', - dataUrl: PNG_DATA_URL, - mimeType: 'image/png', - source: 'paste', - }]; + const images: ImageInput[] = [ + { + id: 'img_1', + dataUrl: PNG_DATA_URL, + mimeType: 'image/png', + source: 'paste', + }, + ]; await processMessageImages(TEST_SESSION_ID, 'msg_1', images, TEST_BACKEND_URL); await processMessageImages(TEST_SESSION_ID, 'msg_2', images, TEST_BACKEND_URL); @@ -245,16 +259,18 @@ describe('ThumbnailService', () => { describe('toImageAttachments', () => { it('should convert processed images to attachment format', () => { - const processed: ProcessedImage[] = [{ - id: 'img_1', - source: 'paste', - mimeType: 'image/png', - dimensions: { width: 100, height: 100 }, - fileSize: 1234, - timestamp: '2024-01-01T00:00:00.000Z', - thumbnailUrl: 'http://localhost:3847/api/images/session/message/0', - thumbnailPath: 'images/session/message/thumb_0.jpg', - }]; + const processed: ProcessedImage[] = [ + { + id: 'img_1', + source: 'paste', + mimeType: 'image/png', + dimensions: { width: 100, height: 100 }, + fileSize: 1234, + timestamp: '2024-01-01T00:00:00.000Z', + thumbnailUrl: 'http://localhost:3847/api/images/session/message/0', + thumbnailPath: 'images/session/message/thumb_0.jpg', + }, + ]; const attachments = toImageAttachments(processed); diff --git a/apps/backend/tests/tools/devops-tools.test.ts b/apps/backend/tests/tools/devops-tools.test.ts index 6dbd511..842e534 100644 --- a/apps/backend/tests/tools/devops-tools.test.ts +++ b/apps/backend/tests/tools/devops-tools.test.ts @@ -1,14 +1,14 @@ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import * as fs from 'fs/promises'; -import * as path from 'path'; -import * as os from 'os'; -import { - readFileTool, - listDirectoryTool, - analyzeConfigTool, +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + analyzeConfigTool, analyzeErrorTool, devopsTools, getToolByName, + listDirectoryTool, + readFileTool, } from '../../src/tools/devops-tools.js'; describe('DevOps Tools', () => { @@ -17,9 +17,9 @@ describe('DevOps Tools', () => { beforeAll(async () => { // Create temp test directory - testDir = path.join(os.tmpdir(), 'devmentorai-test-' + Date.now()); + testDir = path.join(os.tmpdir(), `devmentorai-test-${Date.now()}`); await fs.mkdir(testDir, { recursive: true }); - + // Create test file testFile = path.join(testDir, 'test.txt'); await fs.writeFile(testFile, 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'); @@ -101,9 +101,9 @@ spec: - name: test image: nginx:latest `; - const result = await analyzeConfigTool.handler({ - content: k8sConfig, - type: 'kubernetes' + const result = await analyzeConfigTool.handler({ + content: k8sConfig, + type: 'kubernetes', }); expect(result).toContain('kubernetes'); expect(result).toContain(':latest'); @@ -116,9 +116,9 @@ kind: Service metadata: name: my-service `; - const result = await analyzeConfigTool.handler({ - content: k8sConfig, - type: 'auto' + const result = await analyzeConfigTool.handler({ + content: k8sConfig, + type: 'auto', }); expect(result).toContain('kubernetes'); }); @@ -133,9 +133,9 @@ RUN apt-get install -y vim RUN apt-get install -y git COPY . /app `; - const result = await analyzeConfigTool.handler({ - content: dockerfile, - type: 'docker' + const result = await analyzeConfigTool.handler({ + content: dockerfile, + type: 'docker', }); expect(result).toContain('docker'); expect(result).toContain(':latest'); @@ -154,9 +154,9 @@ resource "aws_instance" "example" { instance_type = "t2.micro" } `; - const result = await analyzeConfigTool.handler({ - content: tfConfig, - type: 'terraform' + const result = await analyzeConfigTool.handler({ + content: tfConfig, + type: 'terraform', }); expect(result).toContain('terraform'); expect(result).toContain('version constraints'); @@ -174,9 +174,9 @@ jobs: - run: npm install - run: npm test `; - const result = await analyzeConfigTool.handler({ - content: workflow, - type: 'github-actions' + const result = await analyzeConfigTool.handler({ + content: workflow, + type: 'github-actions', }); expect(result).toContain('github-actions'); expect(result).toContain('version pinning'); diff --git a/apps/backend/tsup.config.ts b/apps/backend/tsup.config.ts index 2283e1a..1210f4c 100644 --- a/apps/backend/tsup.config.ts +++ b/apps/backend/tsup.config.ts @@ -15,10 +15,7 @@ export default defineConfig({ // Bundle @devmentorai/shared and @github/copilot-sdk into the output noExternal: ['@devmentorai/shared', '@github/copilot-sdk'], // Keep native/binary deps as external — npm installs them with prebuilt binaries - external: [ - 'better-sqlite3', - 'sharp', - ], + external: ['better-sqlite3', 'sharp'], banner: { // Inject a require polyfill for bundled CJS modules that use require('util') etc. js: `import { createRequire as __createRequire } from 'module';\nif (typeof require === 'undefined') { globalThis.require = __createRequire(import.meta.url); }`, @@ -32,7 +29,7 @@ export default defineConfig({ for (const file of result.outputFiles) { if (file.path.endsWith('/cli.js') || file.path.endsWith('\\cli.js')) { file.contents = new TextEncoder().encode( - '#!/usr/bin/env node\n' + new TextDecoder().decode(file.contents) + `#!/usr/bin/env node\n${new TextDecoder().decode(file.contents)}` ); } } diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index f77e1ea..4cf668c 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -1,5 +1,5 @@ +import path from 'node:path'; import { defineConfig } from 'vitest/config'; -import path from 'path'; export default defineConfig({ test: { diff --git a/apps/extension/.releaserc.json b/apps/extension/.releaserc.json index 7e69e2e..eeca85a 100644 --- a/apps/extension/.releaserc.json +++ b/apps/extension/.releaserc.json @@ -48,8 +48,14 @@ "@semantic-release/github", { "assets": [ - { "path": ".output/*-chrome.zip", "label": "DevMentorAI-${nextRelease.version}-chrome.zip" }, - { "path": ".output/*-firefox.xpi", "label": "DevMentorAI-${nextRelease.version}-firefox.xpi" } + { + "path": ".output/*-chrome.zip", + "label": "DevMentorAI-${nextRelease.version}-chrome.zip" + }, + { + "path": ".output/*-firefox.xpi", + "label": "DevMentorAI-${nextRelease.version}-firefox.xpi" + } ], "successComment": false, "failComment": false, diff --git a/apps/extension/package.json b/apps/extension/package.json index 6d4db7e..73c9956 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -14,15 +14,15 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "typecheck": "tsc --noEmit", - "lint": "eslint . --ext .ts,.tsx", + "lint": "biome check src/", "clean": "rm -rf .output .wxt dist" }, "dependencies": { "@devmentorai/shared": "workspace:*", "clsx": "^2.1.1", "lucide-react": "^0.468.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", + "react": "^19.2.1", + "react-dom": "^19.2.1", "tailwind-merge": "^2.6.0" }, "devDependencies": { @@ -30,12 +30,12 @@ "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/exec": "^7.1.0", "@semantic-release/git": "^10.0.1", - "@semantic-release/github": "^12.0.5", + "@semantic-release/github": "^12.0.6", "@semantic-release/release-notes-generator": "^14.1.0", "@testing-library/dom": "^10.4.1", "@types/chrome": "^0.0.287", - "@types/react": "^19.0.2", - "@types/react-dom": "^19.0.2", + "@types/react": "^19.2.1", + "@types/react-dom": "^19.2.1", "@wxt-dev/module-react": "^1.1.2", "autoprefixer": "^10.4.20", "conventional-changelog-conventionalcommits": "^9.1.0", @@ -44,7 +44,7 @@ "semantic-release": "^25.0.3", "tailwindcss": "^3.4.17", "typescript": "^5.7.3", - "vitest": "^2.1.8", - "wxt": "^0.19.23" + "vitest": "^4.1.4", + "wxt": "^0.20.20" } } diff --git a/apps/extension/src/components/ActivityView.tsx b/apps/extension/src/components/ActivityView.tsx index 0ac31c9..4ecface 100644 --- a/apps/extension/src/components/ActivityView.tsx +++ b/apps/extension/src/components/ActivityView.tsx @@ -1,14 +1,21 @@ /** * Activity View Component - * + * * Displays Copilot activity states and tool executions for transparency. * Shows processing indicators, running tools, and errors. */ +import { Brain, CheckCircle, ChevronDown, ChevronUp, Loader2, Wrench, XCircle } from 'lucide-react'; import React, { useState, useEffect } from 'react'; -import { Loader2, CheckCircle, XCircle, Wrench, Brain, ChevronDown, ChevronUp } from 'lucide-react'; -export type ActivityStatus = 'idle' | 'thinking' | 'processing' | 'tool_running' | 'streaming' | 'complete' | 'error'; +export type ActivityStatus = + | 'idle' + | 'thinking' + | 'processing' + | 'tool_running' + | 'streaming' + | 'complete' + | 'error'; export interface ToolExecution { id: string; @@ -33,50 +40,53 @@ interface ActivityViewProps { compact?: boolean; } -const statusConfig: Record = { +const statusConfig: Record< + ActivityStatus, + { icon: React.ReactNode; label: string; color: string } +> = { idle: { icon: null, label: '', color: 'text-muted-foreground' }, - thinking: { - icon: , - label: 'Thinking...', - color: 'text-purple-500' + thinking: { + icon: , + label: 'Thinking...', + color: 'text-purple-500', }, - processing: { - icon: , - label: 'Processing...', - color: 'text-blue-500' + processing: { + icon: , + label: 'Processing...', + color: 'text-blue-500', }, - tool_running: { - icon: , - label: 'Running tool...', - color: 'text-orange-500' + tool_running: { + icon: , + label: 'Running tool...', + color: 'text-orange-500', }, - streaming: { - icon: , - label: 'Generating response...', - color: 'text-green-500' + streaming: { + icon: , + label: 'Generating response...', + color: 'text-green-500', }, - complete: { - icon: , - label: 'Complete', - color: 'text-green-500' + complete: { + icon: , + label: 'Complete', + color: 'text-green-500', }, - error: { - icon: , - label: 'Error', - color: 'text-red-500' + error: { + icon: , + label: 'Error', + color: 'text-red-500', }, }; -export function ActivityView({ activity, compact = false }: ActivityViewProps): React.ReactElement | null { +export function ActivityView({ + activity, + compact = false, +}: Readonly): React.ReactElement | null { const [expanded, setExpanded] = useState(false); const [showThinking, setShowThinking] = useState(false); - + const { status, message, toolExecutions, thinkingContent } = activity; const config = statusConfig[status]; - - // Don't render if idle - if (status === 'idle') return null; - + // Auto-collapse after completion useEffect(() => { if (status === 'complete') { @@ -84,7 +94,10 @@ export function ActivityView({ activity, compact = false }: ActivityViewProps): return () => clearTimeout(timer); } }, [status]); - + + // Don't render if idle + if (status === 'idle') return null; + if (compact) { return (
@@ -93,16 +106,17 @@ export function ActivityView({ activity, compact = false }: ActivityViewProps):
); } - - const runningTools = toolExecutions.filter(t => t.status === 'running'); - const completedTools = toolExecutions.filter(t => t.status === 'completed'); - const failedTools = toolExecutions.filter(t => t.status === 'error'); - + + const runningTools = toolExecutions.filter((t) => t.status === 'running'); + const completedTools = toolExecutions.filter((t) => t.status === 'completed'); + const failedTools = toolExecutions.filter((t) => t.status === 'error'); + return (
{/* Status Header */} -
setExpanded(!expanded)} >
@@ -119,22 +133,27 @@ export function ActivityView({ activity, compact = false }: ActivityViewProps): )} {expanded ? : }
-
- + + {/* Expanded Details */} {expanded && (
{/* Thinking Content (if available) */} {thinkingContent && (
-
setShowThinking(!showThinking)} > Reasoning - {showThinking ? : } -
+ {showThinking ? ( + + ) : ( + + )} + {showThinking && (
{thinkingContent} @@ -142,7 +161,7 @@ export function ActivityView({ activity, compact = false }: ActivityViewProps): )}
)} - + {/* Tool Executions */} {toolExecutions.length > 0 && (
@@ -162,31 +181,32 @@ interface ToolExecutionItemProps { tool: ToolExecution; } -function ToolExecutionItem({ tool }: ToolExecutionItemProps): React.ReactElement { +function ToolExecutionItem({ tool }: Readonly): React.ReactElement { const [showDetails, setShowDetails] = useState(false); - + const statusColors = { pending: 'text-muted-foreground', running: 'text-orange-500', completed: 'text-green-500', error: 'text-red-500', }; - + const statusIcons = { pending: , running: , completed: , error: , }; - - const duration = tool.completedAt + + const duration = tool.completedAt ? `${((tool.completedAt.getTime() - tool.startedAt.getTime()) / 1000).toFixed(1)}s` : null; - + return (
-
setShowDetails(!showDetails)} >
@@ -197,16 +217,14 @@ function ToolExecutionItem({ tool }: ToolExecutionItemProps): React.ReactElement {duration && {duration}} {showDetails ? : }
-
- + + {showDetails && (
{tool.input && (
Input: - - {JSON.stringify(tool.input, null, 2)} - + {JSON.stringify(tool.input, null, 2)}
)} {tool.output && ( @@ -233,48 +251,49 @@ function ToolExecutionItem({ tool }: ToolExecutionItemProps): React.ReactElement /** * Hook to manage activity state */ -export function useActivityState(): [ActivityState, { - setStatus: (status: ActivityStatus, message?: string) => void; - setThinking: (content: string) => void; - addToolExecution: (tool: Omit) => string; - updateToolExecution: (id: string, updates: Partial) => void; - reset: () => void; -}] { +export function useActivityState(): [ + ActivityState, + { + setStatus: (status: ActivityStatus, message?: string) => void; + setThinking: (content: string) => void; + addToolExecution: (tool: Omit) => string; + updateToolExecution: (id: string, updates: Partial) => void; + reset: () => void; + }, +] { const [activity, setActivity] = useState({ status: 'idle', toolExecutions: [], }); - + const setStatus = (status: ActivityStatus, message?: string) => { - setActivity(prev => ({ ...prev, status, message })); + setActivity((prev) => ({ ...prev, status, message })); }; - + const setThinking = (content: string) => { - setActivity(prev => ({ ...prev, thinkingContent: content })); + setActivity((prev) => ({ ...prev, thinkingContent: content })); }; - + const addToolExecution = (tool: Omit): string => { const id = `tool_${Date.now()}_${Math.random().toString(36).slice(2)}`; - setActivity(prev => ({ + setActivity((prev) => ({ ...prev, toolExecutions: [...prev.toolExecutions, { ...tool, id }], })); return id; }; - + const updateToolExecution = (id: string, updates: Partial) => { - setActivity(prev => ({ + setActivity((prev) => ({ ...prev, - toolExecutions: prev.toolExecutions.map(t => - t.id === id ? { ...t, ...updates } : t - ), + toolExecutions: prev.toolExecutions.map((t) => (t.id === id ? { ...t, ...updates } : t)), })); }; - + const reset = () => { setActivity({ status: 'idle', toolExecutions: [] }); }; - + return [activity, { setStatus, setThinking, addToolExecution, updateToolExecution, reset }]; } diff --git a/apps/extension/src/components/ChatView.tsx b/apps/extension/src/components/ChatView.tsx index 1859c53..ed7050c 100644 --- a/apps/extension/src/components/ChatView.tsx +++ b/apps/extension/src/components/ChatView.tsx @@ -1,19 +1,28 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; -import { Send, Square, Loader2, Cpu, ChevronDown, Brain, Sparkles, Globe, AlertTriangle, ImagePlus, Upload } from 'lucide-react'; +import type { + ContextPayload, + ImagePayload, + Message, + PlatformDetection, + Session, +} from '@devmentorai/shared'; +import { + AlertTriangle, + Brain, + ChevronDown, + Cpu, + Globe, + ImagePlus, + Loader2, + Send, + Sparkles, + Square, + Upload, +} from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useImageAttachments } from '../hooks/useImageAttachments'; import { cn } from '../lib/utils'; -import { MessageBubble } from './MessageBubble'; import { ImageAttachmentZone } from './ImageAttachmentZone'; -import { useImageAttachments } from '../hooks/useImageAttachments'; -import type { Session, Message, ContextPayload, PlatformDetection, ImagePayload, ModelInfo } from '@devmentorai/shared'; - -const PRICING_BADGES: Record = { - free: { label: 'Free', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' }, - cheap: { label: 'Cheap', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }, - standard: { label: 'Standard', color: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' }, - premium: { label: 'Premium', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' }, -}; - -const MODEL_TIERS = ['free', 'cheap', 'standard', 'premium'] as const; +import { MessageBubble } from './MessageBubble'; interface ChatViewProps { session: Session | null; @@ -22,8 +31,7 @@ interface ChatViewProps { isSending?: boolean; onSendMessage: (content: string, useContext?: boolean, images?: ImagePayload[]) => void; onAbort: () => void; - onChangeModel?: (model: string) => void; - availableModels?: ModelInfo[]; + onChangeModel?: () => void; disabled?: boolean; pendingText?: string; // Context-aware mode props @@ -49,7 +57,6 @@ export function ChatView({ onSendMessage, onAbort, onChangeModel, - availableModels = [], disabled = false, pendingText, // Context-aware mode @@ -66,8 +73,6 @@ export function ChatView({ onRegisterAddImage, }: Readonly) { const [input, setInput] = useState(''); - const [showModelPicker, setShowModelPicker] = useState(false); - const [modelSearch, setModelSearch] = useState(''); const [showContextPreview, setShowContextPreview] = useState(false); const [isCapturingScreenshot, setIsCapturingScreenshot] = useState(false); const messagesEndRef = useRef(null); @@ -91,10 +96,14 @@ export function ChatView({ clearError, } = useImageAttachments(); + const lastMessage = messages.at(-1); + // Auto-scroll to bottom when messages change useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); + if (lastMessage) { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + } + }, [lastMessage]); // Focus input when session changes useEffect(() => { @@ -127,7 +136,8 @@ export function ChatView({ if (pendingText) { const maxLength = 50000; setInput((prev) => { - const textToInsert = pendingText.length > maxLength ? pendingText.substring(0, maxLength) : pendingText; + const textToInsert = + pendingText.length > maxLength ? pendingText.substring(0, maxLength) : pendingText; if (!prev) { pendingCursorPositionRef.current = textToInsert.length; @@ -165,60 +175,69 @@ export function ChatView({ }; // Handle paste event for images - const handleInputPaste = useCallback(async (e: React.ClipboardEvent) => { - if (!imageAttachmentsEnabled) return; - - // Check if clipboard contains image data - const items = e.clipboardData?.items; - if (!items) return; - - for (const item of Array.from(items)) { - if (item.type.startsWith('image/')) { - e.preventDefault(); // Prevent default paste behavior for images - await handlePaste(e.nativeEvent as ClipboardEvent); - return; + const handleInputPaste = useCallback( + async (e: React.ClipboardEvent) => { + if (!imageAttachmentsEnabled) return; + + // Check if clipboard contains image data + const items = e.clipboardData?.items; + if (!items) return; + + for (const item of Array.from(items)) { + if (item.type.startsWith('image/')) { + e.preventDefault(); // Prevent default paste behavior for images + await handlePaste(e.nativeEvent); + return; + } } - } - // If no images, let default text paste happen - }, [imageAttachmentsEnabled, handlePaste]); + // If no images, let default text paste happen + }, + [imageAttachmentsEnabled, handlePaste] + ); // Handle screenshot capture - const handleCaptureScreenshot = useCallback(async (mode: 'visible') => { - if (!onCaptureScreenshot || isAtLimit) return; - - setIsCapturingScreenshot(true); - try { - const dataUrl = await onCaptureScreenshot(mode); - if (dataUrl) { - await addImage(dataUrl, 'screenshot'); + const handleCaptureScreenshot = useCallback( + async (mode: 'visible') => { + if (!onCaptureScreenshot || isAtLimit) return; + + setIsCapturingScreenshot(true); + try { + const dataUrl = await onCaptureScreenshot(mode); + if (dataUrl) { + await addImage(dataUrl, 'screenshot'); + } + } catch (error) { + console.error('[ChatView] Screenshot capture failed:', error); + } finally { + setIsCapturingScreenshot(false); } - } catch (error) { - console.error('[ChatView] Screenshot capture failed:', error); - } finally { - setIsCapturingScreenshot(false); - } - }, [onCaptureScreenshot, isAtLimit, addImage]); + }, + [onCaptureScreenshot, isAtLimit, addImage] + ); // Drag & drop state for form-level handling const [isDraggingOver, setIsDraggingOver] = useState(false); const dragCounterRef = useRef(0); // Handle drag events at form level - const handleFormDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!imageAttachmentsEnabled || disabled || isStreaming) return; - - dragCounterRef.current++; - if (e.dataTransfer?.types.includes('Files')) { - setIsDraggingOver(true); - } - }, [imageAttachmentsEnabled, disabled, isStreaming]); + const handleFormDragEnter = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!imageAttachmentsEnabled || disabled || isStreaming) return; + + dragCounterRef.current++; + if (e.dataTransfer?.types.includes('Files')) { + setIsDraggingOver(true); + } + }, + [imageAttachmentsEnabled, disabled, isStreaming] + ); const handleFormDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - + dragCounterRef.current--; if (dragCounterRef.current === 0) { setIsDraggingOver(false); @@ -230,29 +249,32 @@ export function ChatView({ e.stopPropagation(); }, []); - const handleFormDrop = useCallback(async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - dragCounterRef.current = 0; - setIsDraggingOver(false); - - if (!imageAttachmentsEnabled || disabled || isStreaming) return; - - // Use the handleDrop from useImageAttachments - await handleDrop(e.nativeEvent as DragEvent); - }, [imageAttachmentsEnabled, disabled, isStreaming, handleDrop]); + const handleFormDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragCounterRef.current = 0; + setIsDraggingOver(false); + + if (!imageAttachmentsEnabled || disabled || isStreaming) return; + + // Use the handleDrop from useImageAttachments + await handleDrop(e.nativeEvent); + }, + [imageAttachmentsEnabled, disabled, isStreaming, handleDrop] + ); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (disabled || isStreaming || isSending) return; - + // Allow sending with images even without text const hasContent = input.trim() || images.length > 0; if (!hasContent) return; // Get images for sending and clear them const imagesToSend = images.length > 0 ? getImagesForSend() : undefined; - + onSendMessage(input.trim(), contextEnabled, imagesToSend); setInput(''); clearImages(); @@ -267,37 +289,19 @@ export function ChatView({ const getSessionIcon = (type: Session['type']) => { switch (type) { - case 'devops': return '🔧'; - case 'writing': return '✍️'; - case 'development': return '💻'; - default: return '🤖'; + case 'devops': + return '🔧'; + case 'writing': + return '✍️'; + case 'development': + return '💻'; + default: + return '🤖'; } }; - const normalizedQuery = modelSearch.trim().toLowerCase(); - const filteredModels = normalizedQuery - ? availableModels.filter((model) => { - const searchSource = [ - model.id, - model.name, - model.provider, - model.description || '', - ] - .join(' ') - .toLowerCase(); - return searchSource.includes(normalizedQuery); - }) - : availableModels; - - const hasStartedChat = messages.length > 0; - const canUseModelPicker = Boolean(onChangeModel) && !disabled && !isStreaming && !hasStartedChat; - - useEffect(() => { - if (hasStartedChat && showModelPicker) { - setShowModelPicker(false); - setModelSearch(''); - } - }, [hasStartedChat, showModelPicker]); + // With SDK v0.2.x setModel(), we can change model anytime during chat + const canUseModelPicker = Boolean(onChangeModel) && !disabled && !isStreaming; let placeholderText = chrome.i18n.getMessage('placeholder_message') || 'Type a message...'; if (contextEnabled) { @@ -315,8 +319,8 @@ export function ChatView({ Welcome to DevMentorAI

- Create a new session to start chatting with your AI assistant. - Choose from DevOps, Writing, Development, or General assistance. + Create a new session to start chatting with your AI assistant. Choose from DevOps, + Writing, Development, or General assistance.

@@ -331,85 +335,35 @@ export function ChatView({ {getSessionIcon(session.type)} {session.name}
- - {/* Model selector */} + + {/* Model selector - SDK v0.2.x allows switching anytime */}
- - {showModelPicker && canUseModelPicker && availableModels.length > 0 && ( -
-
- setModelSearch(event.target.value)} - placeholder="Search models..." - className="w-full px-2.5 py-1.5 text-xs rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 focus:outline-none focus:ring-1 focus:ring-primary-500" - /> -
- - {filteredModels.length === 0 && ( -

No models found

- )} - - {MODEL_TIERS.map((tier) => { - const tierModels = filteredModels.filter( - (model) => model.pricingTier === tier || (!model.pricingTier && tier === 'standard') - ); - if (tierModels.length === 0) return null; - - return ( -
-
- - {PRICING_BADGES[tier]?.label || 'Standard'} - -
- - {tierModels.map((model) => ( - - ))} -
- ); - })} -
- )}
@@ -424,15 +378,18 @@ export function ChatView({

Quick prompts:

- {['Explain Kubernetes pods', 'Best practices for CI/CD', 'Debug AWS Lambda'].map((prompt) => ( - - ))} + {['Explain Kubernetes pods', 'Best practices for CI/CD', 'Debug AWS Lambda'].map( + (prompt) => ( + + ) + )}
)} @@ -440,9 +397,14 @@ export function ChatView({

Quick prompts:

- {['Write a professional email', 'Translate to Spanish', 'Make this more formal'].map((prompt) => ( + {[ + 'Write a professional email', + 'Translate to Spanish', + 'Make this more formal', + ].map((prompt) => (
) : ( - messages.map((message) => ( - - )) + messages.map((message) => ) )} - + {/* Sending indicator - shown when uploading images / initiating request */} {isSending && !isStreaming && (
@@ -478,9 +438,18 @@ export function ChatView({
- - - + + +
@@ -488,7 +457,7 @@ export function ChatView({
)} - +
@@ -501,8 +470,8 @@ export function ChatView({ onDragOver={handleFormDragOver} onDrop={handleFormDrop} className={cn( - "border-t border-gray-200 dark:border-gray-700 p-4 bg-white dark:bg-gray-800 relative", - isDraggingOver && "ring-2 ring-primary-400 ring-inset" + 'border-t border-gray-200 dark:border-gray-700 p-4 bg-white dark:bg-gray-800 relative', + isDraggingOver && 'ring-2 ring-primary-400 ring-inset' )} > {/* Drag overlay */} @@ -510,7 +479,9 @@ export function ChatView({
-

Drop images here

+

+ Drop images here +

)} @@ -526,7 +497,9 @@ export function ChatView({ error={lastError} onClearError={clearError} enabled={imageAttachmentsEnabled && !disabled && !isStreaming} - onCaptureScreenshot={screenshotBehavior === 'disabled' ? undefined : handleCaptureScreenshot} + onCaptureScreenshot={ + screenshotBehavior === 'disabled' ? undefined : handleCaptureScreenshot + } isCapturingScreenshot={isCapturingScreenshot} showScreenshotButton={screenshotBehavior !== 'disabled' && !!onCaptureScreenshot} /> @@ -561,7 +534,7 @@ export function ChatView({ {showContextPreview ? 'Hide' : 'Preview'}
- + {showContextPreview && (
@@ -569,12 +542,17 @@ export function ChatView({
{extractedContext.text.selectedText && (
- Selection: {extractedContext.text.selectedText.substring(0, 100)}... + Selection:{' '} + {extractedContext.text.selectedText.substring(0, 100)}...
)} {extractedContext.text.headings.length > 0 && (
- Headings: {extractedContext.text.headings.slice(0, 3).map(h => h.text).join(', ')} + Headings:{' '} + {extractedContext.text.headings + .slice(0, 3) + .map((h) => h.text) + .join(', ')}
)}
@@ -596,9 +574,12 @@ export function ChatView({ : 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600', (isStreaming || isExtractingContext) && 'opacity-50 cursor-not-allowed' )} - title={contextEnabled - ? (chrome.i18n.getMessage('context_mode_on') || 'Context mode ON - Click to disable') - : (chrome.i18n.getMessage('context_mode_off') || 'Use page context')} + title={ + contextEnabled + ? chrome.i18n.getMessage('context_mode_on') || + 'Context mode ON - Click to disable' + : chrome.i18n.getMessage('context_mode_off') || 'Use page context' + } > {isExtractingContext ? ( @@ -687,10 +668,10 @@ export function ChatView({ type="button" onClick={onAbort} className={cn( - "flex items-center justify-center w-12 h-12 rounded-xl text-white transition-colors shrink-0", - isStreaming ? "bg-red-500 hover:bg-red-600" : "bg-amber-500 hover:bg-amber-600" + 'flex items-center justify-center w-12 h-12 rounded-xl text-white transition-colors shrink-0', + isStreaming ? 'bg-red-500 hover:bg-red-600' : 'bg-amber-500 hover:bg-amber-600' )} - title={isStreaming ? "Stop" : "Cancel"} + title={isStreaming ? 'Stop' : 'Cancel'} > {isSending && !isStreaming ? ( @@ -714,11 +695,12 @@ export function ChatView({ )}
- + {/* Context mode indicator text */} {contextEnabled && (
- {chrome.i18n.getMessage('context_mode_hint') || 'Context mode: AI will analyze the current page'} + {chrome.i18n.getMessage('context_mode_hint') || + 'Context mode: AI will analyze the current page'}
)} diff --git a/apps/extension/src/components/ErrorBoundary.tsx b/apps/extension/src/components/ErrorBoundary.tsx index 195c56a..0bb932c 100644 --- a/apps/extension/src/components/ErrorBoundary.tsx +++ b/apps/extension/src/components/ErrorBoundary.tsx @@ -1,5 +1,5 @@ +import { AlertTriangle, ExternalLink, RefreshCw } from 'lucide-react'; import React from 'react'; -import { AlertTriangle, RefreshCw, ExternalLink } from 'lucide-react'; import { EXTENSION_VERSION } from '../version.js'; interface ErrorBoundaryProps { @@ -35,12 +35,12 @@ export class ErrorBoundary extends React.Component { const errorDetails = encodeURIComponent( `**Error Message:**\n\`\`\`\n${this.state.error?.message || 'Unknown error'}\n\`\`\`\n\n` + - `**Stack Trace:**\n\`\`\`\n${this.state.error?.stack || 'No stack trace'}\n\`\`\`\n\n` + - `**Component Stack:**\n\`\`\`\n${this.state.errorInfo?.componentStack || 'No component stack'}\n\`\`\`\n\n` + - `**Browser:** ${navigator.userAgent}\n` + - `**Extension Version:** ${EXTENSION_VERSION}` + `**Stack Trace:**\n\`\`\`\n${this.state.error?.stack || 'No stack trace'}\n\`\`\`\n\n` + + `**Component Stack:**\n\`\`\`\n${this.state.errorInfo?.componentStack || 'No component stack'}\n\`\`\`\n\n` + + `**Browser:** ${navigator.userAgent}\n` + + `**Extension Version:** ${EXTENSION_VERSION}` ); - + const issueUrl = `https://github.com/BOTOOM/devmentorai/issues/new?title=${encodeURIComponent('[Bug] Extension Error')}&body=${errorDetails}&labels=bug,extension`; globalThis.open(issueUrl, '_blank'); }; @@ -76,6 +76,7 @@ export class ErrorBoundary extends React.Component
- +
@@ -130,9 +129,9 @@ export function Header({ */} {/* D.2 - Help */} -
✍️
Writing Assistant: - Email writing, translation, rewriting, grammar fixes + + {' '} + Email writing, translation, rewriting, grammar fixes +
💻
Development Helper: - Code review, debugging, architecture decisions + + {' '} + Code review, debugging, architecture decisions +
🤖
General Assistant: - General purpose AI assistance + + {' '} + General purpose AI assistance +
@@ -164,7 +175,8 @@ export function HelpModal({ onClose }: Readonly) { className="inline-flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors" > - + GitHub + Found a bug? Report an issue diff --git a/apps/extension/src/components/ImageAttachmentZone.tsx b/apps/extension/src/components/ImageAttachmentZone.tsx index 12e9257..d642f2c 100644 --- a/apps/extension/src/components/ImageAttachmentZone.tsx +++ b/apps/extension/src/components/ImageAttachmentZone.tsx @@ -1,17 +1,17 @@ /** * ImageAttachmentZone Component - * + * * Expandable area above the chat input for managing image attachments. * Supports drag & drop, displays thumbnails, and allows removal. */ -import { useState, useCallback, useRef, useEffect } from 'react'; -import { ImagePlus, X, AlertCircle, Camera } from 'lucide-react'; -import { cn } from '../lib/utils'; -import { ImageThumbnail } from './ImageThumbnail'; -import { ImageLightbox } from './ImageLightbox'; import { IMAGE_CONSTANTS } from '@devmentorai/shared'; +import { AlertCircle, Camera, ImagePlus, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { DraftImage } from '../hooks/useImageAttachments'; +import { cn } from '../lib/utils'; +import { ImageLightbox } from './ImageLightbox'; +import { ImageThumbnail } from './ImageThumbnail'; interface ImageAttachmentZoneProps { /** Current draft images */ @@ -50,7 +50,7 @@ export function ImageAttachmentZone({ onCaptureScreenshot, isCapturingScreenshot = false, showScreenshotButton = false, -}: ImageAttachmentZoneProps) { +}: Readonly) { const [isDragOver, setIsDragOver] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(null); const dropZoneRef = useRef(null); @@ -59,21 +59,24 @@ export function ImageAttachmentZone({ const showZone = hasImages || isDragOver; // Handle drag events - const handleDragEnter = useCallback((e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (!enabled || isAtLimit) return; - - // Check if dragging files - if (e.dataTransfer?.types.includes('Files')) { - setIsDragOver(true); - } - }, [enabled, isAtLimit]); + const handleDragEnter = useCallback( + (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!enabled || isAtLimit) return; + + // Check if dragging files + if (e.dataTransfer?.types.includes('Files')) { + setIsDragOver(true); + } + }, + [enabled, isAtLimit] + ); const handleDragLeave = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); - + // Only hide if leaving the drop zone entirely const relatedTarget = e.relatedTarget as Node | null; if (!dropZoneRef.current?.contains(relatedTarget)) { @@ -86,14 +89,17 @@ export function ImageAttachmentZone({ e.stopPropagation(); }, []); - const handleDrop = useCallback(async (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - - if (!enabled) return; - await onDrop(e); - }, [enabled, onDrop]); + const handleDrop = useCallback( + async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + + if (!enabled) return; + await onDrop(e); + }, + [enabled, onDrop] + ); // Set up drag event listeners on the document for global drop detection useEffect(() => { @@ -156,6 +162,7 @@ export function ImageAttachmentZone({ {error}
) : ( @@ -208,24 +217,25 @@ export function ImageAttachmentZone({ {/* Screenshot buttons */} {showScreenshotButton && onCaptureScreenshot && !isAtLimit && ( - <> - - + )} )} @@ -249,7 +259,7 @@ export function ImageAttachmentZone({ {/* Lightbox */} {lightboxIndex !== null && ( ({ + images={images.map((img) => ({ thumbnailSrc: img.dataUrl, alt: `Attachment from ${img.source}`, source: img.source, diff --git a/apps/extension/src/components/ImageLightbox.tsx b/apps/extension/src/components/ImageLightbox.tsx index a7c43cb..f0319ed 100644 --- a/apps/extension/src/components/ImageLightbox.tsx +++ b/apps/extension/src/components/ImageLightbox.tsx @@ -1,15 +1,15 @@ /** * ImageLightbox Component - * + * * Full-screen modal for viewing images at full size. * Supports keyboard navigation (ESC to close, arrows for multiple images). * Loads full image with thumbnail as placeholder/fallback. */ -import { useEffect, useCallback, useState } from 'react'; -import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, Loader2 } from 'lucide-react'; -import { cn } from '../lib/utils'; import type { ImageAttachment } from '@devmentorai/shared'; +import { ChevronLeft, ChevronRight, Download, Loader2, X, ZoomIn, ZoomOut } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { cn } from '../lib/utils'; interface LightboxImage { /** Thumbnail URL (shown while loading / fallback) */ @@ -38,7 +38,7 @@ export function ImageLightbox({ initialIndex = 0, onClose, showNavigation = true, -}: ImageLightboxProps) { +}: Readonly) { const [currentIndex, setCurrentIndex] = useState(initialIndex); const [isZoomed, setIsZoomed] = useState(false); const [fullImageLoaded, setFullImageLoaded] = useState>({}); @@ -48,30 +48,31 @@ export function ImageLightbox({ const hasMultiple = images.length > 1; const canGoNext = currentIndex < images.length - 1; const canGoPrev = currentIndex > 0; - + // Determine which image to display const isFullLoaded = fullImageLoaded[currentIndex]; const hasFullError = fullImageError[currentIndex]; const showFullImage = currentImage?.fullSrc && isFullLoaded && !hasFullError; - const displaySrc = showFullImage ? currentImage.fullSrc! : currentImage?.thumbnailSrc; + const displaySrc = + showFullImage && currentImage?.fullSrc ? currentImage.fullSrc : currentImage?.thumbnailSrc; const isLoadingFull = currentImage?.fullSrc && !isFullLoaded && !hasFullError; const goNext = useCallback(() => { if (canGoNext) { - setCurrentIndex(prev => prev + 1); + setCurrentIndex((prev) => prev + 1); setIsZoomed(false); } }, [canGoNext]); const goPrev = useCallback(() => { if (canGoPrev) { - setCurrentIndex(prev => prev - 1); + setCurrentIndex((prev) => prev - 1); setIsZoomed(false); } }, [canGoPrev]); const toggleZoom = useCallback(() => { - setIsZoomed(prev => !prev); + setIsZoomed((prev) => !prev); }, []); // Preload full image when lightbox opens or index changes @@ -82,11 +83,11 @@ export function ImageLightbox({ const img = new Image(); img.onload = () => { - setFullImageLoaded(prev => ({ ...prev, [currentIndex]: true })); + setFullImageLoaded((prev) => ({ ...prev, [currentIndex]: true })); }; img.onerror = () => { - console.warn(`[ImageLightbox] Failed to load full image, using thumbnail`); - setFullImageError(prev => ({ ...prev, [currentIndex]: true })); + console.warn('[ImageLightbox] Failed to load full image, using thumbnail'); + setFullImageError((prev) => ({ ...prev, [currentIndex]: true })); }; img.src = currentImage.fullSrc; }, [currentIndex, currentImage?.fullSrc, fullImageLoaded, fullImageError]); @@ -95,7 +96,7 @@ export function ImageLightbox({ // Prefer full image for download, fallback to thumbnail const downloadSrc = currentImage?.fullSrc || currentImage?.thumbnailSrc; if (!downloadSrc) return; - + const link = document.createElement('a'); link.href = downloadSrc; const ext = downloadSrc.includes('png') ? 'png' : 'jpg'; @@ -141,44 +142,51 @@ export function ImageLightbox({ if (!currentImage) return null; return ( -
{/* Backdrop */} -
{/* Header toolbar */}
{hasMultiple && ( - {currentIndex + 1} / {images.length} + + {currentIndex + 1} / {images.length} + )}
- +
- + - +
)} - - {currentImage.alt + onClick={toggleZoom} + aria-label={isZoomed ? 'Zoom out image' : 'Zoom in image'} + > + {currentImage.alt +
{/* Navigation arrows */} {showNavigation && hasMultiple && ( <> - + - - {isReplaceableAction && onReplaceText && ( -
+ )} + + {/* Images-only indicator when no text */} + {!message.content && hasImages && ( +
- - Replace - + + + {images.length} image{images.length > 1 ? 's' : ''} + +
)} - )} - {/* Timestamp */} - - {formatTime(message.timestamp)} - + {/* D.3, D.4 - Action buttons for copy/replace */} + {!isUser && message.content && ( +
+ - {/* Tool calls indicator */} - {message.metadata?.toolCalls && message.metadata.toolCalls.length > 0 && ( -
- - - {message.metadata.toolCalls.length} tool(s) executed - -
- )} + {isReplaceableAction && onReplaceText && ( + + )} +
+ )} + + {/* Timestamp */} + + {formatTime(message.timestamp)} + + + {/* Tool calls indicator */} + {message.metadata?.toolCalls && message.metadata.toolCalls.length > 0 && ( +
+ + + {message.metadata.toolCalls.length} tool(s) executed + +
+ )} + - - {/* Lightbox for viewing images */} - {lightboxIndex !== null && hasImages && ( - ({ - thumbnailSrc: getImageSrc(img), - fullSrc: img.fullImageUrl, // Will load full image, fallback to thumbnail - alt: `Image from ${img.source}`, - source: img.source, - }))} - initialIndex={lightboxIndex} - onClose={closeLightbox} - /> - )} + {/* Lightbox for viewing images */} + {lightboxIndex !== null && hasImages && ( + ({ + thumbnailSrc: getImageSrc(img), + fullSrc: img.fullImageUrl, // Will load full image, fallback to thumbnail + alt: `Image from ${img.source}`, + source: img.source, + }))} + initialIndex={lightboxIndex} + onClose={closeLightbox} + /> + )} ); } @@ -221,34 +236,38 @@ function formatAction(action: string): string { function formatContent(content: string): React.ReactNode { // Basic code block detection const parts = content.split(/(```[\s\S]*?```)/g); - - return parts.map((part, index) => { + + return parts.map((part) => { if (part.startsWith('```') && part.endsWith('```')) { const code = part.slice(3, -3); const firstNewline = code.indexOf('\n'); const language = firstNewline > 0 ? code.slice(0, firstNewline) : ''; const codeContent = firstNewline > 0 ? code.slice(firstNewline + 1) : code; - + return (
-          {language && (
-            
{language}
- )} + {language &&
{language}
} {codeContent}
); } - + // Handle inline code const inlineParts = part.split(/(`[^`]+`)/g); - return inlineParts.map((inlinePart, inlineIndex) => { + const inlineOccurrences = new Map(); + + return inlineParts.map((inlinePart) => { if (inlinePart.startsWith('`') && inlinePart.endsWith('`')) { + const keyBase = `${part}-${inlinePart}`; + const occurrence = inlineOccurrences.get(keyBase) ?? 0; + inlineOccurrences.set(keyBase, occurrence + 1); + return ( {inlinePart.slice(1, -1)} diff --git a/apps/extension/src/components/ModelSwitchModal.tsx b/apps/extension/src/components/ModelSwitchModal.tsx new file mode 100644 index 0000000..a140006 --- /dev/null +++ b/apps/extension/src/components/ModelSwitchModal.tsx @@ -0,0 +1,203 @@ +import type { ModelInfo, ReasoningEffort, Session } from '@devmentorai/shared'; +import { Brain, Cpu, X } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { ApiClient } from '../services/api-client'; +import { ReasoningEffortSelector } from './ReasoningEffortSelector'; + +interface ModelSwitchModalProps { + session: Session; + onClose: () => void; + onModelSwitched: (updatedSession: Session) => void; +} + +export function ModelSwitchModal({ + session, + onClose, + onModelSwitched, +}: Readonly) { + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(session.model); + const [reasoningEffort, setReasoningEffort] = useState( + session.reasoningEffort || 'medium' + ); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const apiClient = new ApiClient(); + + useEffect(() => { + fetchModels(); + }, []); + + const fetchModels = async () => { + try { + const response = await apiClient.getModels(); + if (response.success && response.data) { + setModels(response.data.models); + } + } catch (err) { + console.error('Failed to fetch models:', err); + } + }; + + const selectedModelInfo = models.find((m) => m.id === selectedModel); + const supportsReasoning = + selectedModelInfo?.supportedReasoningEfforts && + selectedModelInfo.supportedReasoningEfforts.length > 0; + const supportedReasoningEfforts = (selectedModelInfo?.supportedReasoningEfforts || + []) as ReasoningEffort[]; + const currentReasoningEffort = session.reasoningEffort ?? null; + const selectedReasoningEffort = supportsReasoning ? reasoningEffort : null; + const isUnchanged = + selectedModel === session.model && selectedReasoningEffort === currentReasoningEffort; + + useEffect(() => { + if (!selectedModelInfo?.supportedReasoningEfforts?.length) { + return; + } + + if (!selectedModelInfo.supportedReasoningEfforts.includes(reasoningEffort)) { + setReasoningEffort(selectedModelInfo.supportedReasoningEfforts[0] as ReasoningEffort); + } + }, [selectedModelInfo, reasoningEffort]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + setError(null); + + try { + const response = await apiClient.switchSessionModel( + session.id, + selectedModel, + supportsReasoning ? reasoningEffort : undefined + ); + + if (response.success && response.data) { + onModelSwitched(response.data); + onClose(); + } else { + setError(response.error?.message || 'Failed to switch model'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to switch model'); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+
+

+ + Switch Model +

+ +
+ +

+ Current model: {session.model} + {session.reasoningEffort && ( + + Reasoning: {session.reasoningEffort} + + )} +

+ +
+
+

+ Select Model +

+
+ {models.map((model) => ( + + ))} +
+
+ + {supportsReasoning && ( + + )} + + {error && ( +
+ {error} +
+ )} + +
+ + +
+ +
+
+ ); +} diff --git a/apps/extension/src/components/NewSessionModal.tsx b/apps/extension/src/components/NewSessionModal.tsx index 7915d0e..dae9138 100644 --- a/apps/extension/src/components/NewSessionModal.tsx +++ b/apps/extension/src/components/NewSessionModal.tsx @@ -1,31 +1,51 @@ -import { useState, useEffect } from 'react'; -import { X, ChevronDown } from 'lucide-react'; +import type { ModelInfo, ReasoningEffort, SessionType } from '@devmentorai/shared'; +import { DEFAULT_CONFIG, SESSION_TYPE_CONFIGS } from '@devmentorai/shared'; +import { ChevronDown, X } from 'lucide-react'; +import { type FormEvent, useEffect, useRef, useState } from 'react'; import { cn } from '../lib/utils'; -import type { SessionType, ModelInfo } from '@devmentorai/shared'; -import { SESSION_TYPE_CONFIGS, DEFAULT_CONFIG } from '@devmentorai/shared'; import { ApiClient } from '../services/api-client'; +import { ReasoningEffortSelector } from './ReasoningEffortSelector'; // D.5 - Pricing tier display const PRICING_BADGES: Record = { - free: { label: 'Free', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' }, - cheap: { label: 'Cheap', color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' }, - standard: { label: 'Standard', color: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300' }, - premium: { label: 'Premium', color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' }, + free: { + label: 'Free', + color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + }, + cheap: { + label: 'Cheap', + color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + }, + standard: { + label: 'Standard', + color: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', + }, + premium: { + label: 'Premium', + color: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', + }, }; interface NewSessionModalProps { onClose: () => void; - onSubmit: (name: string, type: SessionType, model?: string) => Promise | void; + onSubmit: ( + name: string, + type: SessionType, + model?: string, + reasoningEffort?: ReasoningEffort + ) => Promise | void; } export function NewSessionModal({ onClose, onSubmit }: Readonly) { const [name, setName] = useState(''); const [type, setType] = useState('devops'); const [model, setModel] = useState('gpt-4.1'); + const [reasoningEffort, setReasoningEffort] = useState('medium'); const [models, setModels] = useState([]); const [showModelPicker, setShowModelPicker] = useState(false); const [modelSearch, setModelSearch] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const nameInputRef = useRef(null); // Fetch available models useEffect(() => { @@ -44,20 +64,46 @@ export function NewSessionModal({ onClose, onSubmit }: Readonly { + const selectedModel = models.find((m) => m.id === model); + + useEffect(() => { + if (!selectedModel?.supportedReasoningEfforts?.length) { + return; + } + + if (!selectedModel.supportedReasoningEfforts.includes(reasoningEffort)) { + setReasoningEffort(selectedModel.supportedReasoningEfforts[0] as ReasoningEffort); + } + }, [selectedModel, reasoningEffort]); + + useEffect(() => { + nameInputRef.current?.focus(); + }, []); + + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); if (!name.trim() || isSubmitting) return; setIsSubmitting(true); try { - await onSubmit(name.trim(), type, model); + const selectedModelInfo = models.find((m) => m.id === model); + const supportsReasoning = + selectedModelInfo?.supportedReasoningEfforts && + selectedModelInfo.supportedReasoningEfforts.length > 0; + await onSubmit(name.trim(), type, model, supportsReasoning ? reasoningEffort : undefined); } finally { setIsSubmitting(false); } }; - const sessionTypes = Object.entries(SESSION_TYPE_CONFIGS) as [SessionType, typeof SESSION_TYPE_CONFIGS[SessionType]][]; - const selectedModel = models.find(m => m.id === model); + const sessionTypes = Object.entries(SESSION_TYPE_CONFIGS) as [ + SessionType, + (typeof SESSION_TYPE_CONFIGS)[SessionType], + ][]; + const supportsReasoning = + selectedModel?.supportedReasoningEfforts && selectedModel.supportedReasoningEfforts.length > 0; + const supportedReasoningEfforts = (selectedModel?.supportedReasoningEfforts || + []) as ReasoningEffort[]; const normalizedQuery = modelSearch.trim().toLowerCase(); const filteredModels = normalizedQuery ? models.filter((modelItem) => { @@ -87,10 +133,9 @@ export function NewSessionModal({ onClose, onSubmit }: Readonly {/* Header */}
-

- New Session -

+

New Session

@@ -139,12 +184,14 @@ export function NewSessionModal({ onClose, onSubmit }: Readonly {config.icon}
-

+

{config.name}

@@ -174,10 +221,12 @@ export function NewSessionModal({ onClose, onSubmit }: Readonly )} - + {showModelPicker && ( @@ -193,21 +242,27 @@ export function NewSessionModal({ onClose, onSubmit }: Readonly {filteredModels.length === 0 && ( -

No models found

+

+ No models found +

)} {/* D.5 - Group models by pricing tier */} - {['free', 'cheap', 'standard', 'premium'].map(tier => { - const tierModels = filteredModels.filter(m => m.pricingTier === tier || (!m.pricingTier && tier === 'standard')); + {['free', 'cheap', 'standard', 'premium'].map((tier) => { + const tierModels = filteredModels.filter( + (m) => m.pricingTier === tier || (!m.pricingTier && tier === 'standard') + ); if (tierModels.length === 0) return null; - + return (
- + {PRICING_BADGES[tier]?.label || 'Standard'}
@@ -252,6 +307,15 @@ export function NewSessionModal({ onClose, onSubmit }: Readonly
+ {/* Reasoning Effort - only for supported models */} + {supportsReasoning && ( + + )} + {/* Description */}

{SESSION_TYPE_CONFIGS[type].description} @@ -259,11 +323,7 @@ export function NewSessionModal({ onClose, onSubmit }: Readonly - + + + + + + + + {/* Selected Text */} + {pageContext.selectedText && ( +

+

+ Selected Text ({pageContext.selectedText.length} chars) +

+
+

+ {pageContext.selectedText} +

+
+
+ )} + + ); + } else { + content = ( +

+ Unable to get page information +

+ ); + } + return (
{/* Backdrop */} -
{/* Modal */} @@ -82,11 +165,10 @@ export function PageContextModal({ onClose, onUseInChat }: PageContextModalProps
-

- Current Page -

+

Current Page

{/* Content */} -
- {isLoading ? ( -
-
-
- ) : pageContext ? ( - <> - {/* Title */} -
- -

- {pageContext.title || '(No title)'} -

-
- - {/* URL */} -
- -
-

- {pageContext.url} -

-
- - - - -
-
-
- - {/* Selected Text */} - {pageContext.selectedText && ( -
- -
-

- {pageContext.selectedText} -

-
-
- )} - - ) : ( -

- Unable to get page information -

- )} -
+
{content}
{/* Actions */}
+ ))} +
+

{helperText}

+
+ ); +} diff --git a/apps/extension/src/components/SessionSelector.tsx b/apps/extension/src/components/SessionSelector.tsx index ff01542..efa6347 100644 --- a/apps/extension/src/components/SessionSelector.tsx +++ b/apps/extension/src/components/SessionSelector.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react'; -import { ChevronDown, Trash2, MessageSquare, Sparkles } from 'lucide-react'; -import { cn } from '../lib/utils'; import type { Session } from '@devmentorai/shared'; import { SESSION_TYPE_CONFIGS } from '@devmentorai/shared'; +import { ChevronDown, MessageSquare, Sparkles, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { cn } from '../lib/utils'; import { isWritingAssistantSession } from '../services/writing-assistant-session'; interface SessionSelectorProps { @@ -17,10 +17,10 @@ export function SessionSelector({ activeSessionId, onSelectSession, onDeleteSession, -}: SessionSelectorProps) { +}: Readonly) { const [isOpen, setIsOpen] = useState(false); - const activeSession = sessions.find(s => s.id === activeSessionId); + const activeSession = sessions.find((s) => s.id === activeSessionId); const activeConfig = activeSession ? SESSION_TYPE_CONFIGS[activeSession.type] : null; const isActiveWritingAssistant = activeSession ? isWritingAssistantSession(activeSession) : false; @@ -37,6 +37,7 @@ export function SessionSelector({ return (
- + {isOpen && ( <> -
setIsOpen(false)} + aria-label="Close session selector" />
- {sessions.map(session => { + {sessions.map((session) => { const config = SESSION_TYPE_CONFIGS[session.type]; const isActive = session.id === activeSessionId; const isWritingAssistant = isWritingAssistantSession(session); @@ -81,6 +83,7 @@ export function SessionSelector({ )} > - +
-
@@ -252,7 +283,7 @@ export function OptionsPage() { {/* Behavior */}

Behavior

- +
{/*