322 lines
9.1 KiB
JavaScript
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 };
|
|
}
|
|
}
|