From 212e74f4fd8b6be9661b90a37970cba870dc6174 Mon Sep 17 00:00:00 2001 From: Calel33 <62570052+Calel33@users.noreply.github.com> Date: Sat, 3 May 2025 19:29:59 -0400 Subject: [PATCH 1/3] feat: add AgentHustle support and update adapter configurations --- chrome-extension/manifest.ts | 7 + package.json | 6 +- pages/content/src/adapters/adapterRegistry.ts | 34 +++ pages/content/src/adapters/index.ts | 212 +++--------------- 4 files changed, 69 insertions(+), 190 deletions(-) diff --git a/chrome-extension/manifest.ts b/chrome-extension/manifest.ts index 335c6363..e49ddb4a 100644 --- a/chrome-extension/manifest.ts +++ b/chrome-extension/manifest.ts @@ -40,6 +40,7 @@ const manifest = { '*://*.aistudio.google.com/*', '*://*.openrouter.ai/*', '*://*.google-analytics.com/*', + '*://*.agenthustle.ai/*', ], permissions: ['storage', 'clipboardWrite'], // permissions: ['storage', 'scripting', 'clipboardWrite'], @@ -105,6 +106,12 @@ const manifest = { js: ['content/index.iife.js'], run_at: 'document_idle', }, + // Specific content script for AgentHustle tool call parsing + { + matches: ['*://*.agenthustle.ai/*'], + js: ['content/index.iife.js'], + run_at: 'document_idle', + }, ], // devtools_page: 'devtools/index.html', web_accessible_resources: [ diff --git a/package.json b/package.json index 2ee3d685..9227dff1 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,9 @@ "lint:fix": "turbo lint:fix --continue -- --fix --cache --cache-location node_modules/.cache/.eslintcache", "prettier": "turbo prettier --continue -- --cache --cache-location node_modules/.cache/.prettiercache", "prepare": "husky", - "update-version": "bash bash-scripts/update_version.sh", - "copy_env": "bash bash-scripts/copy_env.sh", - "set-global-env": "bash bash-scripts/set_global_env.sh", + "update-version": "pwsh bash-scripts/update_version.ps1", + "copy_env": "pwsh bash-scripts/copy_env.ps1", + "set-global-env": "pwsh bash-scripts/set_global_env.ps1", "postinstall": "pnpm build:eslint && pnpm copy_env", "module-manager": "pnpm -F module-manager start" }, diff --git a/pages/content/src/adapters/adapterRegistry.ts b/pages/content/src/adapters/adapterRegistry.ts index 8dd07738..bb616daf 100644 --- a/pages/content/src/adapters/adapterRegistry.ts +++ b/pages/content/src/adapters/adapterRegistry.ts @@ -26,9 +26,18 @@ class AdapterRegistryImpl implements AdapterRegistry { // Register a new adapter registerAdapter(adapter: SiteAdapter): void { const hostnames = Array.isArray(adapter.hostname) ? adapter.hostname : [adapter.hostname]; + if (!hostnames.length) { + logMessage('Cannot register adapter: no hostnames provided'); + return; + } for (const hostname of hostnames) { + if (this.adapters.has(hostname)) { + logMessage(`Adapter for hostname ${hostname} already registered, replacing...`); + } + this.adapters.set(hostname, adapter); + logMessage(`Registered adapter for hostname: ${hostname}`); } // Clear the cache when a new adapter is registered @@ -102,6 +111,31 @@ class AdapterRegistryImpl implements AdapterRegistry { return undefined; } + + // Get all registered adapters + getAllAdapters(): SiteAdapter[] { + // Use Set to deduplicate adapters that might be registered for multiple hostnames + return Array.from(new Set(this.adapters.values())); + } + + // Check if an adapter is registered for a hostname + hasAdapter(hostname: string): boolean { + return this.adapters.has(hostname); + } + + // Remove an adapter by hostname + removeAdapter(hostname: string): void { + if (this.adapters.has(hostname)) { + this.adapters.delete(hostname); + logMessage(`Removed adapter for hostname: ${hostname}`); + } + } + + // Clear all registered adapters + clearAdapters(): void { + this.adapters.clear(); + logMessage('Cleared all registered adapters'); + } } // Singleton instance of the adapter registry diff --git a/pages/content/src/adapters/index.ts b/pages/content/src/adapters/index.ts index 042e8a7f..d764df9e 100644 --- a/pages/content/src/adapters/index.ts +++ b/pages/content/src/adapters/index.ts @@ -13,202 +13,40 @@ import { GrokAdapter } from './grokAdapter'; import { logMessage } from '../utils/helpers'; import { GeminiAdapter } from './geminiAdapter'; import { OpenRouterAdapter } from './openrouterAdapter'; +import { AgentHustleAdapter } from './agenthustleAdapter'; import type { SiteAdapter } from '../utils/siteAdapter'; // Define type for adapter constructor type AdapterConstructor = new () => SiteAdapter; -// Adapter class instances mapped to their constructors and hostnames -interface AdapterInfo { - AdapterClass: AdapterConstructor; - hostnames: string[]; -} - -// Map adapter constructors with their hostnames to avoid creating instances prematurely -const adapterInfos: AdapterInfo[] = [ - { AdapterClass: PerplexityAdapter, hostnames: ['perplexity.ai'] }, - { AdapterClass: AiStudioAdapter, hostnames: ['aistudio.google.com'] }, - { AdapterClass: ChatGptAdapter, hostnames: ['chat.openai.com', 'chatgpt.com'] }, - { AdapterClass: GrokAdapter, hostnames: ['grok.x.ai'] }, - { AdapterClass: GeminiAdapter, hostnames: ['gemini.google.com'] }, - { AdapterClass: OpenRouterAdapter, hostnames: ['openrouter.ai'] }, +// Adapter class instances map +const adapterClasses: AdapterConstructor[] = [ + PerplexityAdapter, + AiStudioAdapter, + ChatGptAdapter, + GrokAdapter, + GeminiAdapter, + OpenRouterAdapter, + AgentHustleAdapter ]; -// Map of adapter instances that will be lazily initialized -const adapterInstances = new Map(); - -// Track initialization state -let isInitializing = false; -let initializationComplete = false; - -/** - * Gets the hostname from the current URL - */ -function getCurrentHostname(): string { - return window.location.hostname; -} - -/** - * Gets the current full URL - */ -function getCurrentUrl(): string { - return window.location.href; -} - -/** - * Initialize and register a specific adapter by name - */ -function initializeAdapter(AdapterClass: AdapterConstructor): SiteAdapter { - const adapterName = AdapterClass.name; - - if (adapterInstances.has(adapterName)) { - return adapterInstances.get(adapterName)!; - } - +// Register all adapters +adapterClasses.forEach(AdapterClass => { try { - logMessage(`Initializing adapter: ${adapterName}`); const adapter = new AdapterClass(); - registerSiteAdapter(adapter); adapterRegistry.registerAdapter(adapter); - logMessage(`Registered adapter for hostname: ${adapter.hostname}`); - - adapterInstances.set(adapterName, adapter); - return adapter; - } catch (error) { - logMessage(`Error initializing adapter ${adapterName}: ${error instanceof Error ? error.message : String(error)}`); - // Create a fallback if initialization fails to prevent crashes - const fallbackAdapter = new AdapterClass(); - adapterInstances.set(adapterName, fallbackAdapter); - return fallbackAdapter; - } -} - -/** - * Determine which adapter(s) to initialize based on current URL - * Returns a promise that resolves when initialization is complete - */ -async function initializeRelevantAdapters(): Promise { - // Prevent multiple concurrent initialization attempts - if (isInitializing) { - logMessage('Adapter initialization already in progress, waiting...'); - // Wait for initialization to complete - return new Promise(resolve => { - const checkInterval = setInterval(() => { - if (initializationComplete) { - clearInterval(checkInterval); - resolve(); - } - }, 100); - }); - } - - // Set initialization flag - isInitializing = true; - - try { - const currentHostname = getCurrentHostname(); - const currentUrl = getCurrentUrl(); - logMessage(`Determining adapters for hostname: ${currentHostname} and URL: ${currentUrl}`); - - // For each adapter, check if it might apply to the current URL - // without creating full instances - let matchFound = false; - let primaryAdapter: SiteAdapter | null = null; - - // First pass with lightweight hostname checking using predefined hostnames - for (const { AdapterClass, hostnames } of adapterInfos) { - // Simple hostname check without creating instances - const mightMatch = hostnames.some(hostname => { - const hostnameNoWww = hostname.replace(/^www\./, ''); - const currentHostnameNoWww = currentHostname.replace(/^www\./, ''); - - return ( - currentHostname.includes(hostname) || - currentHostname.includes(hostnameNoWww) || - currentHostnameNoWww.includes(hostname) - ); - }); - - if (mightMatch) { - // Initialize this adapter since the hostname matches - const adapter = initializeAdapter(AdapterClass); - if (!primaryAdapter) { - primaryAdapter = adapter; - } - matchFound = true; - } - } - - // If no matches were found based on hostname, initialize all adapters as fallback - if (!matchFound) { - logMessage('No hostname matches found. Initializing all adapters as fallback.'); - for (const { AdapterClass } of adapterInfos) { - const adapter = initializeAdapter(AdapterClass); - if (!primaryAdapter) { - primaryAdapter = adapter; - } - } - - // For sites without a specific adapter, use Perplexity adapter as fallback - // since it has the most generic implementation - primaryAdapter = initializeAdapter(PerplexityAdapter); - } - - // Always ensure we have at least one adapter registered by using Perplexity as fallback - if (!primaryAdapter) { - logMessage('No primary adapter found. Using Perplexity adapter as fallback.'); - primaryAdapter = initializeAdapter(PerplexityAdapter); - } - - // Ensure DOM is ready before initialization completes - if (document.readyState !== 'complete') { - await new Promise(resolve => { - window.addEventListener('load', () => resolve(), { once: true }); - }); - } } catch (error) { - logMessage(`Error initializing adapters: ${error instanceof Error ? error.message : String(error)}`); - // Fall back to initializing Perplexity adapter which has the most robust implementation - try { - logMessage('Falling back to Perplexity adapter only'); - initializeAdapter(PerplexityAdapter); - } catch (fallbackError) { - logMessage( - `Critical error: Even fallback adapter failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`, - ); - } - } finally { - // Set initialization complete flag - initializationComplete = true; - isInitializing = false; + logMessage(`Error registering adapter ${AdapterClass.name}: ${error}`); } -} - -// Initialize adapters relevant to the current URL -// Wrap this in a try-catch to ensure it doesn't crash the extension -try { - // Use async initialization but don't wait for it to complete - initializeRelevantAdapters().catch(error => { - logMessage(`Async adapter initialization error: ${error instanceof Error ? error.message : String(error)}`); - }); -} catch (error) { - logMessage(`Error starting adapter initialization: ${error instanceof Error ? error.message : String(error)}`); - // Fall back to initializing Perplexity adapter which has the most robust implementation - try { - logMessage('Falling back to immediate Perplexity adapter initialization'); - initializeAdapter(PerplexityAdapter); - initializationComplete = true; - } catch (fallbackError) { - logMessage( - `Critical error: Even fallback adapter failed: ${fallbackError instanceof Error ? fallbackError.message : String(fallbackError)}`, - ); - } -} - -// Export getter functions for adapters that will lazy-initialize them when requested -export const perplexityAdapter = () => initializeAdapter(PerplexityAdapter); -export const aistudioAdapter = () => initializeAdapter(AiStudioAdapter); -export const chatGptAdapter = () => initializeAdapter(ChatGptAdapter); -export const grokAdapter = () => initializeAdapter(GrokAdapter); -export const geminiAdapter = () => initializeAdapter(GeminiAdapter); -export const openrouterAdapter = () => initializeAdapter(OpenRouterAdapter); +}); + +// Export all adapters +export { + PerplexityAdapter, + AiStudioAdapter, + ChatGptAdapter, + GrokAdapter, + GeminiAdapter, + OpenRouterAdapter, + AgentHustleAdapter +}; From a162a16708d81bb6a829482cd1f4e52c5106dd44 Mon Sep 17 00:00:00 2001 From: Calel33 <62570052+Calel33@users.noreply.github.com> Date: Sat, 3 May 2025 19:40:36 -0400 Subject: [PATCH 2/3] feat: add AgentHustle support and update configurations --- .gitignore | 68 ++++++++++ bash-scripts/copy_env.ps1 | 6 + bash-scripts/set_global_env.ps1 | 91 ++++++++++++++ bash-scripts/update_version.ps1 | 24 ++++ .../adapters/adaptercomponents/agenthustle.ts | 74 +++++++++++ .../src/adapters/agenthustleAdapter.ts | 116 ++++++++++++++++++ .../agenthustle.ts | 53 ++++++++ .../src/components/sidebar/SidebarManager.tsx | 6 + .../sidebar/base/BaseSidebarManager.tsx | 2 +- .../websites/agenthustle/chatInputHandler.ts | 80 ++++++++++++ 10 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 bash-scripts/copy_env.ps1 create mode 100644 bash-scripts/set_global_env.ps1 create mode 100644 bash-scripts/update_version.ps1 create mode 100644 pages/content/src/adapters/adaptercomponents/agenthustle.ts create mode 100644 pages/content/src/adapters/agenthustleAdapter.ts create mode 100644 pages/content/src/components/sidebar/Instructions/website_specific_instruction/agenthustle.ts create mode 100644 pages/content/src/components/websites/agenthustle/chatInputHandler.ts diff --git a/.gitignore b/.gitignore index 9ce073ea..fad3676a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,71 @@ tsconfig.tsbuildinfo .trash/* mcp-superassistant-proxy/bun.lock # pnpm-lock.yaml + +# Cache and temporary files +.cache +**/.cache +**/tmp +**/temp +**/*.log +**/*.log.* +**/.vibesync +**/.vibesync/* + +# IDE and editor files +.vscode +.idea +*.swp +*.swo +*~ +.project +.settings +.classpath +*.sublime-workspace +*.sublime-project + +# Debug files +**/*.debug +**/*.debug.* +**/debug.log +**/npm-debug.log* +**/yarn-debug.log* +**/yarn-error.log* +**/pnpm-debug.log* + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Build artifacts +**/*.tsbuildinfo +**/*.js.map +**/*.d.ts.map +**/.turbo +**/dist +**/build +**/.next +**/.nuxt +**/.output + +# Local development +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Package manager files +.pnpm-store +.npm +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions +.pnp.* diff --git a/bash-scripts/copy_env.ps1 b/bash-scripts/copy_env.ps1 new file mode 100644 index 00000000..3e2534b7 --- /dev/null +++ b/bash-scripts/copy_env.ps1 @@ -0,0 +1,6 @@ +# Check if .env does not exist and .example.env exists +if (!(Test-Path ".env") -and (Test-Path ".example.env")) { + # Copy .example.env to .env + Copy-Item ".example.env" -Destination ".env" + Write-Host ".example.env has been copied to .env" +} \ No newline at end of file diff --git a/bash-scripts/set_global_env.ps1 b/bash-scripts/set_global_env.ps1 new file mode 100644 index 00000000..a786d8d1 --- /dev/null +++ b/bash-scripts/set_global_env.ps1 @@ -0,0 +1,91 @@ +# Default values +$CLI_CEB_DEV = $false +$CLI_CEB_FIREFOX = $false +$cli_values = @() + +function Validate-IsBoolean { + param ( + [string]$value, + [string]$name + ) + if ($value -ne "true" -and $value -ne "false") { + Write-Error "Invalid value for <$name>. Please use 'true' or 'false'." + exit 1 + } +} + +function Validate-Key { + param ( + [string]$key, + [bool]$isEditableSection = $false + ) + if ($key -and -not $key.StartsWith("#")) { + if ($isEditableSection -and -not $key.StartsWith("CEB_")) { + Write-Error "Invalid key: <$key>. All keys in the editable section must start with 'CEB_'." + exit 1 + } + elseif (-not $isEditableSection -and -not $key.StartsWith("CLI_CEB_")) { + Write-Error "Invalid key: <$key>. All CLI keys must start with 'CLI_CEB_'." + exit 1 + } + } +} + +function Parse-Arguments { + param ( + [string[]]$args + ) + foreach ($arg in $args) { + $key = $arg.Split('=')[0] + $value = $arg.Split('=')[1] + + Validate-Key $key + + switch ($key) { + "CLI_CEB_DEV" { + $script:CLI_CEB_DEV = $value + Validate-IsBoolean $CLI_CEB_DEV "CLI_CEB_DEV" + } + "CLI_CEB_FIREFOX" { + $script:CLI_CEB_FIREFOX = $value + Validate-IsBoolean $CLI_CEB_FIREFOX "CLI_CEB_FIREFOX" + } + default { + $script:cli_values += "$key=$value" + } + } + } +} + +function Validate-EnvKeys { + $editableSectionStarts = $false + Get-Content .env | ForEach-Object { + $key = $_.Split('=')[0] + if ($key -match "^CLI_CEB_") { + $editableSectionStarts = $true + } + elseif ($editableSectionStarts) { + Validate-Key $key $true + } + } +} + +function Create-NewFile { + $tempFile = New-TemporaryFile + @" +# THOSE VALUES ARE EDITABLE ONLY VIA CLI +CLI_CEB_DEV=$CLI_CEB_DEV +CLI_CEB_FIREFOX=$CLI_CEB_FIREFOX +$($cli_values -join "`n") + +# THOSE VALUES ARE EDITABLE +$((Get-Content .env | Where-Object { $_ -match '^CEB_' }) -join "`n") +"@ | Set-Content $tempFile -NoNewline + + Move-Item $tempFile .env -Force +} + +# Main script execution +Parse-Arguments $args +Validate-EnvKeys +Create-NewFile \ No newline at end of file diff --git a/bash-scripts/update_version.ps1 b/bash-scripts/update_version.ps1 new file mode 100644 index 00000000..61a65bd0 --- /dev/null +++ b/bash-scripts/update_version.ps1 @@ -0,0 +1,24 @@ +# Usage: ./update_version.ps1 +# FORMAT IS <0.0.0> + +param( + [Parameter(Mandatory=$true)] + [string]$NewVersion +) + +if ($NewVersion -match '^\d+\.\d+\.\d+$') { + Get-ChildItem -Path . -Filter 'package.json' -Recurse -File | + Where-Object { $_.FullName -notmatch 'node_modules' } | + ForEach-Object { + $content = Get-Content $_.FullName -Raw + if ($content -match '"version":\s*"([^"]*)"') { + $currentVersion = $matches[1] + $content = $content -replace [regex]::Escape($currentVersion), $NewVersion + Set-Content -Path $_.FullName -Value $content -NoNewline + } + } + Write-Host "Updated versions to $NewVersion" +} +else { + Write-Error "Version format <$NewVersion> isn't correct, proper format is <0.0.0>" +} \ No newline at end of file diff --git a/pages/content/src/adapters/adaptercomponents/agenthustle.ts b/pages/content/src/adapters/adaptercomponents/agenthustle.ts new file mode 100644 index 00000000..7f208a21 --- /dev/null +++ b/pages/content/src/adapters/adaptercomponents/agenthustle.ts @@ -0,0 +1,74 @@ +/** + * AgentHustle Adapter Components + * + * This file contains the AgentHustle-specific adapter components and configuration. + */ + +import { logMessage } from '../../utils/helpers'; +import type { AdapterConfig } from './common'; +import { initializeAdapter } from './common'; + +// --- DOM Element Finders --- + +function findAgentHustleButtonInsertionPoint(): Element | null { + // Find the chat input container to insert the MCP toggle button + const chatInput = document.querySelector('input.flex-1.rounded-lg.border.border-border.bg-card'); + if (chatInput) { + return chatInput.parentElement; + } + return null; +} + +// --- Event Handlers --- + +function onAgentHustleMCPEnabled(adapter: any): void { + logMessage('MCP enabled for AgentHustle'); + if (adapter?.sidebarManager?.show) { + adapter.sidebarManager.show(); + } +} + +function onAgentHustleMCPDisabled(adapter: any): void { + logMessage('MCP disabled for AgentHustle'); + if (adapter?.sidebarManager?.hide) { + adapter.sidebarManager.hide(); + } +} + +function getAgentHustleURLKey(): string { + // Generate a unique key for the current URL state + // This helps track state across different chat sessions + return window.location.pathname; +} + +// --- Adapter Configuration --- + +const agentHustleAdapterConfig: AdapterConfig = { + adapterName: 'AgentHustle', + storageKeyPrefix: 'mcp-agenthustle-state', + findButtonInsertionPoint: findAgentHustleButtonInsertionPoint, + getStorage: () => localStorage, + getCurrentURLKey: getAgentHustleURLKey, + onMCPEnabled: onAgentHustleMCPEnabled, + onMCPDisabled: onAgentHustleMCPDisabled +}; + +// --- Initialization --- + +export function initAgentHustleComponents(): void { + logMessage('Initializing AgentHustle MCP components'); + const stateManager = initializeAdapter(agentHustleAdapterConfig); + + // Expose manual injection for debugging + (window as any).injectMCPButtons_AgentHustle = () => { + logMessage('Manual injection for AgentHustle triggered'); + const insertFn = (window as any)[`injectMCPButtons_${agentHustleAdapterConfig.adapterName}`]; + if (insertFn) { + insertFn(); + } else { + logMessage('Manual injection function not found for AgentHustle'); + } + }; + + logMessage('AgentHustle MCP components initialization complete'); +} \ No newline at end of file diff --git a/pages/content/src/adapters/agenthustleAdapter.ts b/pages/content/src/adapters/agenthustleAdapter.ts new file mode 100644 index 00000000..5c5d6c29 --- /dev/null +++ b/pages/content/src/adapters/agenthustleAdapter.ts @@ -0,0 +1,116 @@ +/** + * AgentHustle Adapter + * + * This adapter provides integration with agenthustle.ai/vip platform. + */ + +import { BaseAdapter } from './common/baseAdapter'; +import { logMessage } from '../utils/helpers'; +import { SidebarManager } from '../components/sidebar/SidebarManager'; +import { + insertToolResultToChatInput, + submitChatInput, + supportsFileUpload as agentHustleSupportsFileUpload, + attachFileToChatInput as agentHustleAttachFileToChatInput, +} from '../components/websites/agenthustle/chatInputHandler'; +import { initAgentHustleComponents } from './adaptercomponents/agenthustle'; + +export class AgentHustleAdapter extends BaseAdapter { + name = 'AgentHustle'; + hostname = 'agenthustle.ai'; + urlPatterns = [/^https?:\/\/(?:www\.)?agenthustle\.ai\/vip(?:\/.*)?$/]; + private urlCheckInterval: NodeJS.Timeout | null = null; + + constructor() { + super(); + this.sidebarManager = SidebarManager.getInstance('agenthustle'); + + // Initialize components + initAgentHustleComponents(); + + // Start URL check interval + this.urlCheckInterval = setInterval(() => { + // Check if we're still on an AgentHustle page + if (!this.urlPatterns.some(pattern => pattern.test(window.location.href))) { + this.cleanup(); + } + }, 1000); + } + + protected initializeObserver(forceReset: boolean = false): void { + // Initialize observer for chat input and output elements + logMessage('Initializing AgentHustle observer'); + + // Add mutation observer to detect chat interface changes + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'childList') { + // Handle chat interface changes + this.handleChatInterfaceChanges(); + } + } + }); + + // Start observing the chat container + const chatContainer = document.querySelector('.chat-container'); + if (chatContainer) { + observer.observe(chatContainer, { + childList: true, + subtree: true + }); + } + } + + private handleChatInterfaceChanges(): void { + // Handle any necessary UI updates when the chat interface changes + logMessage('Chat interface changed, updating UI elements'); + } + + protected initializeSidebarManager(): void { + if (this.sidebarManager) { + this.sidebarManager.initialize(); + } + } + + insertTextIntoInput(text: string): void { + insertToolResultToChatInput(text); + } + + triggerSubmission(): void { + submitChatInput(); + } + + cleanup(): void { + // Clear URL check interval + if (this.urlCheckInterval) { + window.clearInterval(this.urlCheckInterval); + this.urlCheckInterval = null; + } + + // Call parent cleanup + super.cleanup(); + } + + /** + * Check if AgentHustle supports file upload + * @returns true if file upload is supported + */ + supportsFileUpload(): boolean { + return agentHustleSupportsFileUpload(); + } + + /** + * Attach a file to the chat input + * @param file The file to attach + */ + async attachFile(file: File): Promise { + return agentHustleAttachFileToChatInput(file); + } + + /** + * Force a full document scan for tool commands + */ + public forceFullScan(): void { + logMessage('Forcing full document scan for AgentHustle'); + } +} \ No newline at end of file diff --git a/pages/content/src/components/sidebar/Instructions/website_specific_instruction/agenthustle.ts b/pages/content/src/components/sidebar/Instructions/website_specific_instruction/agenthustle.ts new file mode 100644 index 00000000..05fa0c51 --- /dev/null +++ b/pages/content/src/components/sidebar/Instructions/website_specific_instruction/agenthustle.ts @@ -0,0 +1,53 @@ +/** + * AgentHustle-specific instructions for the MCP sidebar + */ + +export const instructions = { + title: 'AgentHustle MCP Instructions', + description: ` + Welcome to the Model Context Protocol (MCP) integration for AgentHustle! + + This tool enhances your AgentHustle experience by providing additional capabilities + through a set of powerful tools and commands. + + To use MCP tools: + 1. Type your query or command in the chat input + 2. The MCP sidebar will automatically detect and process tool commands + 3. Results will be inserted into your conversation + + Available tools include: + - File operations (read, write, search) + - Code analysis and search + - Environment management + - Web search and research + - And more... + + The sidebar will remain hidden until tool commands are detected in your chat. + You can also manually toggle the sidebar using the MCP button. + + Tips: + - Use natural language to describe what you want to do + - The AI will automatically select the appropriate tools + - Results will be formatted and inserted into your chat + + For more information about available tools and commands, + click the "Available Tools" section in the sidebar. + `, + examples: [ + { + title: 'File Operations', + description: 'Read, write, and search files', + command: 'Can you read the contents of config.json?' + }, + { + title: 'Code Search', + description: 'Search through codebase', + command: 'Find all functions that handle user authentication' + }, + { + title: 'Web Search', + description: 'Search the web for information', + command: 'What are the latest updates to the AgentHustle API?' + } + ] +}; \ No newline at end of file diff --git a/pages/content/src/components/sidebar/SidebarManager.tsx b/pages/content/src/components/sidebar/SidebarManager.tsx index 3a7640f2..5d274bf3 100644 --- a/pages/content/src/components/sidebar/SidebarManager.tsx +++ b/pages/content/src/components/sidebar/SidebarManager.tsx @@ -23,6 +23,7 @@ export class SidebarManager extends BaseSidebarManager { private static geminiInstance: SidebarManager | null = null; private static aistudioInstance: SidebarManager | null = null; private static openrouterInstance: SidebarManager | null = null; + private static agenthustleInstance: SidebarManager | null = null; private lastToolOutputsHash: string = ''; private lastMcpToolsHash: string = ''; private isFirstLoad: boolean = true; @@ -79,6 +80,11 @@ export class SidebarManager extends BaseSidebarManager { SidebarManager.openrouterInstance = new SidebarManager(siteType); } return SidebarManager.openrouterInstance; + case 'agenthustle': + if (!SidebarManager.agenthustleInstance) { + SidebarManager.agenthustleInstance = new SidebarManager(siteType); + } + return SidebarManager.agenthustleInstance; default: // For any unexpected site type, create and return a new instance logMessage(`Creating new SidebarManager for unknown site type: ${siteType}`); diff --git a/pages/content/src/components/sidebar/base/BaseSidebarManager.tsx b/pages/content/src/components/sidebar/base/BaseSidebarManager.tsx index 97ce42d7..59d9415b 100644 --- a/pages/content/src/components/sidebar/base/BaseSidebarManager.tsx +++ b/pages/content/src/components/sidebar/base/BaseSidebarManager.tsx @@ -13,7 +13,7 @@ import '@src/components/sidebar/styles/sidebar.css'; /** * Type definition for the site type */ -export type SiteType = 'perplexity' | 'chatgpt' | 'grok' | 'gemini' | 'aistudio' | 'openrouter'; +export type SiteType = 'perplexity' | 'chatgpt' | 'grok' | 'gemini' | 'aistudio' | 'openrouter' | 'agenthustle'; /** * BaseSidebarManager is a base class for creating sidebar managers diff --git a/pages/content/src/components/websites/agenthustle/chatInputHandler.ts b/pages/content/src/components/websites/agenthustle/chatInputHandler.ts new file mode 100644 index 00000000..2f647cb4 --- /dev/null +++ b/pages/content/src/components/websites/agenthustle/chatInputHandler.ts @@ -0,0 +1,80 @@ +/** + * AgentHustle Chat Input Handler + * + * This file contains functions for interacting with AgentHustle's chat interface. + */ + +import { logMessage } from '../../../utils/helpers'; + +/** + * Insert text into the chat input field + */ +export function insertToolResultToChatInput(text: string): void { + try { + // Find the chat input element using the correct selector + const chatInput = document.querySelector('input.flex-1.rounded-lg.border.border-border.bg-card.px-3.py-2.text-card-foreground') as HTMLInputElement; + if (!chatInput) { + throw new Error('Chat input element not found'); + } + + // Insert the text + chatInput.value = text; + chatInput.dispatchEvent(new Event('input', { bubbles: true })); + logMessage('Successfully inserted text into AgentHustle chat input'); + } catch (error) { + logMessage(`Error inserting text into AgentHustle chat input: ${error}`); + } +} + +/** + * Submit the chat input + */ +export function submitChatInput(): void { + try { + // Find the submit button - it's likely next to the input + const submitButton = document.querySelector('button[type="submit"]') as HTMLButtonElement; + if (!submitButton) { + throw new Error('Submit button not found'); + } + + // Click the button + submitButton.click(); + logMessage('Successfully submitted AgentHustle chat input'); + } catch (error) { + logMessage(`Error submitting AgentHustle chat input: ${error}`); + } +} + +/** + * Check if file upload is supported + */ +export function supportsFileUpload(): boolean { + // Return true if AgentHustle supports file uploads + return false; +} + +/** + * Attach a file to the chat input + */ +export async function attachFileToChatInput(file: File): Promise { + try { + // Find the file input element + const fileInput = document.querySelector('input[type="file"].chat-file-input') as HTMLInputElement; + if (!fileInput) { + throw new Error('File input element not found'); + } + + // Create a DataTransfer object and add the file + const dataTransfer = new DataTransfer(); + dataTransfer.items.add(file); + fileInput.files = dataTransfer.files; + + // Trigger change event + fileInput.dispatchEvent(new Event('change', { bubbles: true })); + logMessage('Successfully attached file to AgentHustle chat input'); + return true; + } catch (error) { + logMessage(`Error attaching file to AgentHustle chat input: ${error}`); + return false; + } +} \ No newline at end of file From a25651bd3c401ce660a8c07c531c4687f9b5f818 Mon Sep 17 00:00:00 2001 From: Calel33 <62570052+Calel33@users.noreply.github.com> Date: Sun, 4 May 2025 16:07:27 -0400 Subject: [PATCH 3/3] feat: Improve server stability and performance - Enhanced configuration with better timeouts and retry mechanisms - Improved error handling with graceful degradation - Added retry mechanism for stalled streams - Updated renderer styles and function blocks for better UX - Updated core types and configurations for stability --- .../src/render_prescript/src/core/config.ts | 21 +- .../src/render_prescript/src/core/types.ts | 7 + .../src/observer/stalledStreamHandler.ts | 180 ++-- .../src/renderer/functionBlock.ts | 776 ++++++++++-------- .../render_prescript/src/renderer/styles.ts | 131 +++ 5 files changed, 661 insertions(+), 454 deletions(-) diff --git a/pages/content/src/render_prescript/src/core/config.ts b/pages/content/src/render_prescript/src/core/config.ts index 52d44f7f..5ae5c3de 100644 --- a/pages/content/src/render_prescript/src/core/config.ts +++ b/pages/content/src/render_prescript/src/core/config.ts @@ -34,20 +34,25 @@ export const DEFAULT_CONFIG: FunctionCallRendererConfig = { enableDirectMonitoring: true, streamingContainerSelectors: ['.pre', '.code'], // streamingContainerSelectors: ['.message-content', '.chat-message', '.message-body', '.message'], - updateThrottle: 25, - streamingMonitoringInterval: 100, + updateThrottle: 100, + streamingMonitoringInterval: 300, largeContentThreshold: Number.MAX_SAFE_INTEGER, - progressiveUpdateInterval: 250, + progressiveUpdateInterval: 750, maxContentPreviewLength: Number.MAX_SAFE_INTEGER, usePositionFixed: false, - stabilizeTimeout: 500, - debug: true, + stabilizeTimeout: 2000, + debug: false, // Theme detection useHostTheme: true, - // Stalled stream detection - defaults + // Stalled stream detection - improved defaults enableStalledStreamDetection: true, - stalledStreamTimeout: 3000, // 3 seconds before marking a stream as stalled - stalledStreamCheckInterval: 1000, // Check every 1 second + stalledStreamTimeout: 8000, + stalledStreamCheckInterval: 3000, + maxRetryAttempts: 5, + retryDelay: 2000, + reconnectDelay: 5000, + maxReconnectAttempts: 3, + exponentialBackoff: true, }; /** diff --git a/pages/content/src/render_prescript/src/core/types.ts b/pages/content/src/render_prescript/src/core/types.ts index e88f2a08..bf486190 100644 --- a/pages/content/src/render_prescript/src/core/types.ts +++ b/pages/content/src/render_prescript/src/core/types.ts @@ -24,6 +24,13 @@ export interface FunctionCallRendererConfig { enableStalledStreamDetection: boolean; stalledStreamTimeout: number; stalledStreamCheckInterval: number; + // Retry mechanism for better resilience + maxRetryAttempts: number; + retryDelay: number; + // New properties for better resilience + reconnectDelay: number; + maxReconnectAttempts: number; + exponentialBackoff: boolean; } /** diff --git a/pages/content/src/render_prescript/src/observer/stalledStreamHandler.ts b/pages/content/src/render_prescript/src/observer/stalledStreamHandler.ts index b4e2d881..8aab6e40 100644 --- a/pages/content/src/render_prescript/src/observer/stalledStreamHandler.ts +++ b/pages/content/src/render_prescript/src/observer/stalledStreamHandler.ts @@ -148,6 +148,70 @@ export const detectPreExistingIncompleteBlocks = (): void => { } }; +/** + * Calculate retry delay with exponential backoff + */ +const calculateRetryDelay = (retryCount: number, baseDelay: number): number => { + if (!CONFIG.exponentialBackoff) return baseDelay; + // Exponential backoff with a maximum delay of 30 seconds + return Math.min(baseDelay * Math.pow(2, retryCount), 30000); +}; + +/** + * Handle retry attempt for a stalled stream + */ +const handleRetryAttempt = (blockId: string, block: HTMLElement): void => { + const retryCount = stalledStreamRetryCount.get(blockId) || 0; + const maxRetries = CONFIG.maxRetryAttempts; + + if (retryCount >= maxRetries) { + // Max retries reached, update UI to show permanent stall + block.classList.add('function-permanently-stalled'); + const indicator = block.querySelector(`.stalled-indicator[data-stalled-for="${blockId}"]`); + if (indicator) { + const message = indicator.querySelector('.stalled-message span'); + if (message) { + message.textContent = `Stream permanently stalled after ${maxRetries} retry attempts.`; + } + // Remove retry button + const retryButton = indicator.querySelector('.stalled-retry-button'); + retryButton?.remove(); + } + return; + } + + // Increment retry count + stalledStreamRetryCount.set(blockId, retryCount + 1); + + // Calculate delay with exponential backoff + const delay = calculateRetryDelay(retryCount, CONFIG.retryDelay); + + // Update UI to show retry attempt + const indicator = block.querySelector(`.stalled-indicator[data-stalled-for="${blockId}"]`); + if (indicator) { + const message = indicator.querySelector('.stalled-message span'); + if (message) { + message.textContent = `Retrying... (Attempt ${retryCount + 1}/${maxRetries})`; + } + } + + // Attempt retry after delay + setTimeout(() => { + // Remove stalled status + stalledStreams.delete(blockId); + block.classList.remove('function-stalled'); + + // Trigger re-render + const functionBlock = block.closest('pre'); + if (functionBlock instanceof HTMLPreElement) { + renderFunctionCall(functionBlock, { current: false }); + } + + // Force check for updates + checkStreamingUpdates(); + }, delay); +}; + /** * Create a stalled indicator for the specified block */ @@ -192,26 +256,12 @@ export const createStalledIndicator = (blockId: string, block: HTMLElement, isAb } message.appendChild(span); - // Add a retry button + // Add a retry button with enhanced retry logic const retryButton = document.createElement('button'); retryButton.className = 'stalled-retry-button'; retryButton.textContent = 'Check for updates'; retryButton.onclick = () => { - // Track retry attempts - const retryCount = (stalledStreamRetryCount.get(blockId) || 0) + 1; - stalledStreamRetryCount.set(blockId, retryCount); - - // Update retry button text - retryButton.textContent = retryCount > 1 ? `Check again (${retryCount})` : 'Check again'; - - // Force a check for updates - checkStreamingUpdates(); - - // Try re-rendering this block - const event = new CustomEvent('render-function-call', { - detail: { blockId, element: block }, - }); - document.dispatchEvent(event); + handleRetryAttempt(blockId, block); }; indicator.appendChild(message); @@ -222,92 +272,30 @@ export const createStalledIndicator = (blockId: string, block: HTMLElement, isAb }; /** - * Check for stalled streams + * Check for stalled streams with improved detection */ export const checkStalledStreams = (): void => { if (!CONFIG.enableStalledStreamDetection) return; const now = Date.now(); - const stalledTimeout = CONFIG.stalledStreamTimeout; - - // Additional check for any incomplete function calls that might have been missed - const potentiallyMissedBlocks = Array.from(document.querySelectorAll('pre, code')).filter(el => { - // If already processed or part of function block, skip - if (el.closest('.function-block') || el.hasAttribute('data-monitored-node')) { - return false; - } - - const content = el.textContent || ''; - return ( - content.includes('') || - content.includes('')) - ); - }); - - // Process potentially missed blocks - for (const element of potentiallyMissedBlocks) { - const blockId = - element.getAttribute('data-block-id') || `missed-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - - // Set block ID if not already present - if (!element.getAttribute('data-block-id')) { - element.setAttribute('data-block-id', blockId); - } - - if (CONFIG.debug) { - console.debug(`Found potentially missed function block: ${blockId}`); - } - - // Trigger a custom event to render this block - const event = new CustomEvent('render-function-call', { - detail: { blockId, element }, - }); - document.dispatchEvent(event); - } - - // Check all streaming blocks for stalled status - streamingLastUpdated.forEach((lastUpdate, blockId) => { - // Skip blocks already marked as stalled - if (stalledStreams.has(blockId)) return; - - // Check if the block is still in the DOM - const block = renderedFunctionBlocks.get(blockId); - if (!block || !document.body.contains(block)) { - streamingLastUpdated.delete(blockId); - return; - } - - // Skip blocks that are pre-existing incomplete - if (preExistingIncompleteBlocks.has(blockId)) return; - - // Check if the block is complete (no longer loading) - if (!block.classList.contains('function-loading')) { - streamingLastUpdated.delete(blockId); - return; - } - - // Check if the block has stalled - if (now - lastUpdate > stalledTimeout) { - if (CONFIG.debug) - console.debug(`Stream stalled for block: ${blockId}. No updates for ${Math.round((now - lastUpdate) / 1000)}s`); - - // Verify if the block content is actually incomplete - const functionCallCompleteCheck = (block.textContent || '').includes(''); - const invokeCompleteCheck = (block.textContent || '').includes('') - : true; - - // If the function call or invoke tags are complete, don't mark as stalled - if (functionCallCompleteCheck && invokeCompleteCheck) { - if (CONFIG.debug) - console.debug(`Block ${blockId} appears complete despite loading status, skipping stalled indicator`); - streamingLastUpdated.delete(blockId); - return; + + // Get all function blocks + document.querySelectorAll('.function-block').forEach(block => { + const blockId = block.getAttribute('data-block-id'); + if (!blockId) return; + + const lastUpdate = streamingLastUpdated.get(blockId) || 0; + const timeSinceUpdate = now - lastUpdate; + + // Check if block is incomplete + const isIncomplete = block.classList.contains('function-loading'); + + // Check if stream is stalled + if (isIncomplete && timeSinceUpdate > CONFIG.stalledStreamTimeout) { + // Don't re-create indicator if already marked as stalled + if (!stalledStreams.has(blockId)) { + createStalledIndicator(blockId, block as HTMLElement); } - - // Create stalled indicator for this block - createStalledIndicator(blockId, block, false); } }); }; diff --git a/pages/content/src/render_prescript/src/renderer/functionBlock.ts b/pages/content/src/render_prescript/src/renderer/functionBlock.ts index f5e9e98d..ab55b4e8 100644 --- a/pages/content/src/render_prescript/src/renderer/functionBlock.ts +++ b/pages/content/src/render_prescript/src/renderer/functionBlock.ts @@ -11,6 +11,8 @@ import { import { applyThemeClass } from '../utils/themeDetector'; import { getPreviousExecution, getPreviousExecutionLegacy, generateContentSignature } from '../mcpexecute/storage'; import type { ParamValueElement } from '../core/types'; +import { streamingLastUpdated } from '../observer/streamObserver'; +import { stalledStreamRetryCount, stalledStreams } from '../observer/stalledStreamHandler'; // Define custom property for tracking scroll state declare global { @@ -183,417 +185,457 @@ if (typeof window !== 'undefined') { } export const renderFunctionCall = (block: HTMLPreElement, isProcessingRef: { current: boolean }): boolean => { - const functionInfo = containsFunctionCalls(block); + try { + const functionInfo = containsFunctionCalls(block); - if (!functionInfo.hasFunctionCalls || block.closest('.function-block')) { - return false; - } + if (!functionInfo.hasFunctionCalls || block.closest('.function-block')) { + return false; + } - const blockId = - block.getAttribute('data-block-id') || `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const blockId = + block.getAttribute('data-block-id') || `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - // Get the set of pre-existing incomplete blocks if it exists - const preExistingIncompleteBlocks = (window as any).preExistingIncompleteBlocks || new Set(); + // Get the set of pre-existing incomplete blocks if it exists + const preExistingIncompleteBlocks = (window as any).preExistingIncompleteBlocks || new Set(); - // Check if this is a pre-existing incomplete block that should not get spinners - const isPreExistingIncomplete = preExistingIncompleteBlocks.has(blockId); + // Check if this is a pre-existing incomplete block that should not get spinners + const isPreExistingIncomplete = preExistingIncompleteBlocks.has(blockId); - let existingDiv = renderedFunctionBlocks.get(blockId); - let isNewRender = false; - let previousCompletionStatus: boolean | null = null; + let existingDiv = renderedFunctionBlocks.get(blockId); + let isNewRender = false; + let previousCompletionStatus: boolean | null = null; - if (processedElements.has(block)) { - if (!existingDiv) { - const existingDivs = document.querySelectorAll(`.function-block[data-block-id="${blockId}"]`); - if (existingDivs.length > 0) { - existingDiv = existingDivs[0]; - renderedFunctionBlocks.set(blockId, existingDiv); - } else { - processedElements.delete(block); + // Track retry attempts for this block + const retryCount = (window as any)._stalledStreamRetryCount?.get(blockId) || 0; + const maxRetries = CONFIG.maxRetryAttempts; + + // Enhanced error recovery - check if we're in a retry situation + const isRetry = retryCount > 0; + if (isRetry) { + console.debug(`Retry attempt ${retryCount}/${maxRetries} for block ${blockId}`); + } + + // If this is a retry and we've exceeded max attempts, mark as permanently failed + if (isRetry && retryCount >= maxRetries) { + console.warn(`Max retry attempts (${maxRetries}) reached for block ${blockId}`); + block.classList.add('function-permanently-failed'); + return false; + } + + if (processedElements.has(block)) { + if (!existingDiv) { + const existingDivs = document.querySelectorAll(`.function-block[data-block-id="${blockId}"]`); + if (existingDivs.length > 0) { + existingDiv = existingDivs[0]; + renderedFunctionBlocks.set(blockId, existingDiv); + } else { + processedElements.delete(block); + } } } - } - if (!existingDiv) { - isNewRender = true; - if (!processedElements.has(block)) { - processedElements.add(block); - block.setAttribute('data-block-id', blockId); + if (!existingDiv) { + isNewRender = true; + if (!processedElements.has(block)) { + processedElements.add(block); + block.setAttribute('data-block-id', blockId); + } + } else { + previousCompletionStatus = !existingDiv.classList.contains('function-loading'); } - } else { - previousCompletionStatus = !existingDiv.classList.contains('function-loading'); - } - const rawContent = block.textContent?.trim() || ''; - const { tag, content } = extractLanguageTag(rawContent); + const rawContent = block.textContent?.trim() || ''; + const { tag, content } = extractLanguageTag(rawContent); - // CRITICAL: Use the existing div if available for streaming updates, or create a new one - const blockDiv = existingDiv || document.createElement('div'); + // CRITICAL: Use the existing div if available for streaming updates, or create a new one + const blockDiv = existingDiv || document.createElement('div'); - // Only update these properties on a new render, not during streaming updates - if (isNewRender) { - blockDiv.className = 'function-block'; - blockDiv.setAttribute('data-block-id', blockId); + // Only update these properties on a new render, not during streaming updates + if (isNewRender) { + blockDiv.className = 'function-block'; + blockDiv.setAttribute('data-block-id', blockId); - // Apply theme class based on current theme - applyThemeClass(blockDiv); + // Apply theme class based on current theme + applyThemeClass(blockDiv); - // Register this block - renderedFunctionBlocks.set(blockId, blockDiv); - } + // Register this block + renderedFunctionBlocks.set(blockId, blockDiv); + } - // Handle state transitions when block completion status changes - if (!isNewRender) { - const justCompleted = previousCompletionStatus === false && functionInfo.isComplete; - const justBecameIncomplete = previousCompletionStatus === true && !functionInfo.isComplete; + // Handle state transitions when block completion status changes + if (!isNewRender) { + const justCompleted = previousCompletionStatus === false && functionInfo.isComplete; + const justBecameIncomplete = previousCompletionStatus === true && !functionInfo.isComplete; - if (justCompleted) { - // Update UI state when transitioning from loading to complete - blockDiv.classList.remove('function-loading'); - blockDiv.classList.add('function-complete'); + if (justCompleted) { + // Update UI state when transitioning from loading to complete + blockDiv.classList.remove('function-loading'); + blockDiv.classList.add('function-complete'); - // Remove spinner if exists - const spinner = blockDiv.querySelector('.spinner'); - if (spinner) { - spinner.remove(); + // Remove spinner if exists + const spinner = blockDiv.querySelector('.spinner'); + if (spinner) { + spinner.remove(); + } + } else if (justBecameIncomplete) { + // Update UI state when transitioning from complete to loading + blockDiv.classList.remove('function-complete'); + blockDiv.classList.add('function-loading'); + } + } else { + // Only add loading state for new renders if not pre-existing incomplete + if (!functionInfo.isComplete && !isPreExistingIncomplete) { + blockDiv.classList.add('function-loading'); } - } else if (justBecameIncomplete) { - // Update UI state when transitioning from complete to loading - blockDiv.classList.remove('function-complete'); - blockDiv.classList.add('function-loading'); - } - } else { - // Only add loading state for new renders if not pre-existing incomplete - if (!functionInfo.isComplete && !isPreExistingIncomplete) { - blockDiv.classList.add('function-loading'); - } - // Add language tag if needed for new renders - if (tag || functionInfo.languageTag) { - const langTag = document.createElement('div'); - langTag.className = 'language-tag'; - langTag.textContent = tag || functionInfo.languageTag; - blockDiv.appendChild(langTag); + // Add language tag if needed for new renders + if (tag || functionInfo.languageTag) { + const langTag = document.createElement('div'); + langTag.className = 'language-tag'; + langTag.textContent = tag || functionInfo.languageTag; + blockDiv.appendChild(langTag); + } } - } - // Extract function name from the raw content - // Use regex to extract function name directly from content as a fallback for functionInfo - const invokeMatch = content.match(//i); - const functionName = invokeMatch ? invokeMatch[1] : 'function'; - const callId = invokeMatch && invokeMatch[2] ? invokeMatch[2] : blockId; - - // Handle function name creation or update - let functionNameElement = blockDiv.querySelector('.function-name'); - - if (!functionNameElement) { - // Create function name if not exists (new render) - functionNameElement = document.createElement('div'); - functionNameElement.className = 'function-name'; - - const functionNameText = document.createElement('span'); - functionNameText.className = 'function-name-text'; - functionNameText.textContent = functionName; - functionNameElement.appendChild(functionNameText); - - // Add call ID to the function name element (positioned top right via CSS) - if (callId) { - const callIdElement = document.createElement('span'); - callIdElement.className = 'call-id'; - callIdElement.textContent = callId; - functionNameElement.appendChild(callIdElement); - } + // Extract function name from the raw content + // Use regex to extract function name directly from content as a fallback for functionInfo + const invokeMatch = content.match(//i); + const functionName = invokeMatch ? invokeMatch[1] : 'function'; + const callId = invokeMatch && invokeMatch[2] ? invokeMatch[2] : blockId; + + // Handle function name creation or update + let functionNameElement = blockDiv.querySelector('.function-name'); + + if (!functionNameElement) { + // Create function name if not exists (new render) + functionNameElement = document.createElement('div'); + functionNameElement.className = 'function-name'; + + const functionNameText = document.createElement('span'); + functionNameText.className = 'function-name-text'; + functionNameText.textContent = functionName; + functionNameElement.appendChild(functionNameText); + + // Add call ID to the function name element (positioned top right via CSS) + if (callId) { + const callIdElement = document.createElement('span'); + callIdElement.className = 'call-id'; + callIdElement.textContent = callId; + functionNameElement.appendChild(callIdElement); + } - // If function is not complete and not a pre-existing incomplete block, add spinner - if (!functionInfo.isComplete && !isPreExistingIncomplete) { - const spinner = document.createElement('div'); - spinner.className = 'spinner'; - functionNameElement.appendChild(spinner); - } + // If function is not complete and not a pre-existing incomplete block, add spinner + if (!functionInfo.isComplete && !isPreExistingIncomplete) { + const spinner = document.createElement('div'); + spinner.className = 'spinner'; + functionNameElement.appendChild(spinner); + } - blockDiv.appendChild(functionNameElement); - } else { - // Update existing function name (streaming update) - const nameText = functionNameElement.querySelector('.function-name-text'); - if (nameText && nameText.textContent !== functionName) { - nameText.textContent = functionName; - } + blockDiv.appendChild(functionNameElement); + } else { + // Update existing function name (streaming update) + const nameText = functionNameElement.querySelector('.function-name-text'); + if (nameText && nameText.textContent !== functionName) { + nameText.textContent = functionName; + } - // Update call ID if needed - const callIdElement = functionNameElement.querySelector('.call-id'); - if (callId) { - if (callIdElement) { - if (callIdElement.textContent !== callId) { - callIdElement.textContent = callId; + // Update call ID if needed + const callIdElement = functionNameElement.querySelector('.call-id'); + if (callId) { + if (callIdElement) { + if (callIdElement.textContent !== callId) { + callIdElement.textContent = callId; + } + } else { + const newCallId = document.createElement('span'); + newCallId.className = 'call-id'; + newCallId.textContent = callId; + functionNameElement.appendChild(newCallId); } - } else { - const newCallId = document.createElement('span'); - newCallId.className = 'call-id'; - newCallId.textContent = callId; - functionNameElement.appendChild(newCallId); } } - } - // Get existing or create a new parameter container - let paramsContainer = blockDiv.querySelector('.function-params'); - - if (!paramsContainer) { - // Create parameter container if it doesn't exist - paramsContainer = document.createElement('div'); - paramsContainer.className = 'function-params'; - paramsContainer.style.display = 'flex'; - paramsContainer.style.flexDirection = 'column'; - paramsContainer.style.gap = '4px'; - paramsContainer.style.width = '100%'; - blockDiv.appendChild(paramsContainer); - } - - // --- START: Incremental Parameter Parsing and Rendering --- - const partialParameters: Record = {}; - const paramStartRegex = /]*>/gs; - let match; - while ((match = paramStartRegex.exec(rawContent)) !== null) { - const paramName = match[1]; - const startIndex = match.index + match[0].length; - const endTag = ''; - const endTagIndex = rawContent.indexOf(endTag, startIndex); - - let extractedValue = ''; - // Determine if parameter is complete (has ending tag) or still streaming - const isParamStreaming = endTagIndex === -1; - if (!isParamStreaming) { - // Full parameter content available (within the current rawContent) - extractedValue = rawContent.substring(startIndex, endTagIndex); - } else { - // Partial parameter content (streaming) - extractedValue = rawContent.substring(startIndex); + // Get existing or create a new parameter container + let paramsContainer = blockDiv.querySelector('.function-params'); + + if (!paramsContainer) { + // Create parameter container if it doesn't exist + paramsContainer = document.createElement('div'); + paramsContainer.className = 'function-params'; + paramsContainer.style.display = 'flex'; + paramsContainer.style.flexDirection = 'column'; + paramsContainer.style.gap = '4px'; + paramsContainer.style.width = '100%'; + blockDiv.appendChild(paramsContainer); } - // Handle potential CDATA within the extracted value - const cdataMatch = extractedValue.match(/)?$/s); - if (cdataMatch) { - // Use CDATA content, remove partial end tag if streaming - extractedValue = cdataMatch[1]; - } else { - // Trim only if not CDATA, as CDATA preserves whitespace - extractedValue = extractedValue.trim(); + // --- START: Incremental Parameter Parsing and Rendering --- + const partialParameters: Record = {}; + const paramStartRegex = /]*>/gs; + let match; + while ((match = paramStartRegex.exec(rawContent)) !== null) { + const paramName = match[1]; + + try { + const result = extractParameterValue(rawContent, match.index); + if (result !== null) { + partialParameters[paramName] = result.value; + createOrUpdateParamElement(paramsContainer!, paramName, result.value, blockId, isNewRender, result.isStreaming); + } + } catch (paramError) { + console.warn(`Error extracting parameter ${paramName}:`, paramError); + // Continue with next parameter instead of failing completely + continue; + } } + // --- END: Incremental Parameter Parsing and Rendering --- - partialParameters[paramName] = extractedValue; - - // Create or update the parameter - use the found/created params container - // If paramsContainer doesn't exist, this will still work by using document-level lookup - createOrUpdateParamElement(paramsContainer!, paramName, extractedValue, blockId, isNewRender, isParamStreaming); - } - // --- END: Incremental Parameter Parsing and Rendering --- - - // Extract *complete* parameters using the function from components.ts *only when needed* - let completeParameters: Record | null = null; - if (functionInfo.isComplete) { - completeParameters = extractFunctionParameters(rawContent); - } - - // Generate content signature *only* when complete - let contentSignature: string | null = null; - if (functionInfo.isComplete && completeParameters) { - contentSignature = generateContentSignature(functionName, completeParameters); - } - - // Only replace the original element with our render if this is a new render - if (isNewRender) { - if (block.parentNode) { - block.parentNode.insertBefore(blockDiv, block); - block.style.display = 'none'; - } else { - if (CONFIG.debug) console.warn('Function call block has no parent element, cannot insert rendered block'); - return false; + // Extract *complete* parameters using the function from components.ts *only when needed* + let completeParameters: Record | null = null; + if (functionInfo.isComplete) { + completeParameters = extractFunctionParameters(rawContent); } - } - // Create a button container if it doesn't exist - let buttonContainer = blockDiv.querySelector('.function-buttons'); - if (!buttonContainer) { - // Create a container for the buttons - buttonContainer = document.createElement('div'); - buttonContainer.className = 'function-buttons'; - blockDiv.appendChild(buttonContainer); - - // Add spacing between parameters and buttons - const spacer = document.createElement('div'); - spacer.style.height = '8px'; - blockDiv.insertBefore(spacer, buttonContainer); - } - - // Add a raw XML toggle if the function is complete - if (functionInfo.isComplete && !blockDiv.querySelector('.raw-toggle')) { - // If we're using the button container, pass it instead of blockDiv - if (buttonContainer) { - addRawXmlToggle(buttonContainer, rawContent); - } else { - addRawXmlToggle(blockDiv, rawContent); + // Generate content signature *only* when complete + let contentSignature: string | null = null; + if (functionInfo.isComplete && completeParameters) { + contentSignature = generateContentSignature(functionName, completeParameters); } - } - // Add execute button if the function is complete and not already added - if (functionInfo.isComplete && !blockDiv.querySelector('.execute-button')) { - // Ensure completeParameters is available before adding button/setting up auto-exec - if (!completeParameters) { - completeParameters = extractFunctionParameters(rawContent); - } - // If we're using the button container, pass it instead of blockDiv - if (buttonContainer) { - addExecuteButton(buttonContainer, rawContent); // rawContent has full data here - } else { - addExecuteButton(blockDiv, rawContent); + // Only replace the original element with our render if this is a new render + if (isNewRender) { + if (block.parentNode) { + block.parentNode.insertBefore(blockDiv, block); + block.style.display = 'none'; + } else { + if (CONFIG.debug) console.warn('Function call block has no parent element, cannot insert rendered block'); + return false; + } } - // Setup auto-execution with proper wait time for DOM stabilization - // This ensures we wait until the function block is fully rendered and stable - const autoExecuteEnabled = (window as any).toggleState?.autoExecute === true; + // Create a button container if it doesn't exist + let buttonContainer = blockDiv.querySelector('.function-buttons'); + if (!buttonContainer) { + // Create a container for the buttons + buttonContainer = document.createElement('div'); + buttonContainer.className = 'function-buttons'; + blockDiv.appendChild(buttonContainer); + + // Add spacing between parameters and buttons + const spacer = document.createElement('div'); + spacer.style.height = '8px'; + blockDiv.insertBefore(spacer, buttonContainer); + } - // Extract function information for execution tracking - const invokeMatch = content.match(//i); - const extractedCallId = invokeMatch && invokeMatch[2] ? invokeMatch[2] : blockId; - - // Check if the function has already been executed using the complete signature - if (contentSignature && !executionTracker.isFunctionExecuted(extractedCallId, contentSignature, functionName)) { - // Proceed with auto-execution setup - // STRICT CHECK #1: Is auto-execute enabled in UI settings? - if (autoExecuteEnabled !== true) { - console.debug(`Auto-execution disabled by user settings for block ${blockId} (${functionName})`); - return true; + // Add a raw XML toggle if the function is complete + if (functionInfo.isComplete && !blockDiv.querySelector('.raw-toggle')) { + // If we're using the button container, pass it instead of blockDiv + if (buttonContainer) { + addRawXmlToggle(buttonContainer, rawContent); + } else { + addRawXmlToggle(blockDiv, rawContent); } + } - // STRICT CHECK #2: Has this block already been processed for auto-execution? - if (executionTracker.isBlockExecuted(blockId) === true) { - console.debug(`Auto-execution skipped: Block ${blockId} (${functionName}) has already been processed`); - return true; + // Add execute button if the function is complete and not already added + if (functionInfo.isComplete && !blockDiv.querySelector('.execute-button')) { + // Ensure completeParameters is available before adding button/setting up auto-exec + if (!completeParameters) { + completeParameters = extractFunctionParameters(rawContent); + } + // If we're using the button container, pass it instead of blockDiv + if (buttonContainer) { + addExecuteButton(buttonContainer, rawContent); // rawContent has full data here + } else { + addExecuteButton(blockDiv, rawContent); } - // At this point, we've passed all checks and can proceed with auto-execution - // Immediately mark function as scheduled for execution to prevent race conditions - executionTracker.markFunctionExecuted(extractedCallId, contentSignature, functionName); - executionTracker.markBlockExecuted(blockId); - - console.debug(`Setting up auto-execution for block ${blockId} (${functionName})`); + // Setup auto-execution with proper wait time for DOM stabilization + // This ensures we wait until the function block is fully rendered and stable + const autoExecuteEnabled = (window as any).toggleState?.autoExecute === true; + + // Extract function information for execution tracking + const invokeMatch = content.match(//i); + const extractedCallId = invokeMatch && invokeMatch[2] ? invokeMatch[2] : blockId; + + // Check if the function has already been executed using the complete signature + if (contentSignature && !executionTracker.isFunctionExecuted(extractedCallId, contentSignature, functionName)) { + // Proceed with auto-execution setup + // STRICT CHECK #1: Is auto-execute enabled in UI settings? + if (autoExecuteEnabled !== true) { + console.debug(`Auto-execution disabled by user settings for block ${blockId} (${functionName})`); + return true; + } - // Store function details for use in the retry mechanism (use completeParameters) - const functionDetails = { - functionName, - callId: extractedCallId, - contentSignature, - params: completeParameters || {}, // Ensure params is an object - }; - // Use a more robust retry mechanism with proper cleanup - const setupAutoExecution = () => { - const attempts = executionTracker.incrementAttempts(blockId); - - if (attempts > MAX_AUTO_EXECUTE_ATTEMPTS) { - console.debug(`Auto-execute: Giving up on block ${blockId} after ${attempts - 1} attempts`); - executionTracker.cleanupBlock(blockId); - return; + // STRICT CHECK #2: Has this block already been processed for auto-execution? + if (executionTracker.isBlockExecuted(blockId) === true) { + console.debug(`Auto-execution skipped: Block ${blockId} (${functionName}) has already been processed`); + return true; } - console.debug(`Auto-execute attempt ${attempts}/${MAX_AUTO_EXECUTE_ATTEMPTS} for block ${blockId}`); + // At this point, we've passed all checks and can proceed with auto-execution + // Immediately mark function as scheduled for execution to prevent race conditions + executionTracker.markFunctionExecuted(extractedCallId, contentSignature, functionName); + executionTracker.markBlockExecuted(blockId); + + console.debug(`Setting up auto-execution for block ${blockId} (${functionName})`); + + // Store function details for use in the retry mechanism (use completeParameters) + const functionDetails = { + functionName, + callId: extractedCallId, + contentSignature, + params: completeParameters || {}, // Ensure params is an object + }; + // Use a more robust retry mechanism with proper cleanup + const setupAutoExecution = () => { + const attempts = executionTracker.incrementAttempts(blockId); + + if (attempts > MAX_AUTO_EXECUTE_ATTEMPTS) { + console.debug(`Auto-execute: Giving up on block ${blockId} after ${attempts - 1} attempts`); + executionTracker.cleanupBlock(blockId); + return; + } - setTimeout(() => { - let currentBlock = document.querySelector(`.function-block[data-block-id="${blockId}"]`); - - if (!currentBlock) { - console.debug(`Auto-execute: Original block ${blockId} not found. Searching for replacement...`); - const potentialBlocks = document.querySelectorAll('.function-block'); - for (const block of potentialBlocks) { - const preElement = block.querySelector('pre'); - if (!preElement || !preElement.textContent) continue; // Skip if no pre element or content - - // Manually parse name and callId from content here - const content = preElement.textContent; - const invokeRegex = //i; - const match = content.match(invokeRegex); - - // Check if the parsed details match the function we are trying to execute - if (match && match[1] === functionDetails.functionName && match[2] === functionDetails.callId) { - const replacementBlockId = block.getAttribute('data-block-id'); - // Use the imported getPreviousExecution which checks storage - const alreadyExecuted = getPreviousExecution( - functionDetails.functionName, - functionDetails.callId, - functionDetails.contentSignature, - ); - // Removed isBeingProcessed check - - if (!alreadyExecuted) { - console.debug( - `Auto-execute: Found potential replacement block ${replacementBlockId || 'unknown ID'}. Attempting execution.`, + console.debug(`Auto-execute attempt ${attempts}/${MAX_AUTO_EXECUTE_ATTEMPTS} for block ${blockId}`); + + setTimeout(() => { + let currentBlock = document.querySelector(`.function-block[data-block-id="${blockId}"]`); + + if (!currentBlock) { + console.debug(`Auto-execute: Original block ${blockId} not found. Searching for replacement...`); + const potentialBlocks = document.querySelectorAll('.function-block'); + for (const block of potentialBlocks) { + const preElement = block.querySelector('pre'); + if (!preElement || !preElement.textContent) continue; // Skip if no pre element or content + + // Manually parse name and callId from content here + const content = preElement.textContent; + const invokeRegex = //i; + const match = content.match(invokeRegex); + + // Check if the parsed details match the function we are trying to execute + if (match && match[1] === functionDetails.functionName && match[2] === functionDetails.callId) { + const replacementBlockId = block.getAttribute('data-block-id'); + // Use the imported getPreviousExecution which checks storage + const alreadyExecuted = getPreviousExecution( + functionDetails.functionName, + functionDetails.callId, + functionDetails.contentSignature, ); - currentBlock = block; // Target the replacement block - break; - } else { - console.debug( - `Auto-execute: Replacement block ${replacementBlockId || 'unknown ID'} skipped (already executed).`, - ); // Updated log message + // Removed isBeingProcessed check + + if (!alreadyExecuted) { + console.debug( + `Auto-execute: Found potential replacement block ${replacementBlockId || 'unknown ID'}. Attempting execution.`, + ); + currentBlock = block; // Target the replacement block + break; + } else { + console.debug( + `Auto-execute: Replacement block ${replacementBlockId || 'unknown ID'} skipped (already executed).`, + ); // Updated log message + } } } } - } - if (!currentBlock) { - console.debug( - `Auto-execute: Block ${blockId} (and suitable replacement) not found in DOM (attempt ${attempts}/${MAX_AUTO_EXECUTE_ATTEMPTS})`, - ); - if (attempts < MAX_AUTO_EXECUTE_ATTEMPTS) { - setTimeout(setupAutoExecution, 500); // Retry - } else { - console.debug(`Auto-execute: Giving up on block ${blockId} - not found in DOM`); - executionTracker.cleanupBlock(blockId); + if (!currentBlock) { + console.debug( + `Auto-execute: Block ${blockId} (and suitable replacement) not found in DOM (attempt ${attempts}/${MAX_AUTO_EXECUTE_ATTEMPTS})`, + ); + if (attempts < MAX_AUTO_EXECUTE_ATTEMPTS) { + setTimeout(setupAutoExecution, 500); // Retry + } else { + console.debug(`Auto-execute: Giving up on block ${blockId} - not found in DOM`); + executionTracker.cleanupBlock(blockId); + } + return; } - return; - } - // --- START: Added final check against persistent storage --- - // Use the imported getPreviousExecution which checks storage - const finalCheckExecuted = getPreviousExecution( - functionDetails.functionName, - functionDetails.callId, - functionDetails.contentSignature, - ); - if (finalCheckExecuted) { - console.debug( - `Auto-execute: Function ${functionDetails.functionName} (callId: ${functionDetails.callId}) was found in execution history right before click. Skipping.`, + // --- START: Added final check against persistent storage --- + // Use the imported getPreviousExecution which checks storage + const finalCheckExecuted = getPreviousExecution( + functionDetails.functionName, + functionDetails.callId, + functionDetails.contentSignature, ); - executionTracker.cleanupBlock(blockId); // Clean up tracker - return; - } - // --- END: Added final check against persistent storage --- - - const executeButton = currentBlock.querySelector('.execute-button'); - if (executeButton) { - console.debug( - `Auto-execute: Executing function in block ${currentBlock.getAttribute('data-block-id') || blockId} (${functionDetails.functionName}) after DOM stabilization`, - ); - executeButton.click(); - // NOTE: Execution marking should happen *after* click success, likely handled by the execute button's click handler via functionHistory/storage. - executionTracker.cleanupBlock(blockId); // Clean up tracker for *this* attempt - } else { - console.debug( - `Auto-execute: Execute button not found in block ${currentBlock.getAttribute('data-block-id') || blockId} (attempt ${attempts}/${MAX_AUTO_EXECUTE_ATTEMPTS})`, - ); - if (attempts < MAX_AUTO_EXECUTE_ATTEMPTS) { - setTimeout(setupAutoExecution, 500); // Retry + if (finalCheckExecuted) { + console.debug( + `Auto-execute: Function ${functionDetails.functionName} (callId: ${functionDetails.callId}) was found in execution history right before click. Skipping.`, + ); + executionTracker.cleanupBlock(blockId); // Clean up tracker + return; + } + // --- END: Added final check against persistent storage --- + + const executeButton = currentBlock.querySelector('.execute-button'); + if (executeButton) { + console.debug( + `Auto-execute: Executing function in block ${currentBlock.getAttribute('data-block-id') || blockId} (${functionDetails.functionName}) after DOM stabilization`, + ); + executeButton.click(); + // NOTE: Execution marking should happen *after* click success, likely handled by the execute button's click handler via functionHistory/storage. + executionTracker.cleanupBlock(blockId); // Clean up tracker for *this* attempt } else { - console.debug(`Auto-execute: Giving up on block ${blockId} - button not found`); - executionTracker.cleanupBlock(blockId); + console.debug( + `Auto-execute: Execute button not found in block ${currentBlock.getAttribute('data-block-id') || blockId} (attempt ${attempts}/${MAX_AUTO_EXECUTE_ATTEMPTS})`, + ); + if (attempts < MAX_AUTO_EXECUTE_ATTEMPTS) { + setTimeout(setupAutoExecution, 500); // Retry + } else { + console.debug(`Auto-execute: Giving up on block ${blockId} - button not found`); + executionTracker.cleanupBlock(blockId); + } } - } - }, 500); // Reduced initial wait to 500ms - }; + }, 500); // Reduced initial wait to 500ms + }; - setupAutoExecution(); + setupAutoExecution(); + } } - } - return true; + // Enhanced completion status handling + if (functionInfo.isComplete) { + block.classList.remove('function-loading', 'function-stalled'); + block.classList.add('function-complete'); + + // Clear retry count on successful completion + stalledStreamRetryCount.delete(blockId); + stalledStreams.delete(blockId); + + // Remove any stalled indicators + const stalledIndicator = block.querySelector(`.stalled-indicator[data-stalled-for="${blockId}"]`); + stalledIndicator?.remove(); + } else { + block.classList.add('function-loading'); + if (!isPreExistingIncomplete) { + // Update last activity timestamp + streamingLastUpdated.set(blockId, Date.now()); + } + } + + return true; + } catch (error) { + console.error('Error in renderFunctionCall:', error); + + // Enhanced error recovery + const blockId = block.getAttribute('data-block-id'); + if (blockId) { + const retryCount = (window as any)._stalledStreamRetryCount?.get(blockId) || 0; + if (retryCount < CONFIG.maxRetryAttempts) { + // Schedule a retry with exponential backoff + const delay = Math.min(CONFIG.retryDelay * Math.pow(2, retryCount), 30000); + setTimeout(() => { + console.debug(`Retrying render for block ${blockId} after error (attempt ${retryCount + 1})`); + (window as any)._stalledStreamRetryCount?.set(blockId, retryCount + 1); + renderFunctionCall(block, isProcessingRef); + }, delay); + } + } + + return false; + } }; /** @@ -754,3 +796,37 @@ export const createOrUpdateParamElement = ( } } }; + +/** + * Extract parameter value from raw content + */ +const extractParameterValue = (rawContent: string, matchIndex: number): { value: string; isStreaming: boolean } | null => { + const startTag = ''; + const endTag = ''; + const startIndex = rawContent.indexOf(startTag, matchIndex) + startTag.length; + const endTagIndex = rawContent.indexOf(endTag, startIndex); + + // Determine if parameter is complete (has ending tag) or still streaming + const isStreaming = endTagIndex === -1; + let extractedValue: string; + + if (!isStreaming) { + // Full parameter content available + extractedValue = rawContent.substring(startIndex, endTagIndex); + } else { + // Partial parameter content (streaming) + extractedValue = rawContent.substring(startIndex); + } + + // Handle potential CDATA within the extracted value + const cdataMatch = extractedValue.match(/)?$/s); + if (cdataMatch) { + // Use CDATA content, remove partial end tag if streaming + extractedValue = cdataMatch[1]; + } else { + // Trim only if not CDATA, as CDATA preserves whitespace + extractedValue = extractedValue.trim(); + } + + return { value: extractedValue, isStreaming }; +}; diff --git a/pages/content/src/render_prescript/src/renderer/styles.ts b/pages/content/src/render_prescript/src/renderer/styles.ts index 2fe30b78..5873817c 100644 --- a/pages/content/src/render_prescript/src/renderer/styles.ts +++ b/pages/content/src/render_prescript/src/renderer/styles.ts @@ -730,3 +730,134 @@ export const styles = ` background: #aecbfa; } `; + +export const injectStyles = (): void => { + const style = document.createElement('style'); + style.textContent = ` + .function-block { + position: relative; + margin: 1em 0; + padding: 1em; + border-radius: 0.5em; + background-color: var(--function-block-bg, rgba(0, 0, 0, 0.05)); + font-family: monospace; + white-space: pre-wrap; + word-break: break-word; + } + + .function-block.function-loading { + opacity: 0.8; + } + + .function-block.function-stalled { + border: 1px solid var(--warning-border-color, #f59e0b); + } + + .function-name { + font-weight: bold; + margin-bottom: 0.5em; + display: flex; + align-items: center; + gap: 0.5em; + } + + .function-spinner { + display: inline-block; + width: 1em; + height: 1em; + border: 2px solid var(--spinner-color, #4b5563); + border-top-color: transparent; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-left: 0.5em; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + .param-row { + display: flex; + gap: 1em; + margin: 0.25em 0; + align-items: flex-start; + } + + .param-name { + font-weight: bold; + min-width: 100px; + } + + .param-value { + flex: 1; + overflow-x: auto; + } + + .stalled-indicator { + position: absolute; + top: 0.5em; + right: 0.5em; + padding: 0.5em; + border-radius: 0.25em; + background-color: var(--warning-bg-color, rgba(245, 158, 11, 0.1)); + color: var(--warning-color, #f59e0b); + font-size: 0.875rem; + display: flex; + align-items: center; + gap: 0.5em; + z-index: 10; + } + + .stalled-message { + display: flex; + align-items: center; + gap: 0.5em; + } + + .stalled-retry-button { + margin-left: 1em; + padding: 0.25em 0.5em; + border-radius: 0.25em; + background-color: var(--button-bg-color, #4b5563); + color: var(--button-text-color, white); + border: none; + cursor: pointer; + font-size: 0.75rem; + } + + .stalled-retry-button:hover { + background-color: var(--button-hover-bg-color, #374151); + } + + .render-warning { + margin-top: 0.5em; + padding: 0.5em; + border-radius: 0.25em; + background-color: var(--warning-bg-color, rgba(245, 158, 11, 0.1)); + color: var(--warning-color, #f59e0b); + font-size: 0.875rem; + display: flex; + align-items: center; + gap: 0.5em; + } + + .render-warning::before { + content: "⚠️"; + font-size: 1em; + } + + /* Dark mode support */ + @media (prefers-color-scheme: dark) { + .function-block { + background-color: var(--function-block-bg-dark, rgba(255, 255, 255, 0.05)); + } + + .stalled-indicator, + .render-warning { + background-color: var(--warning-bg-color-dark, rgba(245, 158, 11, 0.2)); + } + } + `; + + document.head.appendChild(style); +};