Files
client/www/js/strudel-ai.mjs
T
2026-04-17 16:52:51 -04:00

322 lines
9.1 KiB
JavaScript

/**
* Strudel AI Assistant Module
* Handles AI API calls, conversation history, and code extraction
*/
const STORAGE_KEYS = {
url: 'strudel-ai-url',
key: 'strudel-ai-key',
model: 'strudel-ai-model'
};
// Cached API reference (loaded once)
let _apiReference = null;
/**
* Load the Strudel API reference from the compact markdown file
* @returns {Promise<string>} API reference text
*/
export async function loadApiReference() {
if (_apiReference !== null) return _apiReference;
try {
const resp = await fetch('./js/strudel-api-compact.md');
if (resp.ok) {
_apiReference = await resp.text();
} else {
_apiReference = '';
}
} catch (e) {
console.warn('[strudel-ai] Could not load API reference:', e.message);
_apiReference = '';
}
return _apiReference;
}
// Default settings
const DEFAULT_SETTINGS = {
url: 'https://api.ppq.ai',
key: '',
model: 'gpt-4o'
};
// Conversation history (in-memory only)
let conversationHistory = [];
/**
* Get AI settings from localStorage
* @returns {Object} {url, key, model}
*/
export function getAISettings() {
return {
url: localStorage.getItem(STORAGE_KEYS.url) || DEFAULT_SETTINGS.url,
key: localStorage.getItem(STORAGE_KEYS.key) || DEFAULT_SETTINGS.key,
model: localStorage.getItem(STORAGE_KEYS.model) || DEFAULT_SETTINGS.model
};
}
/**
* Save AI settings to localStorage
* @param {string} url - API base URL
* @param {string} key - API key
* @param {string} model - Model name
*/
export function saveAISettings(url, key, model) {
localStorage.setItem(STORAGE_KEYS.url, url);
localStorage.setItem(STORAGE_KEYS.key, key);
localStorage.setItem(STORAGE_KEYS.model, model);
}
/**
* Fetch available models from the API
* @param {string} url - API base URL
* @returns {Promise<Array>} Array of model objects with id and name
*/
export async function fetchAvailableModels(url) {
if (!url) {
return [];
}
const endpoint = `${url.replace(/\/$/, '')}/models`;
try {
const response = await fetch(endpoint, {
method: 'GET'
});
if (!response.ok) {
console.warn(`Failed to fetch models: ${response.status}`);
return [];
}
const data = await response.json();
if (data.data && Array.isArray(data.data)) {
// Sort models alphabetically by id
return data.data.sort((a, b) => a.id.localeCompare(b.id));
}
return [];
} catch (error) {
console.warn('Error fetching models:', error.message);
return [];
}
}
/**
* Clear conversation history
*/
export function clearConversationHistory() {
conversationHistory = [];
}
/**
* Get current conversation history (for debugging)
* @returns {Array} Conversation history
*/
export function getConversationHistory() {
return [...conversationHistory];
}
/**
* Build the system prompt with current code context, optional MIDI context, and API reference
* @param {string} currentCode - Current code in the editor
* @param {string} midiContext - Optional MIDI device/control context
* @param {string} apiReference - Optional Strudel API reference
* @returns {string} System prompt
*/
export function buildSystemPrompt(currentCode, midiContext, apiReference) {
const apiSection = apiReference
? `\n\n${apiReference}`
: '';
return `You are a Strudel live coding assistant. Strudel is a JavaScript port of TidalCycles for making music in the browser.
Rules:
- If the user asks you to create or modify music/sounds, respond with ONLY valid Strudel JavaScript code. No markdown fences, no explanation.
- If the user asks a question or wants an explanation, respond in plain text only. Do not include code.
- When modifying, preserve the overall structure unless asked to change it completely.
- When MIDI devices are listed below, use them in code when relevant (e.g. \`let midi = await midin('DeviceName')\`).
- Only use functions that exist in the API reference below. Do not invent function names.${apiSection}
## Current editor code:
${currentCode || '// Empty editor'}${midiContext || ''}`;
}
/**
* Build messages array for API call
* @param {Array} history - Previous conversation history
* @param {string} currentCode - Current code in editor
* @param {string} userInput - User's current input
* @param {string} midiContext - Optional MIDI context string
* @param {string} apiReference - Optional Strudel API reference
* @returns {Array} Messages array for OpenAI-compatible API
*/
export function buildMessages(history, currentCode, userInput, midiContext, apiReference) {
const messages = [
{ role: 'system', content: buildSystemPrompt(currentCode, midiContext, apiReference) },
...history,
{ role: 'user', content: userInput }
];
return messages;
}
/**
* Check if a response looks like Strudel code
* @param {string} text - Response text
* @returns {boolean}
*/
export function isCodeResponse(text) {
const codePatterns = [
/\bnote\s*\(/,
/\bs\s*\(/,
/\bsound\s*\(/,
/\bstack\s*\(/,
/\bsamples\s*\(/,
/\bn\s*\(/,
/\bchord\s*\(/,
/\.sound\s*\(/,
/\.s\s*\(/,
/\.note\s*\(/,
/\bsetcps\s*\(/,
/\bsetCps\s*\(/,
/\bseq\s*\(/,
/\bfast\s*\(/,
/\bslow\s*\(/,
/\bloop\s*\(/,
/\broom\s*\(/,
/\blpf\s*\(/,
/\bhpf\s*\(/,
/\bdelay\s*\(/,
/\brev\s*\(/,
/\bscope\s*\(/,
/\bpunchcard\s*\(/,
/\bspiral\s*\(/,
/\bpianoroll\s*\(/,
/\b"[a-f0-9]+"/,
/<[a-g][0-9]?[^>]*>/
];
return codePatterns.some(pattern => pattern.test(text.trim()));
}
/**
* Extract code from AI response (removes markdown fences if present)
* @param {string} text - Response text
* @returns {string} Extracted code
*/
export function extractCode(text) {
// Check for markdown code blocks
const codeBlockMatch = text.match(/```(?:javascript|js)?\s*\n?([\s\S]*?)```/);
if (codeBlockMatch) {
return codeBlockMatch[1].trim();
}
// Return as-is if no code block found
return text.trim();
}
/**
* Call the AI API
* @param {Array} messages - Messages array
* @param {Object} settings - {url, key, model}
* @returns {Promise<string>} AI response text
*/
export async function callAI(messages, settings) {
const { url, key, model } = settings;
if (!key) {
throw new Error('API key not configured. Please set it in the AI Settings panel.');
}
const endpoint = `${url.replace(/\/$/, '')}/chat/completions`;
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${key}`
},
body: JSON.stringify({
model: model,
messages: messages,
temperature: 0.7,
max_tokens: 2000
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`API error ${response.status}: ${errorData.error?.message || response.statusText}`);
}
const data = await response.json();
if (!data.choices || !data.choices[0] || !data.choices[0].message) {
throw new Error('Invalid response format from AI API');
}
return data.choices[0].message.content;
} catch (error) {
if (error.message.includes('Failed to fetch')) {
throw new Error('Network error: Could not connect to AI API. Check your URL and internet connection.');
}
throw error;
}
}
/**
* Send a message to the AI and handle the response
* @param {string} userInput - User's input
* @param {string} currentCode - Current code in editor
* @param {Object} editor - Strudel editor instance (for getting/setting code)
* @param {Function} onResponse - Callback for response text
* @param {Function} onError - Callback for errors
* @param {string} midiContext - Optional MIDI context string
* @returns {Promise<Object>} {isCode, content}
*/
export async function sendMessage(userInput, currentCode, editor, onResponse, onError, midiContext) {
try {
const settings = getAISettings();
// Load API reference (cached after first load)
const apiReference = await loadApiReference();
// Build messages
const messages = buildMessages(conversationHistory, currentCode, userInput, midiContext, apiReference);
// Call AI
const responseText = await callAI(messages, settings);
// Add to conversation history
conversationHistory.push({ role: 'user', content: userInput });
conversationHistory.push({ role: 'assistant', content: responseText });
// Check if response is code
const isCode = isCodeResponse(responseText);
if (isCode && editor) {
// Extract and inject code
const code = extractCode(responseText);
try {
editor.setCode(code);
onResponse(`Updated the code with your changes.`);
return { isCode: true, content: code };
} catch (injectError) {
onError(`Error injecting code: ${injectError.message}`);
return { isCode: false, content: responseText };
}
} else {
// Text response
onResponse(responseText);
return { isCode: false, content: responseText };
}
} catch (error) {
onError(error.message);
return { isCode: false, content: error.message, isError: true };
}
}