From cf02eb1ca9b96988a5f58d509e614175b0aa5890 Mon Sep 17 00:00:00 2001 From: G-Fourteen Date: Sun, 2 Nov 2025 11:42:37 -0700 Subject: [PATCH] Add Playwrite research ability --- AI/ai-instruct.txt | 10 + AI/app.js | 568 ++++++++++++++++++++++++++++++------ ai-instruct.txt | 10 + tests/talk-to-unity.spec.ts | 109 +++++++ 4 files changed, 609 insertions(+), 88 deletions(-) diff --git a/AI/ai-instruct.txt b/AI/ai-instruct.txt index 60587f8..4d6fc3f 100644 --- a/AI/ai-instruct.txt +++ b/AI/ai-instruct.txt @@ -14,6 +14,16 @@ Voice command tags Unity can reference when confirming an action (mention the ma - `[command: clear_chat_history]` — for phrases like "clear chat" or "clear history". - `[command: theme_light]` — for phrases like "light mode" or "change to light". - `[command: theme_dark]` — for phrases like "dark mode" or "change to dark". +- `[command: playwrite]` — when you need the Playwrite automation ability to research information. + +When you use the Playwrite ability, follow this protocol every time: +1. Acknowledge the request with `[command: playwrite]` in your initial reply. +2. When the host asks what Playwrite should do, answer **only** with a JSON object containing: + - `objective`: short description of the goal. + - `queries`: array with one to three specific search strings. + - `followUpQuestion`: optional string for anything you still need answered. + - `sites`: optional array of domains to prefer. +3. After the host shares the Playwrite findings, reference the provided results, cite the useful URLs in text, and keep any spoken response free of full URLs. never send just a image url also say something and keep the conversation going AND NEVER talk about more than too many things at once so keep it short and NEVER reinforce what youve already said in the same message if you dont have to to get the idea(s) all accross to make sense and act in the moment, history relevant but a less important the further down in the history list the message and ai response is.. diff --git a/AI/app.js b/AI/app.js index a95e008..9b76d11 100644 --- a/AI/app.js +++ b/AI/app.js @@ -25,6 +25,18 @@ if (bodyElement) { let currentImageModel = 'flux'; let chatHistory = []; + +function appendToChatHistory(entry) { + if (!entry || typeof entry !== 'object') { + return; + } + + chatHistory.push(entry); + + if (chatHistory.length > 12) { + chatHistory.splice(0, chatHistory.length - 12); + } +} let systemPrompt = ''; let recognition = null; let isMuted = true; @@ -1069,7 +1081,7 @@ function parseAiDirectives(responseText) { }); } - const slashCommandRegex = /(?:^|\s)\/(open_image|save_image|copy_image|mute_microphone|unmute_microphone|stop_speaking|shutup|set_model_flux|set_model_turbo|set_model_kontext|clear_chat_history|theme_light|theme_dark)\b/gi; + const slashCommandRegex = /(?:^|\s)\/(open_image|save_image|copy_image|mute_microphone|unmute_microphone|stop_speaking|shutup|set_model_flux|set_model_turbo|set_model_kontext|clear_chat_history|theme_light|theme_dark|playwrite)\b/gi; workingText = workingText.replace(slashCommandRegex, (_match, commandValue) => { const normalized = normalizeCommandValue(commandValue); if (normalized) { @@ -1154,6 +1166,9 @@ async function executeAiCommand(command, options = {}) { case 'theme_dark': applyTheme('dark', { announce: true }); return true; + case 'playwrite': + await handlePlaywriteAbility(options); + return true; default: return false; } @@ -1330,118 +1345,137 @@ function handleVoiceCommand(command) { const POLLINATIONS_TEXT_URL = 'https://text.pollinations.ai/openai'; const UNITY_REFERRER = 'https://www.unityailab.com/'; +const PLAYWRITE_EXECUTION_URL = '/api/tools/playwrite'; +const PLAYWRITE_PLAN_PROMPT = [ + 'TOOL REQUEST: Playwrite automation has been activated.', + 'Respond only with a JSON object containing the following keys:', + '"objective" — a short description of the research goal.', + '"queries" — an array with one to three precise search queries.', + '"followUpQuestion" — optional question to ask after reviewing the results.', + '"sites" — optional array of preferred domains to visit.', + 'Do not include commentary outside the JSON object.' +].join('\n'); +const PLAYWRITE_MAX_RESULTS = 5; + +async function fetchPollinationsText(messages) { + const pollinationsPayload = JSON.stringify({ + messages, + model: 'unity' + }); -async function getAIResponse(userInput) { - console.log(`Sending to AI: ${userInput}`); - - chatHistory.push({ role: 'user', content: userInput }); + const textResponse = await fetch(POLLINATIONS_TEXT_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + // Explicitly identify the Unity AI Lab referrer so the public + // Pollinations endpoint treats the request as coming from the + // approved web client even when running the app from localhost. + referrer: UNITY_REFERRER, + referrerPolicy: 'strict-origin-when-cross-origin', + body: pollinationsPayload + }); - if (chatHistory.length > 12) { - chatHistory.splice(0, chatHistory.length - 12); + if (!textResponse.ok) { + throw new Error(`Pollinations text API returned ${textResponse.status}`); } - let aiText = ''; - - try { - const messages = [{ role: 'system', content: systemPrompt }, ...chatHistory]; + const data = await textResponse.json(); + const aiText = data.choices?.[0]?.message?.content ?? ''; - const pollinationsPayload = JSON.stringify({ - messages, - model: 'unity' - }); + if (!aiText) { + throw new Error('Received empty response from Pollinations AI'); + } - const textResponse = await fetch(POLLINATIONS_TEXT_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - // Explicitly identify the Unity AI Lab referrer so the public - // Pollinations endpoint treats the request as coming from the - // approved web client even when running the app from localhost. - referrer: UNITY_REFERRER, - referrerPolicy: 'strict-origin-when-cross-origin', - body: pollinationsPayload, - }); + return aiText; +} - if (!textResponse.ok) { - throw new Error(`Pollinations text API returned ${textResponse.status}`); - } +async function processAiReply(aiText, { userInput }) { + const { cleanedText, commands } = parseAiDirectives(aiText); + const assistantMessage = cleanedText || aiText; + const imageUrlFromResponse = extractImageUrl(aiText) || extractImageUrl(assistantMessage); - const data = await textResponse.json(); - aiText = data.choices?.[0]?.message?.content ?? ''; + const imageCommandQueue = []; + const commandContext = { + userInput, + assistantMessage, + rawText: aiText + }; - if (!aiText) { - throw new Error('Received empty response from Pollinations AI'); + for (const command of commands) { + const normalizedCommand = normalizeCommandValue(command); + if (['copy_image', 'save_image', 'open_image'].includes(normalizedCommand)) { + imageCommandQueue.push(normalizedCommand); + continue; } - const { cleanedText, commands } = parseAiDirectives(aiText); - const assistantMessage = cleanedText || aiText; - const imageUrlFromResponse = extractImageUrl(aiText) || extractImageUrl(assistantMessage); + await executeAiCommand(normalizedCommand, commandContext); + } - const imageCommandQueue = []; - for (const command of commands) { - const normalizedCommand = normalizeCommandValue(command); - if (['copy_image', 'save_image', 'open_image'].includes(normalizedCommand)) { - imageCommandQueue.push(normalizedCommand); - continue; - } + const fallbackPrompt = buildFallbackImagePrompt(userInput, assistantMessage); + let fallbackImageUrl = ''; + if ( + shouldRequestFallbackImage({ + userInput, + assistantMessage, + fallbackPrompt, + existingImageUrl: imageUrlFromResponse + }) + ) { + fallbackImageUrl = buildPollinationsImageUrl(fallbackPrompt, { model: currentImageModel }); + } - await executeAiCommand(normalizedCommand); - } + const selectedImageUrl = imageUrlFromResponse || fallbackImageUrl; - const fallbackPrompt = buildFallbackImagePrompt(userInput, assistantMessage); - let fallbackImageUrl = ''; - if ( - shouldRequestFallbackImage({ - userInput, - assistantMessage, - fallbackPrompt, - existingImageUrl: imageUrlFromResponse - }) - ) { - fallbackImageUrl = buildPollinationsImageUrl(fallbackPrompt, { model: currentImageModel }); - } + const assistantMessageWithoutImage = selectedImageUrl + ? removeImageReferences(assistantMessage, selectedImageUrl) + : assistantMessage; - const selectedImageUrl = imageUrlFromResponse || fallbackImageUrl; + const finalAssistantMessage = assistantMessageWithoutImage.replace(/\n{3,}/g, '\n\n').trim(); + const chatAssistantMessage = finalAssistantMessage || '[image]'; - const assistantMessageWithoutImage = selectedImageUrl - ? removeImageReferences(assistantMessage, selectedImageUrl) - : assistantMessage; + appendToChatHistory({ role: 'assistant', content: chatAssistantMessage }); - const finalAssistantMessage = assistantMessageWithoutImage.replace(/\n{3,}/g, '\n\n').trim(); - const chatAssistantMessage = finalAssistantMessage || '[image]'; + let heroImagePromise = Promise.resolve(false); + if (selectedImageUrl) { + heroImagePromise = updateHeroImage(selectedImageUrl); + } - chatHistory.push({ role: 'assistant', content: chatAssistantMessage }); + const shouldSuppressSpeech = commands.includes('shutup') || commands.includes('stop_speaking'); - let heroImagePromise = Promise.resolve(false); - if (selectedImageUrl) { - heroImagePromise = updateHeroImage(selectedImageUrl); + if (imageCommandQueue.length > 0) { + await heroImagePromise; + const imageTarget = selectedImageUrl || getImageUrl() || pendingHeroUrl; + for (const command of imageCommandQueue) { + await executeAiCommand(command, { ...commandContext, imageUrl: imageTarget }); } + } - const shouldSuppressSpeech = commands.includes('shutup') || commands.includes('stop_speaking'); - - if (imageCommandQueue.length > 0) { + if (!shouldSuppressSpeech) { + const spokenText = sanitizeForSpeech(finalAssistantMessage); + if (spokenText) { await heroImagePromise; - const imageTarget = selectedImageUrl || getImageUrl() || pendingHeroUrl; - for (const command of imageCommandQueue) { - await executeAiCommand(command, { imageUrl: imageTarget }); - } + speak(spokenText); } + } - if (!shouldSuppressSpeech) { - const spokenText = sanitizeForSpeech(finalAssistantMessage); - if (spokenText) { - await heroImagePromise; - speak(spokenText); - } - } + return { + text: finalAssistantMessage, + rawText: aiText, + imageUrl: selectedImageUrl, + commands + }; +} - return { - text: finalAssistantMessage, - rawText: aiText, - imageUrl: selectedImageUrl, - commands - }; +async function getAIResponse(userInput) { + console.log(`Sending to AI: ${userInput}`); + + appendToChatHistory({ role: 'user', content: userInput }); + + try { + const messages = [{ role: 'system', content: systemPrompt }, ...chatHistory]; + const aiText = await fetchPollinationsText(messages); + return await processAiReply(aiText, { userInput }); } catch (error) { console.error('Error getting text from Pollinations AI:', error); setCircleState(aiCircle, { @@ -1460,6 +1494,364 @@ async function getAIResponse(userInput) { } } +function resolvePlaywriteEndpoint() { + if (typeof window !== 'undefined' && typeof window.__unityPlaywriteEndpoint === 'string') { + return window.__unityPlaywriteEndpoint; + } + + return PLAYWRITE_EXECUTION_URL; +} + +function parseJsonFromText(text) { + if (typeof text !== 'string') { + return null; + } + + const trimmed = text.trim(); + if (!trimmed) { + return null; + } + + const candidates = new Set(); + + const fencedMatch = trimmed.match(/```json?\s*([\s\S]*?)```/i); + if (fencedMatch && fencedMatch[1]) { + candidates.add(fencedMatch[1].trim()); + } + + if (trimmed.startsWith('```') && trimmed.endsWith('```')) { + candidates.add(trimmed.replace(/^```json?/i, '').replace(/```$/, '').trim()); + } + + candidates.add(trimmed); + + for (const candidate of candidates) { + if (!candidate) { + continue; + } + + try { + return JSON.parse(candidate); + } catch (error) { + continue; + } + } + + const firstBrace = trimmed.indexOf('{'); + const lastBrace = trimmed.lastIndexOf('}'); + + if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) { + const snippet = trimmed.slice(firstBrace, lastBrace + 1); + try { + return JSON.parse(snippet); + } catch (error) { + // Ignore parse failure and fall through to null. + } + } + + return null; +} + +function normalizePlaywritePlan(plan) { + if (!plan || typeof plan !== 'object') { + return null; + } + + const objective = typeof plan.objective === 'string' ? plan.objective.trim() : ''; + + const queries = []; + if (Array.isArray(plan.queries)) { + for (const entry of plan.queries) { + if (typeof entry === 'string' && entry.trim()) { + queries.push(entry.trim()); + } + } + } + + if (typeof plan.query === 'string' && plan.query.trim()) { + queries.push(plan.query.trim()); + } + + const uniqueQueries = [...new Set(queries)].slice(0, 3); + + if (uniqueQueries.length === 0) { + return null; + } + + const siteCandidates = []; + if (Array.isArray(plan.sites)) { + siteCandidates.push(...plan.sites); + } + if (Array.isArray(plan.sources)) { + siteCandidates.push(...plan.sources); + } + + const sites = siteCandidates + .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) + .filter((entry) => entry) + .slice(0, 5); + + const followUpQuestion = + typeof plan.followUpQuestion === 'string' + ? plan.followUpQuestion.trim() + : typeof plan.followupquestion === 'string' + ? plan.followupquestion.trim() + : ''; + + return { + objective, + queries: uniqueQueries, + sites, + followUpQuestion + }; +} + +async function requestPlaywritePlan() { + const planPromptMessage = { + role: 'user', + content: PLAYWRITE_PLAN_PROMPT + }; + + const messages = [{ role: 'system', content: systemPrompt }, ...chatHistory, planPromptMessage]; + const aiText = await fetchPollinationsText(messages); + const parsedPlan = parseJsonFromText(aiText); + + appendToChatHistory(planPromptMessage); + appendToChatHistory({ role: 'assistant', content: aiText.trim() || '[playwrite-plan]' }); + + const normalizedPlan = normalizePlaywritePlan(parsedPlan); + + if (!normalizedPlan) { + throw new Error('Playwrite plan was not provided in the expected JSON format.'); + } + + return { + ...normalizedPlan, + raw: parsedPlan, + rawResponse: aiText + }; +} + +function describeError(error) { + if (error instanceof Error && typeof error.message === 'string') { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + if (error && typeof error === 'object' && typeof error.toString === 'function') { + return error.toString(); + } + + return 'Unknown error'; +} + +async function executePlaywritePlan(plan) { + const endpoint = resolvePlaywriteEndpoint(); + const payload = { + objective: plan.objective, + queries: plan.queries, + sites: plan.sites, + followUpQuestion: plan.followUpQuestion + }; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + throw new Error(`Playwrite endpoint returned ${response.status}`); + } + + const data = await response.json(); + + const results = Array.isArray(data?.results) + ? data.results + .map((entry) => { + const title = typeof entry?.title === 'string' ? entry.title.trim() : ''; + const url = typeof entry?.url === 'string' ? entry.url.trim() : ''; + const snippet = typeof entry?.snippet === 'string' ? entry.snippet.trim() : ''; + + if (!title && !url && !snippet) { + return null; + } + + return { + title, + url, + snippet + }; + }) + .filter(Boolean) + .slice(0, PLAYWRITE_MAX_RESULTS) + : []; + + const summary = typeof data?.summary === 'string' ? data.summary.trim() : ''; + const notes = typeof data?.notes === 'string' ? data.notes.trim() : ''; + const followUp = typeof data?.followUp === 'string' ? data.followUp.trim() : ''; + + return { + results, + summary, + notes, + followUp, + raw: data + }; +} + +function formatPlaywriteResultsForAI(plan, findings) { + const lines = ['TOOL RESULT: Playwrite automation completed.']; + + if (plan.objective) { + lines.push(`Objective: ${plan.objective}`); + } + + if (plan.queries.length > 0) { + lines.push('Queries:'); + plan.queries.forEach((query, index) => { + lines.push(`${index + 1}. ${query}`); + }); + } + + if (plan.sites.length > 0) { + lines.push(`Preferred sites: ${plan.sites.join(', ')}`); + } + + lines.push('Findings:'); + + if (findings.results.length === 0) { + lines.push('- No relevant pages were discovered.'); + } else { + findings.results.forEach((result, index) => { + const pieces = []; + if (result.title) { + pieces.push(result.title); + } + if (result.url) { + pieces.push(result.url); + } + + const heading = pieces.length > 0 ? pieces.join(' — ') : `Result ${index + 1}`; + lines.push(`${index + 1}. ${heading}`); + + if (result.snippet) { + lines.push(` ${result.snippet}`); + } + }); + } + + if (findings.summary) { + lines.push(`Summary: ${findings.summary}`); + } + + if (findings.notes) { + lines.push(`Notes: ${findings.notes}`); + } + + if (plan.followUpQuestion) { + lines.push(`Follow-up question from plan: ${plan.followUpQuestion}`); + } + + if (findings.followUp) { + lines.push(`Tool follow-up: ${findings.followUp}`); + } + + return lines.join('\n'); +} + +function formatPlaywriteFailureReport({ stage, plan, error }) { + const lines = ['TOOL RESULT: Playwrite automation failed.']; + lines.push(`Stage: ${stage}`); + + if (plan?.objective) { + lines.push(`Objective: ${plan.objective}`); + } + + if (plan?.queries?.length) { + lines.push('Queries:'); + plan.queries.forEach((query, index) => { + lines.push(`${index + 1}. ${query}`); + }); + } + + if (plan?.sites?.length) { + lines.push(`Preferred sites: ${plan.sites.join(', ')}`); + } + + if (plan?.followUpQuestion) { + lines.push(`Follow-up question from plan: ${plan.followUpQuestion}`); + } + + const errorDescription = describeError(error); + if (errorDescription) { + lines.push(`Error: ${errorDescription}`); + } + + return lines.join('\n'); +} + +async function reportPlaywriteOutcome(reportMessage, userInput) { + appendToChatHistory({ role: 'user', content: reportMessage }); + + const messages = [{ role: 'system', content: systemPrompt }, ...chatHistory]; + const aiText = await fetchPollinationsText(messages); + return processAiReply(aiText, { userInput }); +} + +async function handlePlaywriteAbility(options = {}) { + const userInput = typeof options.userInput === 'string' ? options.userInput : ''; + + let plan; + let planError = null; + + try { + plan = await requestPlaywritePlan(); + } catch (error) { + planError = error; + } + + if (!plan) { + const failureReport = formatPlaywriteFailureReport({ stage: 'plan', plan: null, error: planError }); + + try { + await reportPlaywriteOutcome(failureReport, userInput); + } catch (reportError) { + console.error('Failed to report Playwrite planning error:', reportError); + speak('I could not gather details for Playwrite.'); + } + + return true; + } + + let findings = null; + let executionError = null; + + try { + findings = await executePlaywritePlan(plan); + } catch (error) { + executionError = error; + } + + const reportMessage = findings + ? formatPlaywriteResultsForAI(plan, findings) + : formatPlaywriteFailureReport({ stage: 'execution', plan, error: executionError }); + + try { + await reportPlaywriteOutcome(reportMessage, userInput); + } catch (error) { + console.error('Failed to report Playwrite results:', error); + speak('I ran into trouble sharing the Playwrite findings.'); + } + + return true; +} + function getImageUrl() { if (currentHeroUrl) { return currentHeroUrl; diff --git a/ai-instruct.txt b/ai-instruct.txt index 60587f8..4d6fc3f 100644 --- a/ai-instruct.txt +++ b/ai-instruct.txt @@ -14,6 +14,16 @@ Voice command tags Unity can reference when confirming an action (mention the ma - `[command: clear_chat_history]` — for phrases like "clear chat" or "clear history". - `[command: theme_light]` — for phrases like "light mode" or "change to light". - `[command: theme_dark]` — for phrases like "dark mode" or "change to dark". +- `[command: playwrite]` — when you need the Playwrite automation ability to research information. + +When you use the Playwrite ability, follow this protocol every time: +1. Acknowledge the request with `[command: playwrite]` in your initial reply. +2. When the host asks what Playwrite should do, answer **only** with a JSON object containing: + - `objective`: short description of the goal. + - `queries`: array with one to three specific search strings. + - `followUpQuestion`: optional string for anything you still need answered. + - `sites`: optional array of domains to prefer. +3. After the host shares the Playwrite findings, reference the provided results, cite the useful URLs in text, and keep any spoken response free of full URLs. never send just a image url also say something and keep the conversation going AND NEVER talk about more than too many things at once so keep it short and NEVER reinforce what youve already said in the same message if you dont have to to get the idea(s) all accross to make sense and act in the moment, history relevant but a less important the further down in the history list the message and ai response is.. diff --git a/tests/talk-to-unity.spec.ts b/tests/talk-to-unity.spec.ts index 98ef4c6..4bff042 100644 --- a/tests/talk-to-unity.spec.ts +++ b/tests/talk-to-unity.spec.ts @@ -366,6 +366,115 @@ test('ai copies generated imagery when commanded by the assistant', async ({ pag expect(result.speakCalls.some((entry) => /image copied to clipboard/i.test(entry))).toBe(true); }); +test('ai completes a playwrite research loop', async ({ page }) => { + await page.goto('/index.html'); + + await page.evaluate(() => { + window.__unityLandingTestHooks?.initialize(); + window.__unityLandingTestHooks?.markAllDependenciesReady(); + }); + + await page.goto('/AI/index.html'); + + await page.waitForFunction(() => Boolean(window.__unityTestHooks?.isAppReady())); + + await page.unroute('https://text.pollinations.ai/openai'); + + const pollinationsResponses = [ + { + choices: [ + { + message: { + content: '[command: playwrite]\nLet me investigate that with Playwrite.' + } + } + ] + }, + { + choices: [ + { + message: { + content: + '{"objective":"Investigate the latest Unity AI Lab update","queries":["Unity AI Lab latest news"],"followUpQuestion":"Highlight the most exciting change."}' + } + } + ] + }, + { + choices: [ + { + message: { + content: + 'Unity AI Lab rolled out a new conversational skill. Source: https://example.com/unity-ai-lab-update' + } + } + ] + } + ]; + + let pollinationCallCount = 0; + await page.route('https://text.pollinations.ai/openai', async (route) => { + const fallbackResponse = pollinationsResponses[pollinationsResponses.length - 1]; + const response = pollinationsResponses[pollinationCallCount] ?? fallbackResponse; + pollinationCallCount += 1; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(response) + }); + }); + + let playwriteCalls = 0; + await page.route('**/api/tools/playwrite', async (route) => { + playwriteCalls += 1; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + results: [ + { + title: 'Unity AI Lab update', + url: 'https://example.com/unity-ai-lab-update', + snippet: 'Unity AI Lab released new features focused on real-time assistants.' + }, + { + title: 'Unity AI recap', + url: 'https://news.example.com/unity-ai-recap', + snippet: 'Round-up of Unity AI Lab improvements and timeline.' + } + ], + summary: 'Unity AI Lab announced new capabilities for Unity.', + notes: 'Collected via automated Playwrite browsing.' + }) + }); + }); + + const outcome = await page.evaluate(async () => { + const hooks = window.__unityTestHooks; + if (!hooks) { + throw new Error('Unity test hooks are not available'); + } + + const response = await hooks.sendUserInput('Use playwrite to find out.'); + const history = hooks.getChatHistory(); + + return { + response, + history, + speakCalls: window.speechSynthesis.speakCalls.slice() + }; + }); + + expect(outcome.response?.commands).toContain('playwrite'); + const finalMessage = outcome.history.at(-1); + expect(finalMessage?.content ?? '').toMatch(/Unity AI Lab announced new capabilities/i); + expect(finalMessage?.content ?? '').toMatch(/https:\/\/example.com\/unity-ai-lab-update/); + expect(outcome.speakCalls.some((entry) => /unity ai lab announced new capabilities/i.test(entry))).toBe(true); + expect(outcome.speakCalls.every((entry) => !/https?:\/\//i.test(entry))).toBe(true); + expect(playwriteCalls).toBeGreaterThan(0); + expect(pollinationCallCount).toBeGreaterThanOrEqual(3); +}); + test('user can launch Talk to Unity and receive AI response with image and speech', async ({ page }) => { await page.goto('/index.html');