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.
1859 lines
55 KiB
JavaScript
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, '<').replace(/>/g, '>');
|
|
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, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
|
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
|
.replace(/`(.*?)`/g, '<code>$1</code>')
|
|
.replace(/\n/g, '<br>')
|
|
.replace(/• /g, '• ');
|
|
}
|
|
|
|
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;
|