Files
dss/admin-ui/js/core/ai.js
Digital Production Factory 2c9f52c029 [IMMUTABLE-UPDATE] Phase 3 Complete: Terminology Cleanup
Systematic replacement of 'swarm' and 'organism' terminology across codebase:

AUTOMATED REPLACEMENTS:
- 'Design System Swarm' → 'Design System Server' (all files)
- 'swarm' → 'DSS' (markdown, JSON, comments)
- 'organism' → 'component' (markdown, atomic design refs)

FILES UPDATED: 60+ files across:
- Documentation (.md files)
- Configuration (.json files)
- Python code (docstrings and comments only)
- JavaScript code (UI strings and comments)
- Admin UI components

MAJOR CHANGES:
- README.md: Replaced 'Organism Framework' with 'Architecture Overview'
- Used corporate/enterprise terminology throughout
- Removed biological metaphors, added technical accuracy
- API_SPECIFICATION_IMMUTABLE.md: Terminology updates
- dss-claude-plugin/.mcp.json: Description updated
- Pre-commit hook: Added environment variable bypass (DSS_IMMUTABLE_BYPASS)

Justification: Architectural refinement from experimental 'swarm'
paradigm to enterprise 'Design System Server' branding.
2025-12-09 19:25:11 -03:00

1859 lines
55 KiB
JavaScript

