/** * Design System Server (DSS) - AI Integration Layer * * Provides: * - Text prompt interface * - Voice input/output * - Streaming responses * - Context management */ // === MCP Tools Integration === // These commands use MCP tools instead of REST endpoints // All work is done by the MCP server, not the frontend class MCPTools { // Project management tools (via MCP server) static async createProject(name, rootPath, description) { return { tool: 'dss_create_project', args: { name, root_path: rootPath, description } }; } // Figma credential tools (via MCP server) static async setupFigmaCredentials(apiToken) { return { tool: 'dss_setup_figma_credentials', args: { api_token: apiToken } }; } // Project manifest tools (via MCP server) static async getProjectManifest(projectId) { return { tool: 'dss_get_project_manifest', args: { project_id: projectId } }; } static async addFigmaFile(projectId, fileKey, fileName) { return { tool: 'dss_add_figma_file', args: { project_id: projectId, file_key: fileKey, file_name: fileName } }; } static async discoverFigmaFiles(projectId) { return { tool: 'dss_discover_figma_files', args: { project_id: projectId } }; } static async listProjectFigmaFiles(projectId) { return { tool: 'dss_list_project_figma_files', args: { project_id: projectId } }; } } class AIAssistant { constructor(options = {}) { this.apiEndpoint = options.apiEndpoint || '/api/ai/chat'; this.voiceEnabled = 'webkitSpeechRecognition' in window || 'SpeechRecognition' in window; this.speechEnabled = 'speechSynthesis' in window; this.context = []; this.maxContextLength = options.maxContextLength || 10; this.recognition = null; this.isListening = false; this.onTranscript = options.onTranscript || (() => {}); this.onResponse = options.onResponse || (() => {}); this.onError = options.onError || console.error; this.onStateChange = options.onStateChange || (() => {}); this.onPromptUser = options.onPromptUser || (() => {}); this.pendingCommand = null; this.commandData = {}; this.initVoice(); this.initCommands(); } // === Command System === initCommands() { this.commands = { 'init': { description: 'Create a new DSS project', fields: [ { key: 'name', prompt: 'Project name:', required: true }, { key: 'root_path', prompt: 'Project root path:', default: '.', required: true }, { key: 'description', prompt: 'Project description (optional):', required: false } ], execute: async (data) => this.executeProjectInit(data) }, 'setup-figma': { description: 'Configure Figma API credentials (user-level, one-time setup)', fields: [ { key: 'api_token', prompt: 'Figma Personal Access Token:', required: true } ], execute: async (data) => this.executeFigmaSetup(data) }, 'discover-figma': { description: 'Discover and link Figma files to current project', fields: [], execute: async (data) => this.executeFigmaDiscovery() }, 'add-figma': { description: 'Add a Figma file reference to project manifest', fields: [ { key: 'file_key', prompt: 'Figma file key or URL:', required: true }, { key: 'file_name', prompt: 'Display name (optional):', required: false } ], execute: async (data) => this.executeAddFigma(data) }, 'sync': { description: 'Sync tokens from Figma', fields: [ { key: 'output_path', prompt: 'Output path:', default: './tokens.css', required: false } ], execute: async (data) => this.executeSyncTokens(data) }, 'analyze': { description: 'Analyze project for components', fields: [ { key: 'path', prompt: 'Path to analyze:', default: './src', required: false } ], execute: async (data) => this.executeAnalyze(data) }, 'projects': { description: 'List all projects', fields: [], execute: async () => this.executeListProjects() }, 'select': { description: 'Select a project', fields: [ { key: 'project_id', prompt: 'Project ID:', required: true } ], execute: async (data) => this.executeSelectProject(data) }, 'figma-files': { description: 'List Figma files in project', fields: [], execute: async () => this.executeListFigmaFiles() }, 'quick-wins': { description: 'Find quick improvements', fields: [ { key: 'path', prompt: 'Path to scan:', default: '.', required: false } ], execute: async (data) => this.executeQuickWins(data) }, 'discover': { description: 'Run project discovery', fields: [ { key: 'path', prompt: 'Path to discover:', default: '.', required: false } ], execute: async (data) => this.executeDiscover(data) }, 'status': { description: 'Show current project status', fields: [], execute: async () => this.executeStatus() } }; } parseCommand(message) { const trimmed = message.trim().toLowerCase(); // Check for slash command patterns if (trimmed.startsWith('/')) { const parts = trimmed.slice(1).split(' '); const cmd = parts[0]; // Handle /select with inline arg if (cmd === 'select' && parts[1]) { return { command: 'select', args: parts.slice(1), inlineData: { project_id: parts[1] } }; } return { command: cmd, args: parts.slice(1) }; } // Natural language patterns const patterns = [ { regex: /^(init|initialize|create|start|setup)\s*(project|new)?/i, command: 'init' }, { regex: /^add\s*figma/i, command: 'add-figma' }, { regex: /^sync\s*(tokens?)?/i, command: 'sync' }, { regex: /^analyze/i, command: 'analyze' }, { regex: /^(list\s*)?projects?$/i, command: 'projects' }, { regex: /^(show\s*)?(figma\s*)?files?$/i, command: 'figma-files' }, { regex: /^quick\s*wins?/i, command: 'quick-wins' }, { regex: /^discover/i, command: 'discover' }, { regex: /^status/i, command: 'status' }, { regex: /^select\s+(\S+)/i, command: 'select', extractId: true } ]; for (const pattern of patterns) { const match = trimmed.match(pattern.regex); if (match) { if (pattern.extractId && match[1]) { return { command: pattern.command, args: [], inlineData: { project_id: match[1] } }; } return { command: pattern.command, args: [] }; } } return null; } async startCommand(commandName, inlineData = {}) { const cmd = this.commands[commandName]; if (!cmd) { return { handled: false }; } // If all required fields provided, execute immediately const missingRequired = cmd.fields.filter(f => f.required && !inlineData[f.key]); if (missingRequired.length === 0 && cmd.fields.length > 0) { return this.executeCommand(commandName, inlineData); } // Generate smart form prompt for chat interface return this.generateFormPrompt(commandName, inlineData); } generateFormPrompt(commandName, providedData = {}) { const cmd = this.commands[commandName]; const requiredFields = cmd.fields.filter(f => f.required && !providedData[f.key]); const optionalFields = cmd.fields.filter(f => !f.required && !providedData[f.key]); // Build structured prompt let prompt = `\nšŸ”§ **${this.formatCommandName(commandName)}**\n\n`; prompt += `Please provide the following details:\n\n`; // Required fields if (requiredFields.length > 0) { prompt += `**Required fields:**\n`; requiredFields.forEach((field, i) => { prompt += `${i + 1}. \`${field.key}\` - ${field.prompt}\n`; }); prompt += '\n'; } // Optional fields if (optionalFields.length > 0) { prompt += `**Optional fields** (leave blank to skip):\n`; optionalFields.forEach((field) => { const defaultHint = field.default ? ` [default: ${field.default}]` : ''; prompt += `- \`${field.key}\` - ${field.prompt}${defaultHint}\n`; }); prompt += '\n'; } // Show expected format prompt += `**Respond in one of these formats:**\n\n`; prompt += '```yaml\n'; cmd.fields.forEach(f => { if (providedData[f.key]) { prompt += `${f.key}: ${providedData[f.key]}\n`; } else { prompt += `${f.key}: value\n`; } }); prompt += '```\n\n'; prompt += 'Or provide naturally: "Create test project in ./src with Figma key xyz123"'; this.pendingCommand = commandName; this.commandData = { ...providedData }; return { handled: true, waiting: true, prompt: prompt, expectsInput: true, command: commandName }; } formatCommandName(cmd) { return this.commands[cmd]?.description || cmd; } async handleCommandInput(input) { if (!this.pendingCommand) { return { handled: false }; } const cmd = this.commands[this.pendingCommand]; const commandName = this.pendingCommand; // Use AI to understand intent and extract values from context const parsed = await this.parseInputWithAI(input, cmd); if (!parsed.success) { return { handled: true, error: parsed.error, waiting: true }; } // Merge with any previously provided data const fullData = { ...this.commandData, ...parsed.data }; // Validate required fields const missingRequired = cmd.fields .filter(f => f.required) .filter(f => !fullData[f.key]) .map(f => f.key); if (missingRequired.length > 0) { return { handled: true, error: `Missing required fields: ${missingRequired.join(', ')}. Please provide: ${missingRequired.join(', ')}`, waiting: true }; } // Apply defaults cmd.fields.forEach(field => { if (field.default && !fullData[field.key]) { fullData[field.key] = field.default; } }); // Show confirmation return this.showConfirmation(commandName, fullData, cmd); } async parseInputWithAI(input, cmd) { const trimmed = input.trim(); // Build field descriptions for the AI const fieldDescriptions = cmd.fields .map(f => `- ${f.key}: ${f.prompt}${f.default ? ` (default: ${f.default})` : ''}${!f.required ? ' (optional)' : ' (required)'}`) .join('\n'); const prompt = `You are a parsing assistant. Extract structured data from user input. Command fields to extract: ${fieldDescriptions} User said: "${trimmed}" Extract the values the user provided. Be intelligent: - "create in ./src" means root_path is "./src" - "test project" means name is "test" - "my app with Figma xyz123" means name is "my app" and figma_file_key is "xyz123" Return ONLY valid JSON, nothing else. Use null for fields not mentioned: {"name": "value", "root_path": "value", ...}`; try { // Use Claude to intelligently parse the input with context understanding const response = await fetch('/api/claude/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: prompt, system: 'You are a JSON extraction tool. Return only valid JSON, no markdown blocks, no explanations.' }) }); if (response.ok) { const result = await response.json(); // Extract JSON from response const text = result.response || result.message || ''; const jsonMatch = text.match(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/); if (jsonMatch) { try { const parsed = JSON.parse(jsonMatch[0]); // Filter out null values and empty strings const data = {}; Object.entries(parsed).forEach(([key, value]) => { if (value !== null && value !== '' && value !== undefined) { data[key] = value; } }); return { success: true, data }; } catch (e) { return this.parseSimple(trimmed, cmd); } } } } catch (error) { // Fallback to simple parsing return this.parseSimple(trimmed, cmd); } return this.parseSimple(trimmed, cmd); } parseSimple(text, cmd) { const data = {}; // Try YAML format const yamlMatch = text.match(/([a-z_]+):\s*(.+?)(?=\n[a-z_]+:|$)/gs); if (yamlMatch) { yamlMatch.forEach(match => { const [key, value] = match.split(':').map(s => s.trim()); if (key && value) { data[key] = value; } }); if (Object.keys(data).length > 0) return { success: true, data }; } // Try key=value format const kvMatch = text.match(/(\w+)=([^\s,]+)/g); if (kvMatch) { kvMatch.forEach(match => { const [key, value] = match.split('='); if (key && value) { data[key] = value; } }); if (Object.keys(data).length > 0) return { success: true, data }; } // Simple heuristic for first required field if (cmd.fields.length > 0 && cmd.fields[0]) { return { success: true, data: { [cmd.fields[0].key]: text } }; } return { success: false, error: 'Could not parse input. Try being more explicit.' }; } showConfirmation(commandName, data, cmd) { let confirmation = `\nāœ… **Ready to execute: ${this.formatCommandName(commandName)}**\n\n`; cmd.fields.forEach(field => { const value = data[field.key]; if (value) { confirmation += `• **${field.key}**: \`${value}\`\n`; } }); confirmation += `\nType **\`confirm\`** to proceed or **\`cancel\`** to abort.`; this.pendingConfirmation = { commandName, data }; return { handled: true, waiting: true, confirmation: confirmation, prompt: confirmation }; } async confirmCommand(input) { const trimmed = input.trim().toLowerCase(); if (trimmed === 'confirm' || trimmed === 'yes' || trimmed === 'y') { const { commandName, data } = this.pendingConfirmation; this.pendingConfirmation = null; this.pendingCommand = null; return this.executeCommand(commandName, data); } if (trimmed === 'cancel' || trimmed === 'no' || trimmed === 'n') { this.pendingConfirmation = null; this.pendingCommand = null; return { handled: true, cancelled: true, message: 'Command cancelled.' }; } return { handled: true, error: 'Please type "confirm" or "cancel"', waiting: true }; } async executeCommand(commandName, data) { const cmd = this.commands[commandName]; if (!cmd) return { handled: false }; try { const result = await cmd.execute(data); this.pendingCommand = null; this.pendingConfirmation = null; return { handled: true, success: true, result, command: commandName }; } catch (error) { return { handled: true, success: false, error: error.message, command: commandName }; } } async executeCurrentCommand() { const cmd = this.commands[this.pendingCommand]; const commandName = this.pendingCommand; // Clear pending state this.pendingCommand = null; this.currentFieldIndex = 0; try { const result = await cmd.execute(this.commandData); this.commandData = {}; return { handled: true, success: true, result, command: commandName }; } catch (error) { this.commandData = {}; return { handled: true, success: false, error: error.message, command: commandName }; } } cancelCommand() { this.pendingCommand = null; this.commandData = {}; this.currentFieldIndex = 0; } // === Command Executors === async executeProjectInit(data) { // Use MCP tool to create project const projectId = `project_${Date.now()}`; localStorage.setItem('dss_current_project', projectId); // Return instruction for Claude to call the MCP tool return { action: 'call_mcp_tool', tool: 'dss_create_project', args: { name: data.name, root_path: data.root_path, description: data.description || '' }, message: `šŸš€ Creating project "${data.name}"...\n\nAfter creation:\n- Run \`/setup-figma\` to configure Figma API credentials (one-time setup)\n- Run \`/discover-figma\` to link Figma design files\n- Run \`/analyze\` to scan for components` }; } async executeFigmaSetup(data) { if (!data.api_token || data.api_token.trim() === '') { throw new Error('Figma API token is required'); } // Return instruction for Claude to call the MCP tool return { action: 'call_mcp_tool', tool: 'dss_setup_figma_credentials', args: { api_token: data.api_token }, message: `šŸ” Configuring Figma API credentials...\n\nYour token will be stored securely at the user level and persist across all projects.` }; } async executeFigmaDiscovery() { const projectId = localStorage.getItem('dss_current_project'); if (!projectId) { throw new Error('No project selected. Run /init first to create a project.'); } // Return instruction for Claude to call the MCP tool return { action: 'call_mcp_tool', tool: 'dss_discover_figma_files', args: { project_id: projectId }, message: `šŸ” Discovering Figma design files for project "${projectId}"...\n\nI'll scan your available Figma workspaces and suggest files to link.` }; } async executeAddFigma(data) { const projectId = localStorage.getItem('dss_current_project'); if (!projectId) { throw new Error('No project selected. Run /init first or select a project.'); } if (!data.file_key) { throw new Error('Figma file key is required'); } // Extract file key from URL if needed const fileKey = data.file_key.includes('figma.com') ? data.file_key.match(/file\/([a-zA-Z0-9]+)/)?.[1] || data.file_key : data.file_key; // Return instruction for Claude to call the MCP tool return { action: 'call_mcp_tool', tool: 'dss_add_figma_file', args: { project_id: projectId, file_key: fileKey, file_name: data.file_name }, message: `šŸ“Ž Linking Figma file to project...\n\n**File Key:** \`${fileKey}\`\n**Name:** ${data.file_name || 'Auto-detected'}` }; } async executeSyncTokens(data) { const projectId = localStorage.getItem('dss_current_project'); if (!projectId) { throw new Error('No project selected. Run /init first or select a project.'); } const response = await fetch('/api/figma/sync-tokens', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: projectId, output_path: data.output_path || './tokens.css' }) }); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.detail || 'Failed to sync tokens'); } const result = await response.json(); return `Tokens synced successfully!\n\nTokens extracted: ${result.tokens_synced || 'N/A'}\nOutput: ${data.output_path || './tokens.css'}`; } async executeAnalyze(data) { const projectId = localStorage.getItem('dss_current_project'); const path = data.path || './src'; const response = await fetch('/api/mcp/discover_project', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: path, project_id: projectId }) }); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.detail || 'Failed to analyze project'); } const result = await response.json(); return `Analysis complete!\n\nPath: ${path}\nComponents found: ${result.components?.length || 0}\nToken files: ${result.token_files?.length || 0}`; } async executeListProjects() { const response = await fetch('/api/projects'); if (!response.ok) { throw new Error('Failed to fetch projects'); } const projects = await response.json(); if (!projects.length) { return 'No projects found. Run `/init` to create one.'; } const currentId = localStorage.getItem('dss_current_project'); const list = projects.map(p => { const current = p.id === currentId ? ' ← current' : ''; return `• **${p.name}** (ID: ${p.id})${current}\n Root: ${p.root_path || 'N/A'}`; }).join('\n\n'); return `**Projects**\n\n${list}\n\nUse \`/select \` to switch projects.`; } async executeSelectProject(data) { const response = await fetch(`/api/projects/${data.project_id}`); if (!response.ok) { throw new Error(`Project not found: ${data.project_id}`); } const project = await response.json(); localStorage.setItem('dss_current_project', project.id); window.dispatchEvent(new CustomEvent('project-selected', { detail: project })); return `Selected project: **${project.name}**\n\nRoot: ${project.root_path || '.'}\nFigma files: ${project.figma_files?.length || 0}`; } async executeListFigmaFiles() { const projectId = localStorage.getItem('dss_current_project'); if (!projectId) { throw new Error('No project selected. Run /init or /select first.'); } const response = await fetch(`/api/projects/${projectId}/figma-files`); if (!response.ok) { throw new Error('Failed to fetch Figma files'); } const files = await response.json(); if (!files.length) { return 'No Figma files linked. Use `/add-figma` to add one.'; } const list = files.map(f => `• **${f.file_name || f.file_key}**\n Key: \`${f.file_key}\`\n Last sync: ${f.last_synced || 'Never'}`).join('\n\n'); return `**Figma Files**\n\n${list}`; } async executeQuickWins(data) { const projectId = localStorage.getItem('dss_current_project'); const path = data.path || '.'; const response = await fetch('/api/mcp/get_quick_wins', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, project_id: projectId }) }); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.detail || 'Failed to find quick wins'); } const result = await response.json(); const wins = result.quick_wins || result.wins || []; if (!wins.length) { return 'No quick wins found. Your codebase looks good!'; } const list = wins.slice(0, 10).map((w, i) => `${i + 1}. **${w.title || w.type}**\n ${w.description || w.message}\n File: \`${w.file || w.location || 'N/A'}\`` ).join('\n\n'); return `**Quick Wins** (${wins.length} found)\n\n${list}${wins.length > 10 ? `\n\n...and ${wins.length - 10} more` : ''}`; } async executeDiscover(data) { const path = data.path || '.'; const response = await fetch('/api/discovery/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, full_scan: false }) }); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.detail || 'Discovery failed'); } const result = await response.json(); return `**Discovery Complete** Path: ${path} Framework: ${result.framework || 'Unknown'} Components: ${result.components?.length || 0} Token files: ${result.token_files?.length || 0} Storybook: ${result.has_storybook ? 'Yes' : 'No'}`; } async executeStatus() { const projectId = localStorage.getItem('dss_current_project'); if (!projectId) { return `**Status** No project selected. Get started: 1. \`/init\` - Create a new project 2. \`/projects\` - List existing projects 3. \`/select \` - Select a project`; } const response = await fetch(`/api/projects/${projectId}`); if (!response.ok) { localStorage.removeItem('dss_current_project'); return 'Selected project no longer exists. Run `/projects` to see available projects.'; } const project = await response.json(); const figmaCount = project.figma_files?.length || 0; return `**Current Project Status** **${project.name}** (${project.id}) Root: ${project.root_path || '.'} Description: ${project.description || 'N/A'} Figma files: ${figmaCount} Next steps: ${figmaCount === 0 ? '• `/add-figma` - Link a Figma file\n' : '• `/sync` - Sync tokens from Figma\n'}• \`/analyze\` - Analyze codebase • \`/quick-wins\` - Find improvements`; } // === Voice Input === initVoice() { if (!this.voiceEnabled) return; const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; this.recognition = new SpeechRecognition(); this.recognition.continuous = false; this.recognition.interimResults = true; this.recognition.lang = 'en-US'; this.recognition.onresult = (event) => { const transcript = Array.from(event.results) .map(result => result[0].transcript) .join(''); const isFinal = event.results[event.results.length - 1].isFinal; this.onTranscript(transcript, isFinal); }; this.recognition.onerror = (event) => { this.onError(`Voice recognition error: ${event.error}`); this.isListening = false; this.onStateChange({ listening: false, error: event.error }); }; this.recognition.onend = () => { this.isListening = false; this.onStateChange({ listening: false }); }; } startListening() { if (!this.voiceEnabled) { this.onError('Voice recognition not supported in this browser'); return false; } try { this.recognition.start(); this.isListening = true; this.onStateChange({ listening: true }); return true; } catch (error) { this.onError(`Failed to start listening: ${error.message}`); return false; } } stopListening() { if (this.recognition && this.isListening) { this.recognition.stop(); this.isListening = false; this.onStateChange({ listening: false }); } } // === Voice Output === speak(text, options = {}) { if (!this.speechEnabled) { this.onError('Speech synthesis not supported'); return; } // Cancel any ongoing speech speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utterance.rate = options.rate || 1; utterance.pitch = options.pitch || 1; utterance.volume = options.volume || 1; // Use a natural voice if available const voices = speechSynthesis.getVoices(); const preferredVoice = voices.find(v => v.name.includes('Google') || v.name.includes('Natural') || v.name.includes('Samantha') ); if (preferredVoice) { utterance.voice = preferredVoice; } utterance.onstart = () => this.onStateChange({ speaking: true }); utterance.onend = () => this.onStateChange({ speaking: false }); utterance.onerror = (e) => { this.onError(`Speech error: ${e.error}`); this.onStateChange({ speaking: false }); }; speechSynthesis.speak(utterance); } stopSpeaking() { if (this.speechEnabled) { speechSynthesis.cancel(); this.onStateChange({ speaking: false }); } } // === Text Chat === async sendMessage(message, options = {}) { // Add to context this.context.push({ role: 'user', content: message }); if (this.context.length > this.maxContextLength * 2) { this.context = this.context.slice(-this.maxContextLength * 2); } try { this.onStateChange({ thinking: true }); // Call real Claude API const response = await this.callClaudeAPI(message, options); // Add response to context this.context.push({ role: 'assistant', content: response }); this.onResponse(response); this.onStateChange({ thinking: false }); // Optionally speak the response if (options.speakResponse) { this.speak(response); } return response; } catch (error) { this.onError(`AI request failed: ${error.message}`); this.onStateChange({ thinking: false, error: error.message }); throw error; } } // Call real Claude API endpoint with MCP tool integration async callClaudeAPI(message, options = {}) { try { // Get current project from localStorage or options const currentProject = options.projectId || localStorage.getItem('dss_current_project'); const userId = options.userId || 1; const response = await fetch('/api/claude/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ message: message, context: options.context || {}, history: this.context.slice(0, -1), // All context except the current message project_id: currentProject, user_id: userId, enable_tools: options.enableTools !== false // Default to true }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.response || 'Failed to get response from Claude'); } const data = await response.json(); if (!data.success) { throw new Error(data.response || 'Claude API returned an error'); } // Store tool usage info for display this.lastToolsUsed = data.tools_used || []; return data.response; } catch (error) { // If API fails, provide helpful error message throw new Error(`Claude API error: ${error.message}. Make sure ANTHROPIC_API_KEY is configured in your .env file.`); } } // Get last tools used by Claude getLastToolsUsed() { return this.lastToolsUsed || []; } // Set current project context setProject(projectId) { localStorage.setItem('dss_current_project', projectId); } // Get current project getProject() { return localStorage.getItem('dss_current_project'); } // === Context Management === setSystemPrompt(prompt) { this.systemPrompt = prompt; } clearContext() { this.context = []; } getContext() { return [...this.context]; } // === Design System Specific Commands === async extractTokens(fileKey) { return this.sendMessage(`Extract design tokens from Figma file ${fileKey}`); } async syncComponents(fileKey) { return this.sendMessage(`Sync components from Figma file ${fileKey}`); } async runVisualDiff(baseline, current) { return this.sendMessage(`Run visual diff between ${baseline} and ${current}`); } } // === AI Chat Web Component === class DsAiChat extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.ai = null; this.messages = []; } connectedCallback() { this.projects = []; this.render(); this.setupAI(); this.setupEventListeners(); this.restoreTeamContext(); this.loadProjects(); // Subscribe to global project changes (component awareness) if (window.app?.store) { // Read current state immediately (prevent race condition) const currentProject = window.app.store.get('currentProject'); if (currentProject) { const projects = window.app.store.get('projects'); const project = projects.find(p => p.id === currentProject); if (project) { this.updateProjectContext(project); } } // Subscribe to future changes this.unsubscribeProject = window.app.store.subscribe('currentProject', (projectId) => { const projects = window.app.store.get('projects'); const project = projects.find(p => p.id === projectId); if (project) { this.updateProjectContext(project); } }); } } disconnectedCallback() { // Unsubscribe from project changes (prevent memory leaks) if (this.unsubscribeProject) { this.unsubscribeProject(); this.unsubscribeProject = null; } } restoreTeamContext() { // Restore last selected team from localStorage const savedTeam = localStorage.getItem('dss_team_context') || 'all'; const teamSelect = this.shadowRoot.querySelector('.team-select'); if (teamSelect) { teamSelect.value = savedTeam; this.updateChatContext(savedTeam); } } setupAI() { this.ai = new AIAssistant({ onTranscript: (text, isFinal) => { this.updateInput(text); if (isFinal) { this.handleSend(); } }, onResponse: (response) => { this.addMessage('assistant', response); }, onError: (error) => { this.addMessage('error', error); }, onStateChange: (state) => { this.updateState(state); }, onPromptUser: (promptInfo) => { this.addMessage('prompt', promptInfo.prompt); } }); } setupEventListeners() { const input = this.shadowRoot.querySelector('.chat-input'); const sendBtn = this.shadowRoot.querySelector('.send-btn'); const voiceBtn = this.shadowRoot.querySelector('.voice-btn'); input.addEventListener('keypress', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.handleSend(); } }); sendBtn.addEventListener('click', () => this.handleSend()); voiceBtn?.addEventListener('click', () => { if (this.ai.isListening) { this.ai.stopListening(); } else { this.ai.startListening(); } }); // Listen for team context change from header window.addEventListener('team-context-changed', (e) => { this.updateChatContext(e.detail.team); }); } handleTeamChange(teamContext) { // Store team context localStorage.setItem('dss_team_context', teamContext); // Dispatch event for dashboard to update window.dispatchEvent(new CustomEvent('team-context-changed', { detail: { team: teamContext } })); // Update chat system prompt this.updateChatContext(teamContext); // Add system message const teamNames = { 'all': 'All Tools', 'ui': 'UI Team', 'ux': 'UX Team', 'qa': 'QA Team' }; this.addMessage('system', `Switched to ${teamNames[teamContext]} view. Dashboard and tools updated.`); } updateChatContext(teamContext) { const contextPrompts = { 'all': 'You are a DSS assistant. Help with all design system operations.', 'ui': `You are a DSS assistant for the UI Team. Focus on: • Extracting tokens and components from Figma • Generating component code • Syncing design updates • Component library maintenance Available commands: extract_tokens, extract_components, generate_component_code, sync_tokens_to_file`, 'ux': `You are a DSS assistant for the UX Team. Focus on: • Design consistency and token validation • Style pattern analysis • Naming conventions • Brand guideline compliance Available commands: analyze_style_values, check_naming_consistency, find_style_patterns, validate_tokens`, 'qa': `You are a DSS assistant for the QA Team. Focus on: • Finding quick wins and issues • Component validation • Unused style detection • Test coverage analysis Available commands: get_quick_wins, validate_tokens, find_unused_styles, analyze_react_components` }; this.ai.setSystemPrompt(contextPrompts[teamContext] || contextPrompts['all']); } updateInput(text) { const input = this.shadowRoot.querySelector('.chat-input'); input.value = text; } async handleSend() { const input = this.shadowRoot.querySelector('.chat-input'); const message = input.value.trim(); if (!message) return; this.addMessage('user', message); input.value = ''; // Check for /help command if (message === '/help' || message === 'help') { this.showHelp(); return; } // Check for /cancel if (message === '/cancel' || message === 'cancel') { if (this.ai.pendingCommand || this.ai.pendingConfirmation) { this.ai.pendingCommand = null; this.ai.pendingConfirmation = null; this.addMessage('system', 'Command cancelled.'); } return; } // Check if we're waiting for confirmation if (this.ai.pendingConfirmation) { const result = await this.ai.confirmCommand(message); if (result.error) { this.addMessage('error', result.error); } else if (result.cancelled) { this.addMessage('system', result.message); } else if (result.success) { this.addMessage('assistant', result.result); } else if (!result.success && result.error) { this.addMessage('error', `Command failed: ${result.error}`); } return; } // Check if we're in a command flow (collecting parameters) if (this.ai.pendingCommand) { const result = await this.ai.handleCommandInput(message); if (result.error) { this.addMessage('error', result.error); } else if (result.waiting) { // Show confirmation prompt if generated if (result.confirmation) { this.addMessage('prompt', result.confirmation); } } else if (result.success) { this.addMessage('assistant', result.result); } else if (!result.success && result.command) { this.addMessage('error', `Command failed: ${result.error}`); } return; } // Check for slash commands const parsed = this.ai.parseCommand(message); if (parsed) { const result = await this.ai.startCommand(parsed.command, parsed.inlineData || {}); if (!result.handled) { this.addMessage('error', `Unknown command: ${parsed.command}. Type /help for available commands.`); } else if (result.waiting) { // Show form prompt this.addMessage('prompt', result.prompt); } else if (result.success) { this.addMessage('assistant', result.result); } else if (result.error) { this.addMessage('error', result.error); } return; } // Regular message - send to AI await this.ai.sendMessage(message); // Show tools used if any const toolsUsed = this.ai.getLastToolsUsed(); if (toolsUsed && toolsUsed.length > 0) { const toolNames = toolsUsed.map(t => t.tool).join(', '); this.addMessage('tool-info', `Tools used: ${toolNames}`); } } showHelp() { const commands = this.ai.commands; const helpText = `**Available Commands** **Project Management** \`/init\` - Create a new DSS project \`/projects\` - List all projects \`/select \` - Select a project **Figma Integration** \`/add-figma\` - Add Figma file to current project \`/figma-files\` - List Figma files in project \`/sync\` - Sync tokens from Figma **Analysis** \`/analyze\` - Analyze project for components \`/quick-wins\` - Find quick improvements \`/discover\` - Run project discovery **Utilities** \`/help\` - Show this help \`/cancel\` - Cancel current command \`/status\` - Show current project status **Tips** - Commands also work in natural language - "init project" = \`/init\` - "sync tokens" = \`/sync\` - Press Enter to use defaults for optional fields`; this.addMessage('assistant', helpText); } addMessage(role, content) { this.messages.push({ role, content, timestamp: new Date() }); this.renderMessages(); } async loadProjects() { try { const response = await fetch('/api/projects'); if (response.ok) { this.projects = await response.json(); this.renderProjectSelector(); } } catch (e) { console.error('Failed to load projects:', e); } } renderProjectSelector() { const selector = this.shadowRoot.querySelector('.project-select'); if (!selector || !this.projects) return; const currentProject = this.ai.getProject(); selector.innerHTML = ` ${this.projects.map(p => ` `).join('')} `; } updateProjectContext(project) { const projectId = project?.id || project; this.ai.setProject(projectId); if (projectId && project) { const projectName = project.name || projectId; // Load full project context from API (MVP1 feature) this.loadProjectContext(project).then(context => { if (context) { const rootPath = context.project?.root_path || 'not configured'; this.addMessage('system', `Project context set to: ${projectName}\nWorking directory: ${rootPath}`); } else { this.addMessage('system', `Project context set to: ${projectName}. Claude can now access project data and tools.`); } }); } else { this.addMessage('system', 'Project context cleared. Using basic chat mode.'); } } async loadProjectContext(project) { /** * Load full project context including file tree for AI injection. * MVP1 Configuration Architecture feature. */ try { const response = await fetch(`/api/projects/${project.id}/context`); if (!response.ok) { console.warn('Failed to load project context:', response.status); return null; } const context = await response.json(); // Update AI with project context if (this.ai && this.ai.setContext) { this.ai.setContext({ projectId: project.id, projectName: project.name, rootPath: context.project?.root_path, fileTree: context.file_tree, config: context.config, contextFiles: context.context_files }); } // Store context for later use this.projectContext = context; return context; } catch (error) { console.error('Failed to load project context:', error); return null; } } updateState(state) { const voiceBtn = this.shadowRoot.querySelector('.voice-btn'); const statusIndicator = this.shadowRoot.querySelector('.status-indicator'); if (voiceBtn) { voiceBtn.classList.toggle('listening', state.listening); } if (statusIndicator) { if (state.thinking) { statusIndicator.textContent = 'Thinking...'; statusIndicator.className = 'status-indicator thinking'; } else if (state.listening) { statusIndicator.textContent = 'Listening...'; statusIndicator.className = 'status-indicator listening'; } else if (state.speaking) { statusIndicator.textContent = 'Speaking...'; statusIndicator.className = 'status-indicator speaking'; } else { statusIndicator.textContent = ''; statusIndicator.className = 'status-indicator'; } } } renderMessages() { const container = this.shadowRoot.querySelector('.messages'); container.innerHTML = this.messages.map(msg => `
${this.formatMessage(msg.content)}
`).join(''); container.scrollTop = container.scrollHeight; } formatMessage(content) { // Use marked.js for full markdown parsing if (typeof marked !== 'undefined' && marked.parse) { try { // Configure marked renderer for code blocks const renderer = new marked.Renderer(); renderer.code = function(code, language) { const lang = language || 'plaintext'; if (typeof hljs !== 'undefined') { try { const highlighted = hljs.highlightAuto(code).value; return `
${highlighted}
`; } catch (e) { /* fallback below */ } } const escaped = code.replace(//g, '>'); return `
${escaped}
`; }; const rawHtml = marked.parse(content, { renderer: renderer, breaks: true, gfm: true }); // Sanitize HTML if (typeof DOMPurify !== 'undefined') { return DOMPurify.sanitize(rawHtml, { ADD_TAGS: ['code', 'pre', 'span'], ADD_ATTR: ['class'] }); } return rawHtml; } catch (e) { console.warn('Markdown parsing error:', e); } } // Fallback to basic formatting return content .replace(//g, '>') .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/`(.*?)`/g, '$1') .replace(/\n/g, '
') .replace(/• /g, '• '); } render() { this.shadowRoot.innerHTML = `
AI Assistant
${this.ai?.voiceEnabled ? ` ` : ''}
`; } } customElements.define('ds-ai-chat', DsAiChat); export { AIAssistant, DsAiChat }; export default AIAssistant;