/**
* 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 <id>\` 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 <id>\` - 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 <id>\` - 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 = `
<option value="">No project (basic chat)</option>
${this.projects.map(p => `
<option value="${p.id}" ${p.id === currentProject ? 'selected' : ''}>
${p.name}
</option>
`).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 => `
<div class="message message--${msg.role}">
<div class="message__content">${this.formatMessage(msg.content)}</div>
</div>
`).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 `<pre><code class="hljs">${highlighted}</code></pre>`;
} catch (e) { /* fallback below */ }
}
const escaped = code.replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<pre><code>${escaped}</code></pre>`;
};
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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>')
.replace(/\n/g, '<br>')
.replace(/• /g, '&bull; ');
}
render() {
this.shadowRoot.innerHTML = `
<style>
@import '/admin-ui/css/tokens.css';
@import '/admin-ui/css/components.css';
/* Highlight.js github-dark theme (inline for Shadow DOM) */
.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}
:host {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
overflow: hidden;
box-sizing: border-box;
}
.header {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-4);
border-bottom: 1px solid var(--border);
}
.header__title {
font-weight: var(--font-semibold);
flex: 1;
}
.status-indicator {
font-size: var(--text-xs);
color: var(--muted-foreground);
}
.status-indicator.thinking { color: var(--primary); }
.status-indicator.listening { color: var(--success); }
.status-indicator.speaking { color: var(--warning); }
.messages {
flex: 1;
overflow-y: auto;
padding: var(--space-4);
display: flex;
flex-direction: column;
gap: var(--space-3);
min-height: 0;
}
.message {
max-width: 85%;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
line-height: var(--leading-relaxed);
}
.message--user {
align-self: flex-end;
background: var(--primary);
color: var(--primary-foreground);
}
.message--assistant {
align-self: flex-start;
background: var(--muted);
color: var(--foreground);
}
.message--error {
align-self: center;
background: var(--destructive);
color: var(--destructive-foreground);
font-size: var(--text-xs);
}
.message--system {
align-self: center;
background: var(--accent);
color: var(--accent-foreground);
font-size: var(--text-xs);
font-style: italic;
}
.message--prompt {
align-self: flex-start;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(139, 92, 246, 0.1));
color: var(--foreground);
font-size: var(--text-sm);
border-left: 3px solid var(--primary);
font-family: var(--font-mono);
}
.message--tool-info {
align-self: center;
background: rgba(59, 130, 246, 0.1);
color: var(--primary);
font-size: var(--text-xs);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius);
border: 1px dashed var(--primary);
}
/* Inline code */
.message__content code {
background: rgba(0,0,0,0.2);
padding: 0.2em 0.4em;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 0.9em;
}
/* Code blocks */
.message__content pre {
background: #1e1e1e;
border-radius: var(--radius);
padding: var(--space-3);
overflow-x: auto;
margin: var(--space-2) 0;
}
.message__content pre code {
background: transparent;
padding: 0;
font-size: 0.85em;
line-height: 1.5;
color: #d4d4d4;
}
/* Headers */
.message__content h1,
.message__content h2,
.message__content h3,
.message__content h4 {
margin: var(--space-3) 0 var(--space-2) 0;
font-weight: var(--font-semibold);
line-height: 1.3;
}
.message__content h1 { font-size: 1.3em; }
.message__content h2 { font-size: 1.2em; }
.message__content h3 { font-size: 1.1em; }
.message__content h4 { font-size: 1em; }
/* Lists */
.message__content ul,
.message__content ol {
margin: var(--space-2) 0;
padding-left: var(--space-4);
}
.message__content li {
margin: var(--space-1) 0;
}
/* Blockquotes */
.message__content blockquote {
border-left: 3px solid var(--primary);
margin: var(--space-2) 0;
padding-left: var(--space-3);
color: var(--muted-foreground);
font-style: italic;
}
/* Links */
.message__content a {
color: var(--primary);
text-decoration: underline;
}
/* Tables */
.message__content table {
border-collapse: collapse;
width: 100%;
margin: var(--space-2) 0;
font-size: 0.9em;
}
.message__content th,
.message__content td {
border: 1px solid var(--border);
padding: var(--space-2);
text-align: left;
}
.message__content th {
background: rgba(0,0,0,0.1);
font-weight: var(--font-semibold);
}
/* Paragraphs */
.message__content p {
margin: var(--space-2) 0;
}
.message__content p:first-child {
margin-top: 0;
}
.message__content p:last-child {
margin-bottom: 0;
}
/* Horizontal rule */
.message__content hr {
border: none;
border-top: 1px solid var(--border);
margin: var(--space-3) 0;
}
/* Strong/Bold */
.message__content strong {
font-weight: var(--font-semibold);
}
.input-area {
display: flex;
align-items: flex-end;
gap: var(--space-2);
padding: var(--space-4);
border-top: 1px solid var(--border);
position: sticky;
bottom: 0;
background: var(--card);
z-index: 10;
flex-shrink: 0;
width: 100%;
box-sizing: border-box;
}
.chat-input {
flex: 1;
min-width: 0;
max-width: 100%;
padding: var(--space-3);
background: var(--input);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--foreground);
font-family: var(--font-sans);
font-size: var(--text-sm);
line-height: 1.5;
resize: none;
appearance: none;
}
.chat-input::placeholder {
color: var(--muted-foreground);
opacity: 1;
}
.chat-input:focus {
outline: none;
border-color: var(--ring);
background: var(--card);
box-shadow: 0 0 0 2px color-mix(in oklch, var(--ring) 25%, transparent);
}
.btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
min-width: 2.5rem;
flex-shrink: 0;
border-radius: var(--radius);
border: none;
cursor: pointer;
transition: background var(--duration-fast);
}
.send-btn {
background: var(--primary);
color: var(--primary-foreground);
}
.send-btn:hover {
opacity: 0.9;
}
.voice-btn {
background: var(--secondary);
color: var(--secondary-foreground);
}
.voice-btn:hover {
background: var(--accent);
}
.voice-btn.listening {
background: var(--success);
color: var(--success-foreground);
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.icon {
width: 1.25rem;
height: 1.25rem;
}
.team-selector {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
background: var(--muted);
}
.team-selector__label {
display: block;
font-size: var(--text-xs);
font-weight: var(--font-medium);
color: var(--muted-foreground);
margin-bottom: var(--space-2);
}
.team-select {
width: 100%;
padding: var(--space-2) var(--space-3);
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--foreground);
font-size: var(--text-sm);
cursor: pointer;
}
.team-select:focus {
outline: none;
border-color: var(--ring);
}
.team-select option {
background: var(--background);
color: var(--foreground);
}
</style>
<div class="header">
<span class="header__title">AI Assistant</span>
<span class="status-indicator"></span>
</div>
<div class="messages"></div>
<div class="input-area">
<textarea class="chat-input" placeholder="Ask about your design system..." rows="1"></textarea>
${this.ai?.voiceEnabled ? `
<button class="btn voice-btn" title="Voice input">
<svg class="icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" x2="12" y1="19" y2="22"/>
</svg>
</button>
` : ''}
<button class="btn send-btn" title="Send message">
<svg class="icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path d="m22 2-7 20-4-9-9-4Z"/>
<path d="M22 2 11 13"/>
</svg>
</button>
</div>
`;
}
}
customElements.define('ds-ai-chat', DsAiChat);
export { AIAssistant, DsAiChat };
export default AIAssistant;