Commit fefd25cb authored by Hoanganhvu123's avatar Hoanganhvu123

feat: n8n MCP copilot extension with sidebar integration

parent fc8d66a5
const fs = require('fs');
const src = 'c:\\canifa-idea\\n8n\\MCP-SuperAssistant\\pages\\content\\src\\render_prescript\\src';
const dest = 'c:\\canifa-idea\\n8n\\anything-copilot\\src\\content\\render_prescript';
fs.mkdirSync(dest, { recursive: true });
fs.cpSync(src, dest, { recursive: true });
console.log('Copy complete!');
{
"mcpServers": {
"n8n-mcp": {
"command": "npx",
"args": [
"-y",
"n8n-mcp@latest"
],
"env": {
"MCP_LOG_LEVEL": "info",
"NODE_ENV": "production"
}
}
}
}
......@@ -4,7 +4,7 @@ import {
type ParseDocOptions,
ContentScriptId,
} from "@/types"
import { waitMessage, tabUpdated, getLocal, getPipWindow } from "@/utils/ext"
import { waitMessage, tabUpdated, getLocal, getPipWindow, getIsEdge } from "@/utils/ext"
import {
createOffscreenDocument,
offscreenHtmlPath,
......@@ -17,8 +17,12 @@ import {
} from "./sidebar"
import config from "@/assets/config.json"
import { allFrameScript, contentMainScript } from "@/manifest"
import { getIsEdge } from "@/utils/ext"
import { messageInvoke } from "@/utils/invoke"
import { getMcpService } from "@/services/McpService"
const mcpService = getMcpService()
// Ensure MCP connects right away in background
mcpService.connect().catch(console.error)
type Config = typeof config
......@@ -33,7 +37,14 @@ const contentScript = {
runAt: placeholder.run_at as "document_start",
} satisfies chrome.scripting.RegisteredContentScript
chrome.scripting.registerContentScripts([contentScript, contentMainScript])
// Unregister first to avoid "already registered" errors on service worker restart
chrome.scripting
.unregisterContentScripts({ ids: [contentScript.id, contentMainScript.id] })
.catch(() => {}) // Ignore if not registered yet
.then(() =>
chrome.scripting.registerContentScripts([contentScript, contentMainScript])
)
.catch((err) => console.warn("[bg] registerContentScripts failed:", err))
chrome.runtime.onMessage.addListener(handleMessage)
chrome.commands.onCommand.addListener(handleCommand)
chrome.runtime.onStartup.addListener(() => {
......@@ -111,6 +122,12 @@ messageInvoke
.register(ServiceFunc.getMyTab, () =>
chrome.tabs.get(currentSender!.tab!.id!)
)
.register(ServiceFunc.mcpGetTools, async () => {
return await mcpService.fetchTools()
})
.register(ServiceFunc.mcpExecuteTool, async ({ name, args }: { name: string; args: any }) => {
return await mcpService.callTool(name, args)
})
async function handleInvokeRequest(
message: any,
......
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="currentColor"
>
<!-- n8n-style workflow icon -->
<circle cx="5" cy="12" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<circle cx="12" cy="6" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<circle cx="12" cy="18" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5" />
<circle cx="19" cy="12" r="2.5" fill="currentColor" opacity="0.7" />
<line x1="7.3" y1="11" x2="9.7" y2="7" stroke="currentColor" stroke-width="1.3" />
<line x1="7.3" y1="13" x2="9.7" y2="17" stroke="currentColor" stroke-width="1.3" />
<line x1="14.3" y1="7" x2="16.7" y2="11" stroke="currentColor" stroke-width="1.3" />
<line x1="14.3" y1="17" x2="16.7" y2="13" stroke="currentColor" stroke-width="1.3" />
</svg>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
const n8nApiUrl = ref("")
const n8nApiKey = ref("")
const mcpAuthToken = ref("")
const proxyUrl = ref("http://localhost:3000")
const saving = ref(false)
const saved = ref(false)
const showKey = ref(false)
const connectionStatus = ref<"idle" | "testing" | "ok" | "error">("idle")
const connectionError = ref("")
const STORAGE_KEY = "mcp_n8n_config"
onMounted(async () => {
const data = await chrome.storage.local.get(STORAGE_KEY)
if (data[STORAGE_KEY]) {
n8nApiUrl.value = data[STORAGE_KEY].n8nApiUrl || ""
n8nApiKey.value = data[STORAGE_KEY].n8nApiKey || ""
mcpAuthToken.value = data[STORAGE_KEY].mcpAuthToken || ""
proxyUrl.value = data[STORAGE_KEY].proxyUrl || "http://localhost:3000"
}
})
async function save() {
saving.value = true
saved.value = false
try {
await chrome.storage.local.set({
[STORAGE_KEY]: {
n8nApiUrl: n8nApiUrl.value.replace(/\/+$/, ""),
n8nApiKey: n8nApiKey.value,
mcpAuthToken: mcpAuthToken.value,
proxyUrl: proxyUrl.value.replace(/\/+$/, ""),
},
})
saved.value = true
setTimeout(() => (saved.value = false), 2500)
} finally {
saving.value = false
}
}
async function testConnection() {
connectionStatus.value = "testing"
connectionError.value = ""
const url = proxyUrl.value.replace(/\/+$/, "")
try {
const headers: Record<string, string> = {}
if (mcpAuthToken.value) {
headers["Authorization"] = `Bearer ${mcpAuthToken.value}`
}
const res = await fetch(`${url}/health`, {
method: "GET",
headers,
signal: AbortSignal.timeout(5000),
})
if (res.ok) {
connectionStatus.value = "ok"
} else {
connectionStatus.value = "error"
connectionError.value = `HTTP ${res.status}`
}
} catch (e: any) {
connectionStatus.value = "error"
connectionError.value = e?.message || "Connection failed"
}
}
const statusDot: Record<string, string> = {
idle: "bg-zinc-500",
testing: "bg-amber-400 animate-pulse",
ok: "bg-emerald-500",
error: "bg-red-500",
}
const statusText: Record<string, string> = {
idle: "Chưa kiểm tra",
testing: "Đang kiểm tra...",
ok: "Đã kết nối ✓",
error: "Lỗi kết nối",
}
</script>
<template>
<div class="mcp-settings">
<!-- Header -->
<div class="settings-header">
<div class="header-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20a8 8 0 1 0 0-16 8 8 0 0 0 0 16Z" />
<path d="M12 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" />
<path d="M12 2v2" /><path d="M12 22v-2" />
<path d="m17 20.66-1-1.73" /><path d="M11 10.27 7 3.34" />
<path d="m20.66 17-1.73-1" /><path d="m3.34 7 1.73 1" />
<path d="M14 12h8" /><path d="M2 12h2" />
<path d="m20.66 7-1.73 1" /><path d="m3.34 17 1.73-1" />
<path d="m17 3.34-1 1.73" /><path d="m11 13.73-4 6.93" />
</svg>
</div>
<div>
<h3 class="header-title">n8n MCP Configuration</h3>
<p class="header-desc">Kết nối extension với n8n instance của bạn</p>
</div>
</div>
<!-- Connection Status -->
<div class="status-bar">
<div class="status-indicator">
<span :class="['status-dot', statusDot[connectionStatus]]"></span>
<span class="status-label">{{ statusText[connectionStatus] }}</span>
</div>
<button class="btn-test" @click="testConnection" :disabled="connectionStatus === 'testing'">
Test
</button>
</div>
<p v-if="connectionError" class="error-text">{{ connectionError }}</p>
<!-- Form -->
<div class="form-group">
<label class="form-label">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
n8n Instance URL
</label>
<input v-model="n8nApiUrl" class="form-input" type="url"
placeholder="http://localhost:5678" spellcheck="false" />
<span class="form-hint">URL instance n8n của bạn (không cần /api/v1)</span>
</div>
<div class="form-group">
<label class="form-label">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
n8n API Key
</label>
<div class="input-group">
<input v-model="n8nApiKey" class="form-input pr-10"
:type="showKey ? 'text' : 'password'"
placeholder="n8n-api-key-xxxx" spellcheck="false" />
<button class="btn-toggle-key" @click="showKey = !showKey" :title="showKey ? 'Ẩn' : 'Hiện'">
<svg v-if="!showKey" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/>
<path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/>
<path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/>
<line x1="2" x2="22" y1="2" y2="22"/>
</svg>
</button>
</div>
<span class="form-hint">Settings → API → Create API Key trong n8n</span>
</div>
<div class="form-group">
<label class="form-label">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="8" x="2" y="2" rx="2" ry="2"/>
<rect width="20" height="8" x="2" y="14" rx="2" ry="2"/>
<line x1="6" x2="6.01" y1="6" y2="6"/>
<line x1="6" x2="6.01" y1="18" y2="18"/>
</svg>
MCP Server URL
</label>
<input v-model="proxyUrl" class="form-input" type="url"
placeholder="http://localhost:3000" spellcheck="false" />
<span class="form-hint">URL của n8n-mcp server (Ex: http://vps-ip:3000)</span>
</div>
<div class="form-group">
<label class="form-label">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"></path>
</svg>
MCP Auth Token
</label>
<input v-model="mcpAuthToken" class="form-input" type="password"
placeholder="Nhập nếu server yêu cầu" spellcheck="false" />
<span class="form-hint">Token bảo vệ n8n-mcp của bạn (tuỳ chọn)</span>
</div>
<!-- Actions -->
<div class="form-actions">
<button class="btn-save" @click="save" :disabled="saving || !n8nApiUrl || !n8nApiKey">
<template v-if="saving">Đang lưu...</template>
<template v-else-if="saved">Đã lưu ✓</template>
<template v-else>Lưu cấu hình</template>
</button>
</div>
<!-- Info Card -->
<div class="info-card">
<p class="info-title">📋 Hướng dẫn nhanh</p>
<ol class="info-list">
<li>Mở n8n instance → <strong>Settings → API</strong></li>
<li>Click <strong>"Create API Key"</strong> và copy key</li>
<li>Chạy <code>npx n8n-mcp</code> trên máy tính/server của bạn</li>
<li>Nhập các thông tin trên, bấm <strong>Lưu cấu hình</strong></li>
<li>Bấm <strong>Test</strong> để kiểm tra kết nối</li>
</ol>
</div>
</div>
</template>
<style scoped>
.mcp-settings {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: var(--color-text, #e4e4e7);
}
.settings-header {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.header-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 10px;
background: linear-gradient(135deg, #ff6d5a 0%, #ee3f3f 100%);
color: white;
flex-shrink: 0;
}
.header-title {
font-size: 15px;
font-weight: 600;
margin: 0;
color: var(--color-heading, #fafafa);
}
.header-desc {
font-size: 12px;
margin: 2px 0 0;
opacity: 0.6;
}
/* Status bar */
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-radius: 8px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.06);
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-label {
font-size: 12px;
opacity: 0.8;
}
.btn-test {
font-size: 11px;
padding: 4px 12px;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
background: rgba(255,255,255,0.06);
color: inherit;
cursor: pointer;
transition: all 0.15s;
}
.btn-test:hover { background: rgba(255,255,255,0.1); }
.btn-test:disabled { opacity: 0.4; cursor: not-allowed; }
.error-text {
font-size: 12px;
color: #ef4444;
margin: -8px 0 0;
padding-left: 4px;
}
/* Form */
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
opacity: 0.8;
}
.form-input {
width: 100%;
padding: 8px 12px;
font-size: 13px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
background: rgba(255,255,255,0.04);
color: inherit;
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
font-family: 'SF Mono', Monaco, Consolas, monospace;
}
.form-input:focus {
border-color: #ff6d5a;
}
.form-input::placeholder {
opacity: 0.35;
}
.pr-10 { padding-right: 38px; }
.input-group {
position: relative;
}
.btn-toggle-key {
position: absolute;
right: 4px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
padding: 6px;
color: inherit;
opacity: 0.5;
cursor: pointer;
}
.btn-toggle-key:hover { opacity: 0.9; }
.form-hint {
font-size: 11px;
opacity: 0.4;
padding-left: 2px;
}
/* Actions */
.form-actions {
padding-top: 4px;
}
.btn-save {
width: 100%;
padding: 10px;
font-size: 13px;
font-weight: 600;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #ff6d5a 0%, #ee3f3f 100%);
color: white;
cursor: pointer;
transition: all 0.2s;
}
.btn-save:hover { opacity: 0.9; transform: translateY(-1px); }
.btn-save:active { transform: translateY(0); }
.btn-save:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
/* Info */
.info-card {
padding: 12px 14px;
border-radius: 8px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
}
.info-title {
font-size: 12px;
font-weight: 600;
margin: 0 0 8px;
}
.info-list {
font-size: 11px;
margin: 0;
padding-left: 18px;
line-height: 1.7;
opacity: 0.65;
}
.info-list code {
padding: 1px 5px;
border-radius: 4px;
background: rgba(255,255,255,0.08);
font-family: 'SF Mono', Monaco, Consolas, monospace;
font-size: 10px;
}
</style>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from "vue"
import { getMcpService, type McpTool, type McpExecutionResult, type McpConnectionStatus } from "@/services/McpService"
import ToolCard from "./ToolCard.vue"
import ExecutionLog from "./ExecutionLog.vue"
const mcpService = getMcpService()
const connectionStatus = ref<McpConnectionStatus>("disconnected")
const tools = ref<McpTool[]>([])
const executions = ref<McpExecutionResult[]>([])
const serverUrl = ref("http://localhost:3006")
const searchQuery = ref("")
const activeTab = ref<"tools" | "log" | "quick">("quick")
const isConnecting = ref(false)
const filteredTools = computed(() => {
if (!searchQuery.value) return tools.value
const q = searchQuery.value.toLowerCase()
return tools.value.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q)
)
})
const statusColor = computed(() => {
switch (connectionStatus.value) {
case "connected": return "#22c55e"
case "connecting": return "#f59e0b"
case "error": return "#ef4444"
default: return "#6b7280"
}
})
const statusText = computed(() => {
switch (connectionStatus.value) {
case "connected": return "Connected"
case "connecting": return "Connecting..."
case "error": return "Error"
default: return "Disconnected"
}
})
let unsubStatus: (() => void) | null = null
let unsubTools: (() => void) | null = null
let unsubExec: (() => void) | null = null
onMounted(() => {
connectionStatus.value = mcpService.getStatus()
tools.value = mcpService.getTools()
executions.value = mcpService.getExecutions()
unsubStatus = mcpService.onStatusChange((s) => {
connectionStatus.value = s
isConnecting.value = s === "connecting"
})
unsubTools = mcpService.onToolsUpdate((t) => {
tools.value = [...t]
})
unsubExec = mcpService.onExecution(() => {
executions.value = [...mcpService.getExecutions()]
})
})
onUnmounted(() => {
unsubStatus?.()
unsubTools?.()
unsubExec?.()
})
async function toggleConnection() {
if (connectionStatus.value === "connected") {
await mcpService.disconnect()
} else {
isConnecting.value = true
await mcpService.connect()
}
}
async function handleToolExecute(toolName: string, args: Record<string, any>) {
activeTab.value = "log"
await mcpService.callTool(toolName, args)
}
function clearLog() {
mcpService.clearExecutions()
executions.value = []
}
// Quick Actions
const quickActions = [
{
icon: "🔍",
label: "Search Nodes",
tool: "search_nodes",
defaultArgs: { query: "webhook", limit: 5 },
},
{
icon: "📋",
label: "List Workflows",
tool: "n8n_list_workflows",
defaultArgs: {},
},
{
icon: "❤️",
label: "Health Check",
tool: "n8n_health_check",
defaultArgs: {},
},
{
icon: "📖",
label: "Tools Doc",
tool: "tools_documentation",
defaultArgs: {},
},
{
icon: "🔎",
label: "Search Templates",
tool: "search_templates",
defaultArgs: { query: "automation", limit: 3 },
},
{
icon: "✅",
label: "Validate Workflow",
tool: "validate_workflow",
defaultArgs: {},
},
]
async function runQuickAction(action: (typeof quickActions)[0]) {
activeTab.value = "log"
await mcpService.callTool(action.tool, action.defaultArgs)
}
</script>
<template>
<div class="n8n-panel">
<!-- Header -->
<div class="panel-header">
<div class="header-top">
<div class="header-title">
<span class="n8n-logo"></span>
<span class="title-text">n8n MCP</span>
</div>
<div class="connection-block">
<span class="status-dot" :style="{ backgroundColor: statusColor }"></span>
<span class="status-label">{{ statusText }}</span>
</div>
</div>
<!-- Server URL -->
<div class="server-row">
<input
v-model="serverUrl"
class="server-input"
placeholder="http://localhost:3006"
:disabled="connectionStatus === 'connected'"
/>
<button
class="connect-btn"
:class="{
'btn-connected': connectionStatus === 'connected',
'btn-connecting': isConnecting,
}"
@click="toggleConnection"
:disabled="isConnecting"
>
{{ connectionStatus === "connected" ? "Disconnect" : isConnecting ? "..." : "Connect" }}
</button>
</div>
<!-- Tabs -->
<div class="tab-bar">
<button
v-for="tab in [
{ key: 'quick', label: '⚡ Quick', count: 0 },
{ key: 'tools', label: '🔧 Tools', count: tools.length },
{ key: 'log', label: '📊 Log', count: executions.length },
]"
:key="tab.key"
class="tab-btn"
:class="{ active: activeTab === tab.key }"
@click="activeTab = tab.key as any"
>
{{ tab.label }}
<span v-if="tab.count" class="tab-badge">{{ tab.count }}</span>
</button>
</div>
</div>
<!-- Content -->
<div class="panel-content">
<!-- Quick Actions -->
<div v-if="activeTab === 'quick'" class="quick-actions">
<div v-if="connectionStatus !== 'connected'" class="empty-state">
<span class="empty-icon">🔌</span>
<p class="empty-text">Connect to MCP server to get started</p>
<p class="empty-hint">
Run <code>start-mcp.bat</code> first, then click Connect
</p>
</div>
<template v-else>
<div class="quick-grid">
<button
v-for="action in quickActions"
:key="action.tool"
class="quick-card"
@click="runQuickAction(action)"
>
<span class="quick-icon">{{ action.icon }}</span>
<span class="quick-label">{{ action.label }}</span>
<span class="quick-tool">{{ action.tool }}</span>
</button>
</div>
<!-- Stats -->
<div class="stats-bar">
<div class="stat">
<span class="stat-value">{{ tools.length }}</span>
<span class="stat-label">Tools</span>
</div>
<div class="stat">
<span class="stat-value">{{ executions.length }}</span>
<span class="stat-label">Executions</span>
</div>
<div class="stat">
<span class="stat-value">{{ executions.filter(e => e.status === 'success').length }}</span>
<span class="stat-label">Success</span>
</div>
<div class="stat">
<span class="stat-value">{{ executions.filter(e => e.status === 'error').length }}</span>
<span class="stat-label">Errors</span>
</div>
</div>
</template>
</div>
<!-- Tools List -->
<div v-if="activeTab === 'tools'" class="tools-tab">
<div v-if="tools.length" class="search-box">
<input
v-model="searchQuery"
class="search-input"
placeholder="Search tools..."
/>
</div>
<div v-if="filteredTools.length" class="tools-list">
<ToolCard
v-for="tool in filteredTools"
:key="tool.name"
:tool="tool"
@execute="handleToolExecute"
/>
</div>
<div v-else-if="connectionStatus !== 'connected'" class="empty-state">
<span class="empty-icon">🔧</span>
<p class="empty-text">No tools available</p>
<p class="empty-hint">Connect to MCP server first</p>
</div>
<div v-else class="empty-state">
<span class="empty-icon">🔍</span>
<p class="empty-text">No tools match "{{ searchQuery }}"</p>
</div>
</div>
<!-- Execution Log -->
<div v-if="activeTab === 'log'" class="log-tab">
<ExecutionLog
:executions="executions"
@clear="clearLog"
/>
</div>
</div>
</div>
</template>
<style scoped>
.n8n-panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-background, #0f0f0f);
color: var(--color-text, #e5e5e5);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.panel-header {
padding: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
flex-shrink: 0;
}
.header-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.n8n-logo {
font-size: 18px;
}
.title-text {
font-size: 15px;
font-weight: 700;
background: linear-gradient(135deg, #ff6d5a 0%, #ff9a44 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.connection-block {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
transition: background-color 0.3s;
box-shadow: 0 0 6px currentColor;
}
.server-row {
display: flex;
gap: 6px;
margin-bottom: 10px;
}
.server-input {
flex: 1;
padding: 6px 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
color: inherit;
font-size: 12px;
outline: none;
transition: border-color 0.2s;
}
.server-input:focus {
border-color: rgba(255, 109, 90, 0.4);
}
.server-input:disabled {
opacity: 0.5;
}
.connect-btn {
padding: 6px 14px;
background: linear-gradient(135deg, #ff6d5a, #ff9a44);
border: none;
border-radius: 6px;
color: white;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.connect-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.connect-btn:active {
transform: scale(0.97);
}
.connect-btn.btn-connected {
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.6);
}
.connect-btn.btn-connected:hover {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.connect-btn.btn-connecting {
opacity: 0.6;
cursor: wait;
}
.tab-bar {
display: flex;
gap: 2px;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
padding: 2px;
}
.tab-btn {
flex: 1;
padding: 6px 8px;
background: transparent;
border: none;
border-radius: 6px;
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.tab-btn:hover {
color: rgba(255, 255, 255, 0.7);
background: rgba(255, 255, 255, 0.04);
}
.tab-btn.active {
background: rgba(255, 109, 90, 0.15);
color: #ff8a65;
font-weight: 600;
}
.tab-badge {
font-size: 10px;
background: rgba(255, 255, 255, 0.1);
padding: 1px 5px;
border-radius: 10px;
min-width: 16px;
text-align: center;
}
.tab-btn.active .tab-badge {
background: rgba(255, 109, 90, 0.25);
}
.panel-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* Scrollbar */
.panel-content::-webkit-scrollbar {
width: 4px;
}
.panel-content::-webkit-scrollbar-track {
background: transparent;
}
.panel-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
/* Quick Actions */
.quick-actions {
padding: 12px;
}
.quick-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-bottom: 16px;
}
.quick-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 14px 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
color: inherit;
}
.quick-card:hover {
background: rgba(255, 109, 90, 0.08);
border-color: rgba(255, 109, 90, 0.2);
transform: translateY(-1px);
}
.quick-card:active {
transform: scale(0.97);
}
.quick-icon {
font-size: 22px;
}
.quick-label {
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
}
.quick-tool {
font-size: 10px;
color: rgba(255, 255, 255, 0.3);
font-family: monospace;
}
.stats-bar {
display: flex;
gap: 4px;
background: rgba(255, 255, 255, 0.02);
border-radius: 10px;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.04);
}
.stat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.stat-value {
font-size: 18px;
font-weight: 700;
background: linear-gradient(135deg, #ff6d5a, #ff9a44);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.stat-label {
font-size: 10px;
color: rgba(255, 255, 255, 0.35);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Tools Tab */
.tools-tab {
padding: 12px;
}
.search-box {
margin-bottom: 10px;
}
.search-input {
width: 100%;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: inherit;
font-size: 13px;
outline: none;
box-sizing: border-box;
}
.search-input:focus {
border-color: rgba(255, 109, 90, 0.4);
}
.tools-list {
display: flex;
flex-direction: column;
gap: 6px;
}
/* Log Tab */
.log-tab {
height: 100%;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
}
.empty-icon {
font-size: 40px;
margin-bottom: 12px;
}
.empty-text {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
margin: 0 0 6px;
}
.empty-hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.3);
margin: 0;
}
.empty-hint code {
background: rgba(255, 109, 90, 0.15);
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
color: #ff8a65;
}
/* Dark/Light mode support */
@media (prefers-color-scheme: light) {
.n8n-panel {
background: #fafafa;
color: #1a1a1a;
}
.panel-header {
border-bottom-color: rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.02);
}
.server-input {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.1);
color: #1a1a1a;
}
.tab-bar {
background: rgba(0, 0, 0, 0.03);
}
.tab-btn {
color: rgba(0, 0, 0, 0.4);
}
.tab-btn:hover {
color: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.04);
}
.quick-card {
background: rgba(0, 0, 0, 0.02);
border-color: rgba(0, 0, 0, 0.08);
}
.quick-label {
color: rgba(0, 0, 0, 0.8);
}
.quick-tool {
color: rgba(0, 0, 0, 0.4);
}
.connection-block {
color: rgba(0, 0, 0, 0.5);
}
.search-input {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.1);
color: #1a1a1a;
}
.empty-text {
color: rgba(0, 0, 0, 0.5);
}
.empty-hint {
color: rgba(0, 0, 0, 0.35);
}
}
</style>
......@@ -25,7 +25,7 @@ onMounted(() => {
// how to
awai messageInvoke.invoke({
await messageInvoke.invoke({
key: ServiceFunc.waitSidebar,
func: ServiceFunc.waitSidebar,
args: [],
......
<script setup lang="ts">
import {} from "vue"
import { ref } from "vue"
import { useI18n } from "@/utils/i18n"
import Search from "@/components/Search.vue"
import SiteButton from "@/components/SiteButton.vue"
import McpSettings from "@/components/n8n/McpSettings.vue"
import type config from "@/assets/config.json"
const logoUrl = chrome.runtime.getURL("/logo.svg")
const { t } = useI18n()
const showMcpSettings = ref(false)
defineProps<{
recentItems: typeof config.data.popularSites
popularItems: typeof config.data.popularSites
......@@ -20,16 +23,40 @@ const emit = defineEmits({
<template>
<div class="flex flex-col p-6 w-full max-w-md mx-auto">
<div class="flex flex-col items-center gap-2 mx-auto mt-16">
<div class="flex flex-col items-center gap-2 mx-auto mt-16 relative">
<img :src="logoUrl" class="size-16" />
<span class="text-2xl font-bold my-2">Anything Copilot</span>
<!-- n8n MCP Settings toggle -->
<button
@click="showMcpSettings = !showMcpSettings"
:class="[
'absolute -right-10 top-2 p-1.5 rounded-lg transition-all',
showMcpSettings
? 'bg-red-500/20 text-red-400'
: 'hover:bg-white/10 text-white/40 hover:text-white/70',
]"
title="n8n MCP Settings"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
<!-- MCP Settings Panel (slide toggle) -->
<transition name="slide">
<div v-if="showMcpSettings" class="my-6 rounded-xl border border-white/5 bg-black/20 overflow-hidden">
<McpSettings />
</div>
</transition>
<div class="my-12">
<div class="my-12" v-if="!showMcpSettings">
<Search @go="(url) => emit('go', url)" />
</div>
<div class="flex flex-wrap gap-x-3 gap-y-4 justify-center">
<div class="flex flex-wrap gap-x-3 gap-y-4 justify-center" v-if="!showMcpSettings">
<SiteButton
v-for="item of recentItems.slice(0, 12)"
:key="item.url"
......@@ -43,8 +70,8 @@ const emit = defineEmits({
</div>
<!-- <div class="text-center my-3">Popular</div> -->
<div class="w-full my-6 border-b border-background-soft h-0"></div>
<div class="flex flex-wrap gap-x-3 gap-y-4 justify-center">
<div class="w-full my-6 border-b border-background-soft h-0" v-if="!showMcpSettings"></div>
<div class="flex flex-wrap gap-x-3 gap-y-4 justify-center" v-if="!showMcpSettings">
<SiteButton
v-for="item of popularItems"
:icon="item.icon"
......@@ -56,4 +83,22 @@ const emit = defineEmits({
</div>
</template>
<style scoped></style>
<style scoped>
.slide-enter-active,
.slide-leave-active {
transition: all 0.25s ease;
}
.slide-enter-from,
.slide-leave-to {
opacity: 0;
max-height: 0;
transform: translateY(-8px);
}
.slide-enter-to,
.slide-leave-from {
opacity: 1;
max-height: 800px;
transform: translateY(0);
}
</style>
......@@ -212,6 +212,8 @@ if (window.top === window) {
run()
}
import { setupMcpClient } from "./mcp-injector"
// webview
if (window.top !== window && window.name.startsWith(WindowName.webview)) {
window.addEventListener("message", handleFrameMessage)
......@@ -225,6 +227,9 @@ if (window.top !== window && window.name.startsWith(WindowName.webview)) {
run()
postPageInfo()
// Connect to the MCP Server proxy
setupMcpClient().catch(console.error)
}
// dev
......
import { createLogger } from './render_prescript/utils/logger';
import { initialize } from './render_prescript/index';
import { messageInvoke } from '../utils/invoke';
import { ServiceFunc } from '../types/index';
const logger = createLogger('McpInjector');
// 1. Setup minimal Chat GPT adapter
const simpleGptAdapter = {
capabilities: ['text-insertion', 'form-submission', 'dom-manipulation'],
insertText: async (text: string): Promise<boolean> => {
// Quick insertion logic
const input = document.querySelector('#prompt-textarea') || document.querySelector('.ProseMirror[contenteditable="true"]');
if (!input) return false;
// Check if it's text area
if (input.tagName.toLowerCase() === 'textarea') {
const textarea = input as HTMLTextAreaElement;
textarea.value += '\n' + text;
textarea.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
// Handle prosemirror text addition
if (input.innerHTML === '<p><br></p>') {
input.innerHTML = `<p>${text}</p>`;
} else {
input.innerHTML += `<p>${text}</p>`;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
// Wait briefly, then optionally submit
setTimeout(() => {
const submitBtn = document.querySelector('button[data-testid="send-button"]') as HTMLButtonElement;
if (submitBtn) submitBtn.click();
}, 100);
return true;
}
};
// 2. Setup MCP Client Proxy
export async function setupMcpClient() {
logger.info('Setting up MCP Client Proxy in Webview Content Script...');
// Expose adapter globally so `render_prescript` can pick it up
(window as any).mcpAdapter = simpleGptAdapter;
// Provide the global interface required by render_prescript and components
// Look at render_prescript/renderer/components.ts -> useTool()
// It calls `(window as any).mcpClient.callTool(name, payload)`
(window as any).mcpClient = {
callTool: async (toolName: string, args: Record<string, any>) => {
logger.info(`Proxying tool call: ${toolName}`, args);
// Let's call the bg script
try {
const result = await messageInvoke.invoke({
func: ServiceFunc.mcpExecuteTool,
args: [{ name: toolName, args }]
});
return result;
} catch(e) {
logger.error(`Error calling ${toolName}`, e);
throw e;
}
},
getTools: async () => {
const result = await messageInvoke.invoke({
func: ServiceFunc.mcpGetTools,
args: []
});
return result;
}
};
// 3. Initialize the renderer
initialize();
logger.info('MCP execution UI injected!');
}
import type { FunctionCallRendererConfig } from './types';
/**
* Default configuration for the function call renderer
*/
export const DEFAULT_CONFIG: FunctionCallRendererConfig = {
knownLanguages: [
'xml',
'html',
'python',
'javascript',
'js',
'ruby',
'bash',
'shell',
'css',
'json',
'java',
'c',
'cpp',
'csharp',
'php',
'typescript',
'ts',
'go',
'rust',
'swift',
'kotlin',
'sql',
],
handleLanguageTags: true,
maxLinesAfterLangTag: 3,
targetSelectors: ['pre', 'code'],
enableDirectMonitoring: true,
streamingContainerSelectors: ['.pre', '.code'],
function_result_selector: [], // Empty by default, will be populated by website-specific configs
// streamingContainerSelectors: ['.message-content', '.chat-message', '.message-body', '.message'],
updateThrottle: 25,
streamingMonitoringInterval: 100,
largeContentThreshold: Number.MAX_SAFE_INTEGER,
progressiveUpdateInterval: 250,
maxContentPreviewLength: Number.MAX_SAFE_INTEGER,
usePositionFixed: false,
stabilizeTimeout: 500,
debug: true,
// Theme detection
useHostTheme: true,
// Stalled stream detection - defaults
enableStalledStreamDetection: true,
stalledStreamTimeout: 3000, // 3 seconds before marking a stream as stalled
stalledStreamCheckInterval: 1000, // Check every 1 second
// CodeMirror content extraction
useCodeMirrorExtraction: false, // Default to false, enabled for specific sites
};
/**
* Website-specific configuration overrides
* Each entry contains a URL pattern to match and configuration overrides
*/
export const WEBSITE_CONFIGS: Array<{
urlPattern: string | RegExp;
config: Partial<FunctionCallRendererConfig>;
}> = [
{
// AI Studio specific configuration
urlPattern: 'aistudio',
config: {
targetSelectors: ['pre'],
streamingContainerSelectors: ['.pre'],
// <ms-prompt-chunk _ngcontent-ng-c1514118342="" _nghost-ng-c66683564="" class="text-chunk ng-star-inserted" id="68420A4A-417F-4A01-8BF8-EF77DFEC7182">
// <div _ngcontent-ng-c1514118342="" msheightchanged="" class="turn-content">
// <div _ngcontent-ng-c1514118342="" class="virtual-scroll-container user-prompt-container" data-turn-role="User">
// <ms-text-chunk _ngcontent-ng-c66683564="" _nghost-ng-c3631226313="" class="ng-star-inserted">
function_result_selector: ['ms-text-chunk.ng-star-inserted'],
},
},
{
urlPattern: 'perplexity',
config: {
targetSelectors: ['pre'],
streamingContainerSelectors: ['.pre'],
function_result_selector: ['div.group\\/query', '.group\\/query', 'div[class*="group/query"]'],
},
},
{
urlPattern: 'gemini',
config: {
// targetSelectors: ['code-block'],
// streamingContainerSelectors: ['.code-block'],
targetSelectors: ['pre'],
streamingContainerSelectors: ['pre'],
function_result_selector: ['div.query-content'],
},
},
{
urlPattern: 'grok.com',
config: {
targetSelectors: ['code'],
streamingContainerSelectors: ['code'],
function_result_selector: ['div.relative.items-end'],
},
},
{
urlPattern: 'openrouter.ai',
config: {
targetSelectors: ['pre'],
streamingContainerSelectors: ['pre'],
// <div data-testid="user-message" class="group my-2 flex w-full flex-col gap-2 md:my-0 slide-in-from-right-12 justify-end items-end">
// <div class="relative group/text-item grid w-full ph-no-capture justify-items-end" data-dd-privacy="hidden"><div class="py-3 px-4 font-normal relative transition-colors border rounded-lg border-transparent rounded-tr-none bg-[var(--bubble-color,#3b82f6)] text-[var(--bubble-text-color,#ffffff)] col-start-1 row-start-1"><div class="min-w-0 w-full [&amp;&gt;ol]:mb-4 [&amp;&gt;ul]:mb-4 [&amp;&gt;*:last-child]:mb-0 [&amp;_li&gt;p]:mb-0">
function_result_selector: [
// 'div.min-w-0.w-full.overflow-hidden',
// 'div.group.my-2.flex.w-full.flex-col.gap-2.md:my-0.slide-in-from-right-12.justify-end.items-end',
'div[data-testid="user-message"]',
// 'div.relative.group/text-item.grid.w-full.ph-no-capture.justify-items-end[data-dd-privacy="hidden"]'
// 'div.flex.max-w-full.flex-col.relative.overflow-auto.gap-1.items-end',
// 'div.flex',
// 'div.flex.items-end',
],
},
},
{
urlPattern: 'chatgpt.com',
config: {
targetSelectors: ['pre'],
streamingContainerSelectors: ['pre'],
function_result_selector: ['div[data-message-author-role="user"]'],
},
},
{
urlPattern: 'chat.openai.com',
config: {
targetSelectors: ['pre'],
streamingContainerSelectors: ['pre'],
function_result_selector: ['div[data-message-author-role="user"]'],
},
},
{
urlPattern: 'kagi.com',
config: {
targetSelectors: ['.content pre', '.codehilite', 'pre'],
streamingContainerSelectors: ['pre', '.content'],
function_result_selector: ['div[data-author="user"]'],
},
},
{
urlPattern: 'chat.deepseek.com',
config: {
targetSelectors: ['pre', 'code'],
streamingContainerSelectors: ['pre', 'code'],
function_result_selector: ['div._9663006'],
},
},
{
urlPattern: 't3.chat',
config: {
targetSelectors: ['pre'],
streamingContainerSelectors: ['pre'],
function_result_selector: ['div[aria-label="Your message"]'],
},
},
{
urlPattern: 'chat.mistral.ai',
config: {
targetSelectors: ['pre'],
streamingContainerSelectors: ['pre'],
function_result_selector: ['div[data-message-part-type="answer"]', '.select-text'],
},
},
{
urlPattern: 'github.com/copilot',
config: {
targetSelectors: ['pre'],
streamingContainerSelectors: ['pre'],
function_result_selector: ['.UserMessage-module__container--cAvvK', '.ChatMessage-module__userMessage--xvIFp'],
},
},
{
urlPattern: 'kimi.com',
config: {
targetSelectors: ['pre'],
streamingContainerSelectors: ['pre'],
function_result_selector: ['div[class*="user-content"]'],
},
},
{
urlPattern: 'chat.z.ai',
config: {
// targetSelectors: ['pre[id^="cm-hidden-pre-"]'],
// streamingContainerSelectors: ['pre[id^="cm-hidden-pre-"]'],
targetSelectors: ['pre'],
streamingContainerSelectors: ['pre'],
function_result_selector: ['div.chat-user'],
useCodeMirrorExtraction: true
},
},
{
urlPattern: 'chat.qwen.ai',
config: {
// Prioritize hidden pre elements from codemirror-accessor (clean content)
targetSelectors: ['pre[id^="cm-hidden-pre-"]', 'pre[data-cm-source]', 'pre', 'code'],
streamingContainerSelectors: ['pre', 'code'],
function_result_selector: ['.user-message-text-content', 'div.user-message-content'],
useCodeMirrorExtraction: true
},
},
// Add more website-specific configurations as needed
// Example:
// {
// urlPattern: 'example.com',
// config: {
// targetSelectors: ['.custom-selector'],
// streamingContainerSelectors: ['.custom-container']
// }
// }
];
/**
* Gets the appropriate configuration based on the current URL
* @returns The merged configuration with website-specific overrides applied if applicable
*/
export function getConfig(): FunctionCallRendererConfig {
const currentUrl = window.location.href;
let config = { ...DEFAULT_CONFIG };
// Check if any website-specific config applies
for (const siteConfig of WEBSITE_CONFIGS) {
const { urlPattern, config: overrides } = siteConfig;
// Check if URL matches the pattern
const matches = typeof urlPattern === 'string' ? currentUrl.includes(urlPattern) : urlPattern.test(currentUrl);
if (matches) {
// Apply overrides to the default config
config = { ...config, ...overrides };
break; // Use first matching config
}
}
return config;
}
/**
* The active configuration - use this as the main config export
*/
export const CONFIG = getConfig();
// Re-export the config interface and utility functions
export type { FunctionCallRendererConfig };
// Re-export core functionality
export * from './config';
export * from './types';
// Core type definitions used throughout the application
/**
* Configuration options for the function call renderer
*/
export interface FunctionCallRendererConfig {
knownLanguages: string[];
handleLanguageTags: boolean;
maxLinesAfterLangTag: number;
targetSelectors: string[];
enableDirectMonitoring: boolean;
streamingContainerSelectors: string[];
function_result_selector?: string[];
updateThrottle: number;
streamingMonitoringInterval: number;
largeContentThreshold: number;
progressiveUpdateInterval: number;
maxContentPreviewLength: number;
usePositionFixed: boolean;
stabilizeTimeout: number;
debug: boolean;
// Theme detection
useHostTheme: boolean;
// Stalled stream detection
enableStalledStreamDetection: boolean;
stalledStreamTimeout: number;
stalledStreamCheckInterval: number;
// CodeMirror content extraction
useCodeMirrorExtraction: boolean;
}
/**
* Parameter data extracted from function calls
*/
export interface Parameter {
name: string;
value: string;
isComplete: boolean;
isNew?: boolean;
isStreaming?: boolean;
originalContent?: string;
isLargeContent?: boolean;
contentLength?: number;
truncated?: boolean;
isIncompleteTag?: boolean;
}
/**
* Information about a function call detection
*/
export interface FunctionInfo {
hasFunctionCalls: boolean;
isComplete: boolean;
hasInvoke: boolean;
hasParameters: boolean;
hasClosingTags: boolean;
languageTag: string | null;
detectedBlockType: string | null;
partialTagDetected: boolean;
invokeName?: string;
textContent?: string;
description?: string;
}
/**
* Interface for tracking partial parameter state during streaming
*/
export interface PartialParameterState {
[paramName: string]: string;
}
/**
* Interface for custom HTMLDivElement with added properties for auto-scrolling
*/
export interface ParamValueElement extends HTMLDivElement {
_autoScrollToBottom?: () => void;
_autoScrollObserver?: MutationObserver;
_scrollTimeout?: number | null;
_userHasScrolled?: boolean;
_scrollListener?: EventListener;
}
/**
* Stabilized block information
*/
export interface StabilizedBlock {
block: HTMLDivElement;
placeholder: HTMLDivElement;
originalStyles: Partial<CSSStyleDeclaration>;
}
import type { FunctionCallRendererConfig } from './core/config';
import { CONFIG } from './core/config';
import { styles } from './renderer/styles';
import {
processFunctionCalls,
checkForUnprocessedFunctionCalls,
startDirectMonitoring,
stopDirectMonitoring,
initializeObserver,
processFunctionResults,
checkForUnprocessedFunctionResults,
startFunctionResultMonitoring,
stopFunctionResultMonitoring,
initializeFunctionResultObserver,
processUpdateQueue,
checkStreamingUpdates,
checkStalledStreams,
detectPreExistingIncompleteBlocks,
startStalledStreamDetection,
updateStalledStreamTimeoutConfig,
} from './observer/index';
import { renderFunctionCall, renderedFunctionBlocks } from './renderer/index';
import { createLogger } from '@extension/shared/lib/logger';
// Import the website-specific components
// import { initPerplexityComponents } from './websites_components/perplexity';
// import { initGrokComponents } from './websites_components/grok';
// Ensure styles are injected only once
const logger = createLogger('RenderPrescript');
let stylesInjected = false;
const injectStyles = () => {
if (stylesInjected) return;
if (typeof document === 'undefined') return; // Guard against non-browser env
const styleElement = document.createElement('style');
styleElement.textContent = styles;
document.head.appendChild(styleElement);
stylesInjected = true;
};
// Inject CodeMirror accessor script
let codeMirrorScriptInjected = false;
let injectionAttempted = false;
const injectCodeMirrorAccessor = () => {
// Prevent multiple injection attempts
if (codeMirrorScriptInjected || injectionAttempted) return;
if (typeof document === 'undefined') return; // Guard against non-browser env
injectionAttempted = true;
// Check if script is already present in DOM
if (document.getElementById('codemirror-accessor-script') ||
document.getElementById('codemirror-accessor-script-direct') ||
document.getElementById('codemirror-accessor-page-context')) {
if (CONFIG.debug) {
logger.debug('CodeMirror accessor script already present in DOM, skipping injection');
}
codeMirrorScriptInjected = true;
return;
}
// Check if window.CodeMirrorAccessor already exists
if (typeof (window as any).CodeMirrorAccessor !== 'undefined') {
if (CONFIG.debug) {
logger.debug('CodeMirrorAccessor already exists on window, skipping injection');
}
codeMirrorScriptInjected = true;
return;
}
try {
// Get the script URL from the chrome extension
const scriptUrl = chrome.runtime.getURL('codemirror-accessor.js');
// Use src attribute method (CSP-safe) instead of inline textContent
// This avoids CSP violations on strict sites like chat.qwen.ai
const scriptElement = document.createElement('script');
scriptElement.type = 'text/javascript';
scriptElement.id = 'codemirror-accessor-script';
scriptElement.src = scriptUrl; // Use src instead of textContent to avoid CSP issues
scriptElement.onload = () => {
codeMirrorScriptInjected = true;
if (CONFIG.debug) {
logger.debug('CodeMirror accessor script loaded successfully via src attribute');
}
// Verify script is active after a short delay
setTimeout(() => {
if (typeof (window as any).CodeMirrorAccessor !== 'undefined') {
if (CONFIG.debug) {
logger.debug('CodeMirror accessor is active and accessible');
}
} else {
logger.debug('CodeMirror accessor script loaded but not accessible - may be in wrong context');
// Try alternative injection method only if not already attempted
if (!document.getElementById('codemirror-accessor-script-direct')) {
injectCodeMirrorAccessorAlternative();
}
}
}, 100);
};
scriptElement.onerror = (error) => {
logger.debug('Failed to load CodeMirror accessor script via src:', error);
// Only try alternative if not already present
if (!document.getElementById('codemirror-accessor-script-direct')) {
injectCodeMirrorAccessorAlternative();
}
};
// Inject into page context, not content script context
(document.head || document.documentElement).appendChild(scriptElement);
} catch (error) {
logger.debug('Error during CodeMirror script injection:', error);
// Only try alternative if not already present
if (!document.getElementById('codemirror-accessor-script-direct')) {
injectCodeMirrorAccessorAlternative();
}
}
};
// Alternative injection method for when content script context isolation prevents access
const injectCodeMirrorAccessorAlternative = () => {
// Prevent multiple alternative injections
if (document.getElementById('codemirror-accessor-script-direct') ||
typeof (window as any).CodeMirrorAccessor !== 'undefined') {
if (CONFIG.debug) {
logger.debug('CodeMirror accessor already present, skipping alternative injection');
}
return;
}
try {
const scriptUrl = chrome.runtime.getURL('codemirror-accessor.js');
// Method 1: Direct script tag injection
const scriptElement = document.createElement('script');
scriptElement.src = scriptUrl;
scriptElement.id = 'codemirror-accessor-script-direct';
scriptElement.onload = () => {
if (CONFIG.debug) {
logger.debug('CodeMirror accessor script loaded via direct method');
}
// Verify accessibility
setTimeout(() => {
if (typeof (window as any).CodeMirrorAccessor !== 'undefined') {
if (CONFIG.debug) {
logger.debug('CodeMirror accessor is now active via direct injection');
}
codeMirrorScriptInjected = true;
} else {
logger.debug('CodeMirror accessor still not accessible - trying page context injection');
// Only try page context if not already present
if (!document.getElementById('codemirror-accessor-page-context')) {
injectCodeMirrorAccessorPageContext();
}
}
}, 50);
};
scriptElement.onerror = () => {
logger.error('Failed to load CodeMirror accessor script via direct method');
// Only try page context if not already present
if (!document.getElementById('codemirror-accessor-page-context')) {
injectCodeMirrorAccessorPageContext();
}
};
(document.head || document.documentElement).appendChild(scriptElement);
} catch (error) {
logger.debug('Alternative injection method failed:', error);
// Only try page context if not already present
if (!document.getElementById('codemirror-accessor-page-context')) {
injectCodeMirrorAccessorPageContext();
}
}
};
// Page context injection method - CSP-safe version
const injectCodeMirrorAccessorPageContext = () => {
// Prevent multiple page context injections
if (document.getElementById('codemirror-accessor-page-context') ||
typeof (window as any).CodeMirrorAccessor !== 'undefined') {
if (CONFIG.debug) {
logger.debug('CodeMirror accessor already present, skipping page context injection');
}
return;
}
try {
const scriptUrl = chrome.runtime.getURL('codemirror-accessor.js');
// Use src attribute method (CSP-safe) instead of inline script
const scriptElement = document.createElement('script');
scriptElement.id = 'codemirror-accessor-page-context';
scriptElement.src = scriptUrl;
scriptElement.onload = () => {
if (CONFIG.debug) {
logger.debug('CodeMirror accessor injected into page context via src');
}
codeMirrorScriptInjected = true;
};
scriptElement.onerror = () => {
logger.error('Failed to inject CodeMirror accessor into page context');
};
(document.head || document.documentElement).appendChild(scriptElement);
if (CONFIG.debug) {
logger.debug('Attempting page context injection of CodeMirror accessor');
}
} catch (error) {
logger.error('Page context injection failed:', error);
}
};
// Initialize the stalled stream detection config early for faster detection
updateStalledStreamTimeoutConfig();
// Main initialization function
const initializeRenderer = () => {
// Guard against running in non-browser environments
if (typeof window === 'undefined' || typeof document === 'undefined') {
logger.debug('Function Call Renderer: Not running in a browser environment.');
return;
}
injectStyles();
// Inject CodeMirror accessor script only if the website uses CodeMirror
if (CONFIG.useCodeMirrorExtraction) {
injectCodeMirrorAccessor();
}
processFunctionCalls(); // Initial processing of existing blocks
// Process function results if selectors are configured
if (CONFIG.function_result_selector && CONFIG.function_result_selector.length > 0) {
processFunctionResults(); // Initial processing of existing function results
}
// Register the global event listener for function call rendering before starting the observer
// document.addEventListener('render-function-call', (event: Event) => {
// const customEvent = event as CustomEvent;
// if (customEvent.detail && customEvent.detail.element) {
// if (CONFIG.debug) {
// logger.debug('Custom render event triggered', customEvent.detail);
// }
// // Attempt to render this function call
// renderFunctionCall(customEvent.detail.element, { current: false });
// }
// });
// Initialize the mutation observer
initializeObserver(); // Start the main MutationObserver
startDirectMonitoring(); // Start direct monitoring if enabled
// Initialize the function result observer if selectors are configured
if (CONFIG.function_result_selector && CONFIG.function_result_selector.length > 0) {
initializeFunctionResultObserver(); // Start the function result observer
}
// Make sure stalled stream detection is explicitly started
startStalledStreamDetection();
// // Initialize website-specific components
// // Check if we're on Perplexity website
// if (window.location.href.includes('perplexity.ai')) {
// logger.debug("Initializing Perplexity-specific components");
// initPerplexityComponents();
// }
// // Check if we're on Grok website
// else if (window.location.href.includes('grok.x.ai') || window.location.href.includes('grok.ai')) {
// logger.debug("Initializing Grok-specific components");
// initGrokComponents();
// }
logger.debug('Function call renderer initialized with improved parameter extraction and streaming support.');
};
// Configuration function
const configure = (options: Partial<FunctionCallRendererConfig>) => {
let monitoringRestart = false;
// Override specific options if provided, respecting the original script's logic
const userOptions = { ...options }; // Clone to avoid modifying the input
// Force specific settings from the original script (if desired)
CONFIG.usePositionFixed = false;
CONFIG.largeContentThreshold = Number.MAX_SAFE_INTEGER;
CONFIG.maxContentPreviewLength = Number.MAX_SAFE_INTEGER;
// Apply user overrides selectively
if (userOptions.knownLanguages !== undefined) CONFIG.knownLanguages = [...userOptions.knownLanguages];
if (userOptions.handleLanguageTags !== undefined) CONFIG.handleLanguageTags = !!userOptions.handleLanguageTags;
if (userOptions.maxLinesAfterLangTag !== undefined) CONFIG.maxLinesAfterLangTag = userOptions.maxLinesAfterLangTag;
if (userOptions.updateThrottle !== undefined) {
CONFIG.updateThrottle = userOptions.updateThrottle;
monitoringRestart = true;
}
if (userOptions.enableDirectMonitoring !== undefined) {
CONFIG.enableDirectMonitoring = !!userOptions.enableDirectMonitoring;
monitoringRestart = true;
}
if (userOptions.streamingContainerSelectors !== undefined)
CONFIG.streamingContainerSelectors = [...userOptions.streamingContainerSelectors];
if (userOptions.function_result_selector !== undefined) {
const oldLength = CONFIG.function_result_selector?.length || 0;
CONFIG.function_result_selector = [...userOptions.function_result_selector];
// If function_result_selector was empty before and now has items, or vice versa,
// we need to restart monitoring
if (
(oldLength === 0 && CONFIG.function_result_selector.length > 0) ||
(oldLength > 0 && CONFIG.function_result_selector.length === 0)
) {
monitoringRestart = true;
}
}
if (userOptions.streamingMonitoringInterval !== undefined) {
CONFIG.streamingMonitoringInterval = userOptions.streamingMonitoringInterval;
monitoringRestart = true;
}
// Allow user to override forced settings if they provide them
if (userOptions.largeContentThreshold !== undefined) CONFIG.largeContentThreshold = userOptions.largeContentThreshold;
if (userOptions.maxContentPreviewLength !== undefined)
CONFIG.maxContentPreviewLength = userOptions.maxContentPreviewLength;
if (userOptions.usePositionFixed !== undefined) CONFIG.usePositionFixed = !!userOptions.usePositionFixed;
// ----
if (userOptions.progressiveUpdateInterval !== undefined) {
CONFIG.progressiveUpdateInterval = userOptions.progressiveUpdateInterval;
monitoringRestart = true;
}
if (userOptions.stabilizeTimeout !== undefined) CONFIG.stabilizeTimeout = userOptions.stabilizeTimeout;
if (userOptions.debug !== undefined) CONFIG.debug = !!userOptions.debug;
// New stalled stream detection configuration
if (userOptions.enableStalledStreamDetection !== undefined) {
CONFIG.enableStalledStreamDetection = !!userOptions.enableStalledStreamDetection;
monitoringRestart = true;
}
if (userOptions.stalledStreamTimeout !== undefined) {
CONFIG.stalledStreamTimeout = userOptions.stalledStreamTimeout;
}
if (userOptions.stalledStreamCheckInterval !== undefined) {
CONFIG.stalledStreamCheckInterval = userOptions.stalledStreamCheckInterval;
monitoringRestart = true;
}
if (monitoringRestart) {
// Restart function call monitoring
stopDirectMonitoring();
if (CONFIG.enableDirectMonitoring) {
startDirectMonitoring();
}
// Restart function result monitoring if selectors are configured
stopFunctionResultMonitoring();
if (CONFIG.function_result_selector && CONFIG.function_result_selector.length > 0) {
startFunctionResultMonitoring();
}
}
logger.debug('Function call renderer configuration updated:', CONFIG);
// Re-process immediately after config change might be needed
processFunctionCalls();
};
// Expose functions to the window object for global access
// if (typeof window !== 'undefined') {
// (window as any).configureFunctionCallRenderer = configure;
// (window as any).startFunctionCallMonitoring = startDirectMonitoring;
// (window as any).stopFunctionCallMonitoring = stopDirectMonitoring;
// (window as any).checkForFunctionCalls = checkForUnprocessedFunctionCalls;
// (window as any).forceStreamingUpdate = checkStreamingUpdates;
// (window as any).renderFunctionCalls = processFunctionCalls;
// (window as any).checkStalledStreams = checkStalledStreams;
// (window as any).detectPreExistingIncompleteBlocks = detectPreExistingIncompleteBlocks;
// // Initialize automatically when the script loads in a browser
// // Use DOMContentLoaded to ensure the body exists for the observer
// if (document.readyState === 'loading') {
// document.addEventListener('DOMContentLoaded', initializeRenderer);
// } else {
// // DOMContentLoaded has already fired
// initializeRenderer();
// }
// }
// Debug helper for JSON parser
if (typeof window !== 'undefined') {
(window as any).enableJSONDebug = () => {
(window as any).__DEBUG_JSON_PARSER = true;
logger.debug('JSON parser debug logging enabled. Refresh or trigger a function call to see logs.');
};
(window as any).disableJSONDebug = () => {
(window as any).__DEBUG_JSON_PARSER = false;
logger.debug('JSON parser debug logging disabled.');
};
}
// --- Exports for potential module usage ---
export {
CONFIG,
styles,
processFunctionCalls,
checkForUnprocessedFunctionCalls,
processFunctionResults,
checkForUnprocessedFunctionResults,
startDirectMonitoring,
stopDirectMonitoring,
startFunctionResultMonitoring,
stopFunctionResultMonitoring,
configure as configureFunctionCallRenderer,
initializeRenderer as initialize,
processUpdateQueue as forceStreamingUpdate,
checkStalledStreams,
detectPreExistingIncompleteBlocks,
};
export type { FunctionCallRendererConfig };
/**
* Storage functionality for executed functions
* This module provides utilities to store and retrieve information about executed functions
* URL-based storage implementation with race condition prevention
*/
// Define the interface for stored function execution data
import { createLogger } from '@extension/shared/lib/logger';
const logger = createLogger('STORAGE_KEY');
export interface ExecutedFunction {
functionName: string; // Name of the executed function
callId: string; // Unique ID for the function call
contentSignature: string; // Hash or signature of the function content
executedAt: number; // Timestamp when the function was executed
params: Record<string, any>; // Parameters used in the function call
}
// Define the URL-based storage structure
interface URLBasedFunctionHistory {
[url: string]: Record<string, ExecutedFunction>; // Key is functionName:callId:contentSignature
}
// Storage key for the executed functions
const STORAGE_KEY = 'mcp_url_based_function_history';
/**
* Store information about an executed function with race condition prevention
*
* @param functionName Name of the executed function
* @param callId Unique ID for the function call
* @param params Parameters used in the function call
* @param contentSignature Hash or signature of the function content
* @returns The stored function data
*/
export const storeExecutedFunction = (
functionName: string,
callId: string,
params: Record<string, any>,
contentSignature: string,
): ExecutedFunction => {
// Get current URL
const url = window.location.href;
// Create the execution record
const executionRecord: ExecutedFunction = {
functionName,
callId,
contentSignature,
executedAt: Date.now(),
params,
};
// Create a unique key for this function execution
const executionKey = generateExecutionKey(functionName, callId, contentSignature);
// Use transaction pattern to prevent race conditions
const storage = getURLBasedStorage();
// Ensure this URL exists in storage
if (!storage[url]) {
storage[url] = {};
}
// Add/update the execution record
storage[url][executionKey] = executionRecord;
// Save back to storage with race condition prevention
try {
const maxRetries = 3;
let retries = 0;
let saved = false;
while (!saved && retries < maxRetries) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(storage));
saved = true;
} catch (error) {
retries++;
// Short delay before retrying
if (retries < maxRetries) {
logger.warn(`Storage write failed, retrying (${retries}/${maxRetries})`);
}
}
}
if (!saved) {
logger.error('Failed to store executed function after multiple attempts');
}
} catch (error) {
logger.error('Failed to store executed function:', error);
}
return executionRecord;
};
/**
* Generate a unique key for function execution tracking
*/
const generateExecutionKey = (functionName: string, callId: string, contentSignature: string): string => {
return `${functionName}:${callId}:${contentSignature}`;
};
/**
* Get URL-based storage data
*
* @returns URL-based function history storage
*/
const getURLBasedStorage = (): URLBasedFunctionHistory => {
try {
const storedData = localStorage.getItem(STORAGE_KEY);
return storedData ? JSON.parse(storedData) : {};
} catch (error) {
logger.error('Failed to retrieve URL-based function history:', error);
return {};
}
};
/**
* Get all stored executed functions (legacy interface for backward compatibility)
*
* @returns Array of executed function records with URL included
*/
export const getExecutedFunctions = (): (ExecutedFunction & { url: string })[] => {
try {
const storage = getURLBasedStorage();
const result: (ExecutedFunction & { url: string })[] = [];
// Convert URL-based structure to flat array
Object.entries(storage).forEach(([url, functions]) => {
Object.values(functions).forEach(func => {
result.push({
...func,
url,
});
});
});
return result;
} catch (error) {
logger.error('Failed to retrieve executed functions:', error);
return [];
}
};
/**
* Get executed functions for the current URL
*
* @returns Array of executed function records for the current URL
*/
export const getExecutedFunctionsForCurrentUrl = (): ExecutedFunction[] => {
const currentUrl = window.location.href;
const storage = getURLBasedStorage();
// Direct access to current URL's functions
if (!storage[currentUrl]) {
return [];
}
return Object.values(storage[currentUrl]);
};
/**
* Get executed functions for a specific URL
*
* @param url The URL to get functions for
* @returns Array of executed function records for the specified URL
*/
export const getExecutedFunctionsForUrl = (url: string): ExecutedFunction[] => {
const storage = getURLBasedStorage();
// Direct access to URL's functions
if (!storage[url]) {
return [];
}
return Object.values(storage[url]);
};
/**
* Check if a function has been previously executed
*
* @param functionName Name of the function
* @param callId Unique ID for the function call
* @param contentSignature Hash or signature of the function content
* @returns The executed function record if found, null otherwise
*/
export const getPreviousExecution = (
functionName: string,
callId: string,
contentSignature: string,
): ExecutedFunction | null => {
const currentUrl = window.location.href;
const storage = getURLBasedStorage();
// Check if URL exists in storage
if (!storage[currentUrl]) {
return null;
}
// Generate the execution key
const executionKey = generateExecutionKey(functionName, callId, contentSignature);
// Direct lookup by key
return storage[currentUrl][executionKey] || null;
};
/**
* Check if a function has been previously executed (backward compatibility version)
*
* @param callId Unique ID for the function call
* @param contentSignature Hash or signature of the function content
* @returns The executed function record if found, null otherwise
*/
export const getPreviousExecutionLegacy = (callId: string, contentSignature: string): ExecutedFunction | null => {
const currentUrl = window.location.href;
const storage = getURLBasedStorage();
// Check if URL exists in storage
if (!storage[currentUrl]) {
return null;
}
// Find by callId and contentSignature
const functionEntry = Object.entries(storage[currentUrl]).find(
([_, func]) => func.callId === callId && func.contentSignature === contentSignature,
);
return functionEntry ? functionEntry[1] : null;
};
/**
* Generate a content signature for a function call
*
* @param functionName Name of the function
* @param params Parameters of the function call
* @returns A string signature representing the function call
*/
export const generateContentSignature = (functionName: string, params: Record<string, any>): string => {
// Create a simple hash of the function name and parameters
try {
// Sort keys of params for deterministic stringification
const sortedParams: Record<string, any> = {};
Object.keys(params)
.sort()
.forEach(key => {
sortedParams[key] = params[key];
});
const content = JSON.stringify({ name: functionName, params: sortedParams });
// Simple hash function for the content
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash.toString(16);
} catch (error) {
logger.error('Failed to generate content signature:', error);
// Fallback to timestamp if hashing fails
return Date.now().toString(16);
}
};
/**
* Format a timestamp to a human-readable date string
*
* @param timestamp Timestamp in milliseconds
* @returns Formatted date string
*/
export const formatExecutionTime = (timestamp: number): string => {
try {
const date = new Date(timestamp);
return date.toLocaleString();
} catch (error) {
return 'Unknown date';
}
};
import { CONFIG } from '../core/config';
import { renderFunctionResult, processedResultElements } from '../renderer/functionResult';
import { createLogger } from '@extension/shared/lib/logger';
// State for processing and observers
const logger = createLogger('FunctionResultObserver');
const isProcessing = false;
let functionResultObserver: MutationObserver | null = null;
/**
* Process all function results in the document
* @returns Number of processed function results
*/
export const processFunctionResults = (): number => {
if (!CONFIG.function_result_selector || CONFIG.function_result_selector.length === 0) {
return 0;
}
return checkForUnprocessedFunctionResults();
};
/**
* Check for unprocessed function results in the document
* @returns Number of processed function results
*/
export const checkForUnprocessedFunctionResults = (): number => {
if (!CONFIG.function_result_selector || CONFIG.function_result_selector.length === 0) {
return 0;
}
const targetElements = getTargetElements();
let processedCount = 0;
// Process each target element
for (const element of targetElements) {
if (!processedResultElements.has(element)) {
const isProcessingRef = { current: isProcessing };
const success = renderFunctionResult(element, isProcessingRef);
if (success) {
processedCount++;
}
}
}
if (CONFIG.debug && processedCount > 0) {
logger.debug(`Processed ${processedCount} function results`);
}
return processedCount;
};
/**
* Get all elements in the document that might contain function results
* @returns Array of HTML elements
*/
const getTargetElements = (): HTMLElement[] => {
if (!CONFIG.function_result_selector || CONFIG.function_result_selector.length === 0) {
return [];
}
const elements: HTMLElement[] = [];
// Get all elements matching the function result selectors
for (const selector of CONFIG.function_result_selector) {
try {
// Handle standard CSS selector
const matches = document.querySelectorAll(selector);
for (const match of matches) {
if (match instanceof HTMLElement) {
elements.push(match);
}
}
// If the selector contains multiple classes, also try to find elements by individual classes
if (selector.includes('.') && selector.includes(' ')) {
// This might be a complex selector with multiple classes
handleComplexSelector(selector, elements);
} else if (selector.startsWith('div.') && selector.split('.').length > 2) {
// This is a div with multiple classes like 'div.class1.class2.class3'
handleMultiClassSelector(selector, elements);
}
} catch (e) {
logger.error(`Invalid selector: ${selector}`, e);
// Try alternative approach for complex selectors
if (selector.includes('.')) {
handleFallbackSelector(selector, elements);
}
}
}
return elements;
};
/**
* Handle a complex selector with multiple parts
* @param selector The complex CSS selector
* @param elements Array to add found elements to
*/
const handleComplexSelector = (selector: string, elements: HTMLElement[]): void => {
// Split by spaces to get individual parts
const parts = selector.split(' ');
// Start with all elements matching the first part
let currentMatches: Element[] = Array.from(document.querySelectorAll(parts[0]));
// For each subsequent part, filter the matches
for (let i = 1; i < parts.length; i++) {
const nextPart = parts[i];
const nextMatches: Element[] = [];
for (const match of currentMatches) {
// Find children matching the next part
const children = match.querySelectorAll(nextPart);
children.forEach(child => nextMatches.push(child));
}
currentMatches = nextMatches;
}
// Add the final matches to the elements array
for (const match of currentMatches) {
if (match instanceof HTMLElement && !elements.includes(match)) {
elements.push(match);
}
}
};
/**
* Handle a selector with multiple classes on a single element
* @param selector The multi-class selector (e.g., 'div.class1.class2.class3')
* @param elements Array to add found elements to
*/
const handleMultiClassSelector = (selector: string, elements: HTMLElement[]): void => {
// Parse the selector to get element type and classes
const [elementType, ...classNames] = selector.split('.');
// Find all elements of the specified type
const allElements = document.querySelectorAll(elementType);
// Filter elements that have all the specified classes
for (const element of allElements) {
if (classNames.every(className => element.classList.contains(className))) {
if (element instanceof HTMLElement && !elements.includes(element)) {
elements.push(element);
}
}
}
};
/**
* Fallback method for handling selectors that might be causing errors
* @param selector The problematic selector
* @param elements Array to add found elements to
*/
const handleFallbackSelector = (selector: string, elements: HTMLElement[]): void => {
if (CONFIG.debug) {
logger.debug(`Using fallback method for selector: ${selector}`);
}
// Try to extract the element type and classes
const match = selector.match(/^([a-z]+)\.(.*)/i);
if (!match) return;
const [, elementType, classesStr] = match;
const classes = classesStr.split('.');
// Find all elements of the specified type
const allElements = document.querySelectorAll(elementType);
// Check each element for the required classes
for (const element of allElements) {
// For complex selectors, we'll be more lenient and match if ANY of the classes match
const hasAnyClass = classes.some(cls => element.classList.contains(cls));
if (hasAnyClass && element instanceof HTMLElement && !elements.includes(element)) {
elements.push(element);
}
}
};
/**
* Handle DOM changes by checking for new function results
*/
const handleDomChanges = (): void => {
setTimeout(() => {
processFunctionResults();
}, 0);
};
/**
* Start direct monitoring of content for function results
*/
export const startFunctionResultMonitoring = (): void => {
if (!CONFIG.function_result_selector || CONFIG.function_result_selector.length === 0) {
if (CONFIG.debug) {
logger.debug('Function result monitoring disabled: no selectors configured');
}
return;
}
if (functionResultObserver) {
stopFunctionResultMonitoring();
}
if (CONFIG.debug) {
logger.debug('Starting function result monitoring');
}
// Initial processing
processFunctionResults();
// Create a new mutation observer
functionResultObserver = new MutationObserver(mutations => {
let shouldProcess = false;
let potentialFunctionResult = false;
// Check if any mutation might contain a function result
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of Array.from(mutation.addedNodes)) {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
// Check if the element matches any function result selector
const isTargetElement = CONFIG.function_result_selector?.some(selector => {
try {
return element.matches(selector);
} catch (e) {
return false;
}
});
// Check if the element contains any elements matching the function result selectors
const hasTargetElements = CONFIG.function_result_selector?.some(selector => {
try {
return element.querySelectorAll(selector).length > 0;
} catch (e) {
return false;
}
});
// Also check if the content of any text nodes might contain function result patterns
if (
element.textContent &&
(element.textContent.includes('<function_result') || element.textContent.includes('</function_result>'))
) {
potentialFunctionResult = true;
}
if (isTargetElement || hasTargetElements || potentialFunctionResult) {
shouldProcess = true;
break;
}
} else if (node.nodeType === Node.TEXT_NODE) {
// Also check text nodes for function result patterns
const textContent = node.textContent || '';
if (textContent.includes('<function_result') || textContent.includes('</function_result>')) {
potentialFunctionResult = true;
shouldProcess = true;
break;
}
}
}
} else if (mutation.type === 'characterData') {
// Check if the characterData mutation might be adding function result content
const textContent = mutation.target.textContent || '';
if (textContent.includes('<function_result') || textContent.includes('</function_result>')) {
potentialFunctionResult = true;
shouldProcess = true;
}
}
if (shouldProcess) break;
}
if (shouldProcess) {
if (potentialFunctionResult && CONFIG.debug) {
logger.debug('Potential function result detected, processing DOM changes');
}
handleDomChanges();
}
});
// Configure the observer to watch for changes to the document
functionResultObserver.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
characterDataOldValue: true,
});
if (CONFIG.debug) {
logger.debug('Function result monitoring started');
}
};
/**
* Stop direct monitoring of content for function results
*/
export const stopFunctionResultMonitoring = (): void => {
if (functionResultObserver) {
functionResultObserver.disconnect();
functionResultObserver = null;
if (CONFIG.debug) {
logger.debug('Function result monitoring stopped');
}
}
};
/**
* Initialize the observer for function results
*/
export const initializeFunctionResultObserver = (): void => {
if (!CONFIG.function_result_selector || CONFIG.function_result_selector.length === 0) {
if (CONFIG.debug) {
logger.debug('Function result observer not initialized: no selectors configured');
}
return;
}
startFunctionResultMonitoring();
};
// Observer functionality exports
import {
processUpdateQueue,
processFunctionCalls,
checkForUnprocessedFunctionCalls,
startDirectMonitoring,
stopDirectMonitoring,
initializeObserver,
} from './mutationObserver';
import {
processFunctionResults,
checkForUnprocessedFunctionResults,
startFunctionResultMonitoring,
stopFunctionResultMonitoring,
initializeFunctionResultObserver,
} from './functionResultObserver';
import {
checkStalledStreams,
detectPreExistingIncompleteBlocks,
preExistingIncompleteBlocks,
startStalledStreamDetection,
stopStalledStreamDetection,
updateStalledStreamTimeoutConfig,
} from './stalledStreamHandler';
import { checkStreamingUpdates } from './streamObserver';
// Re-export only the functions that need to be public
export {
// Main functions
processFunctionCalls,
checkForUnprocessedFunctionCalls,
startDirectMonitoring,
stopDirectMonitoring,
initializeObserver,
// Function result functions
processFunctionResults,
checkForUnprocessedFunctionResults,
startFunctionResultMonitoring,
stopFunctionResultMonitoring,
initializeFunctionResultObserver,
// Streaming and updates
processUpdateQueue,
checkStreamingUpdates,
// Stalled streams
checkStalledStreams,
detectPreExistingIncompleteBlocks,
preExistingIncompleteBlocks,
startStalledStreamDetection,
stopStalledStreamDetection,
updateStalledStreamTimeoutConfig,
};
import { CONFIG } from '../core/config';
import { debounce } from '../utils/index';
import { renderFunctionCall, renderedFunctionBlocks, processedElements } from '../renderer/index';
import { stabilizeBlock, unstabilizeBlock } from '../renderer/components';
import {
monitorNode,
streamingObservers,
updateQueue,
streamingLastUpdated,
startProgressiveUpdates,
} from './streamObserver';
import type { StabilizedBlock } from '../core/types';
import { streamingContentLengths } from '../parser/index';
import {
preExistingIncompleteBlocks,
startStalledStreamDetection,
stopStalledStreamDetection,
} from './stalledStreamHandler';
import { createLogger } from '@extension/shared/lib/logger';
// State for processing and observers
let isProcessing = false;
let functionCallObserver: MutationObserver | null = null;
let updateThrottleTimer: ReturnType<typeof setTimeout> | null = null;
const logger = createLogger('MutationObserver');
// Extend window type
declare global {
interface Window {
_isProcessing?: boolean;
_updateQueue?: Map<string, HTMLElement>;
_stalledStreams?: Set<string>;
_stalledStreamRetryCount?: Map<string, number>;
_processUpdateQueue?: () => void;
preExistingIncompleteBlocks?: Set<string>;
}
}
/**
* Process queued updates with stabilization for smoothness
*/
export const processUpdateQueue = (): void => {
const validUpdates = new Map<string, HTMLElement>();
updateQueue.forEach((node, blockId) => {
if (document.body.contains(node)) {
validUpdates.set(blockId, node);
// Update the last updated timestamp
streamingLastUpdated.set(blockId, Date.now());
// If previously marked as stalled, remove that marker
if (window._stalledStreams && window._stalledStreams.has(blockId)) {
window._stalledStreams.delete(blockId);
const stalledIndicator = document.querySelector(`.stalled-indicator[data-stalled-for="${blockId}"]`);
if (stalledIndicator) stalledIndicator.remove();
}
} else {
if (CONFIG.debug) logger.debug(`Node for block ${blockId} removed, skipping update and cleaning up.`);
const observer = streamingObservers.get(blockId);
if (observer) {
observer.disconnect();
streamingObservers.delete(blockId);
}
renderedFunctionBlocks.delete(blockId);
streamingLastUpdated.delete(blockId);
if (window._stalledStreams) window._stalledStreams.delete(blockId);
// Clean up keys starting with blockId- from streamingContentLengths
Array.from(streamingContentLengths.keys())
.filter(key => key.startsWith(`${blockId}-`))
.forEach(key => streamingContentLengths.delete(key));
}
});
if (validUpdates.size === 0) {
updateQueue.clear();
if (isProcessing) {
isProcessing = false;
window._isProcessing = false;
}
return;
}
updateQueue.clear();
validUpdates.forEach((node, blockId) => updateQueue.set(blockId, node));
if (isProcessing) return;
isProcessing = true;
window._isProcessing = true;
const stabilizedBlocks = new Map<string, StabilizedBlock>();
try {
// if (CONFIG.usePositionFixed) {
// updateQueue.forEach((node, blockId) => {
// const stabilized = stabilizeBlock(blockId);
// if (stabilized) {
// stabilizedBlocks.set(blockId, stabilized);
// }
// });
// }
updateQueue.forEach((node, blockId) => {
if (CONFIG.debug) logger.debug(`Processing update for block: ${blockId}`);
renderFunctionCall(node as HTMLPreElement, { current: isProcessing });
});
updateQueue.clear();
const hasLargeStreaming = Array.from(streamingContentLengths.values()).some(
length => length > CONFIG.largeContentThreshold,
);
if (hasLargeStreaming) {
startProgressiveUpdates();
}
} catch (e) {
logger.error('Error processing update queue:', e);
} finally {
// if (stabilizedBlocks.size > 0) {
// setTimeout(() => {
// stabilizedBlocks.forEach((stabilized) => {
// unstabilizeBlock(stabilized);
// });
// }, CONFIG.stabilizeTimeout);
// }
isProcessing = false;
window._isProcessing = false;
}
};
// Expose shared state to window
if (typeof window !== 'undefined') {
window._isProcessing = isProcessing;
window._updateQueue = updateQueue;
window._stalledStreams = window._stalledStreams || new Set<string>();
window._stalledStreamRetryCount = window._stalledStreamRetryCount || new Map<string, number>();
window.preExistingIncompleteBlocks = preExistingIncompleteBlocks;
window._processUpdateQueue = processUpdateQueue;
}
/**
* Process all function calls in the document
*/
export const processFunctionCalls = (): number => {
const processedCount = checkForUnprocessedFunctionCalls();
return processedCount;
};
/**
* Check for unprocessed function calls in the document
*/
export const checkForUnprocessedFunctionCalls = (): number => {
let processedCount = 0;
// Get all pre/code elements in the document that might contain function calls
const getTargetElements = (): HTMLElement[] => {
const elements: HTMLElement[] = [];
for (const selector of CONFIG.targetSelectors) {
const found = document.querySelectorAll<HTMLElement>(selector);
elements.push(...Array.from(found));
}
return elements;
};
// Process each target element
const elements = getTargetElements();
for (const element of elements) {
if (!processedElements.has(element) && !element.closest('.function-block')) {
const blockId =
element.getAttribute('data-block-id') || `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const result = renderFunctionCall(element as HTMLPreElement, { current: false });
if (result) {
processedCount++;
monitorNode(element, blockId);
}
}
}
return processedCount;
};
/**
* Start direct monitoring of content for function calls
*/
export const startDirectMonitoring = (): void => {
if (!CONFIG.enableDirectMonitoring) return;
// Start with a clean slate
stopDirectMonitoring();
// Process any existing function calls
processFunctionCalls();
// Set up stalled stream detection
startStalledStreamDetection();
// Define a function to observe DOM changes and process new function calls
const handleDomChanges = debounce(() => {
if (!isProcessing) {
const processedCount = checkForUnprocessedFunctionCalls();
if (processedCount > 0 && CONFIG.debug) {
logger.debug(`Processed ${processedCount} new function blocks`);
}
}
}, CONFIG.updateThrottle);
// Create a mutation observer to watch for changes to the DOM
functionCallObserver = new MutationObserver(mutations => {
let shouldProcess = false;
let potentialFunctionCall = false;
for (const mutation of mutations) {
// Check if any added nodes might contain function calls
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
for (const node of Array.from(mutation.addedNodes)) {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
// Check for target elements or containers
const isTargetElement = CONFIG.targetSelectors.some(selector => element.matches(selector));
const hasTargetElements = element.querySelectorAll(CONFIG.targetSelectors.join(',')).length > 0;
// Check for streaming container elements
const isStreamingContainer = CONFIG.streamingContainerSelectors.some(selector => element.matches(selector));
const hasStreamingContainers =
element.querySelectorAll(CONFIG.streamingContainerSelectors.join(',')).length > 0;
// Also check if the content of any text nodes might contain function call patterns (XML or JSON)
if (element.textContent) {
const hasXMLPattern =
element.textContent.includes('<function_calls>') ||
element.textContent.includes('<invoke');
// Be lenient for JSON - allow partial/streaming content
const looksLikeJSONStart = element.textContent.trim().startsWith('{');
const hasJSONPattern =
(element.textContent.includes('"type"') &&
(element.textContent.includes('function_call') || element.textContent.includes('parameter'))) ||
(looksLikeJSONStart && element.textContent.length < 50);
if (hasXMLPattern || hasJSONPattern) {
potentialFunctionCall = true;
}
}
if (
isTargetElement ||
hasTargetElements ||
isStreamingContainer ||
hasStreamingContainers ||
potentialFunctionCall
) {
shouldProcess = true;
break;
}
} else if (node.nodeType === Node.TEXT_NODE) {
// Also check text nodes for function call patterns (XML or JSON)
const textContent = node.textContent || '';
const hasXMLPattern =
textContent.includes('<function_calls>') ||
textContent.includes('<invoke');
// Be lenient for JSON - allow partial/streaming content
const looksLikeJSONStart = textContent.trim().startsWith('{');
const hasJSONPattern =
(textContent.includes('"type"') &&
(textContent.includes('function_call') || textContent.includes('parameter'))) ||
(looksLikeJSONStart && textContent.length < 50);
if (hasXMLPattern || hasJSONPattern) {
potentialFunctionCall = true;
shouldProcess = true;
break;
}
}
}
} else if (mutation.type === 'characterData') {
// Check if the characterData mutation might be adding function call content (XML or JSON)
const textContent = mutation.target.textContent || '';
const hasXMLPattern =
textContent.includes('<function_calls>') ||
textContent.includes('<invoke');
// Be lenient for JSON detection - allow partial/streaming content
// Check if it looks like JSON start, not just complete patterns
const looksLikeJSONStart = textContent.trim().startsWith('{') || textContent.trim().startsWith('[');
const hasJSONPattern =
(textContent.includes('"type"') &&
(textContent.includes('function_call') || textContent.includes('parameter'))) ||
(looksLikeJSONStart && textContent.length < 50); // Allow short JSON-like content
if (hasXMLPattern || hasJSONPattern) {
potentialFunctionCall = true;
shouldProcess = true;
}
}
if (shouldProcess) break;
}
if (shouldProcess) {
if (potentialFunctionCall && CONFIG.debug) {
logger.debug('Potential function call detected, processing DOM changes');
}
handleDomChanges();
}
});
// Configure the observer to watch for changes to the document
functionCallObserver.observe(document.body, {
childList: true,
subtree: true,
characterData: true, // Also watch for text content changes
characterDataOldValue: true, // Keep old values for comparison
});
if (CONFIG.debug) logger.debug('Direct monitoring started for function calls');
};
/**
* Stop direct monitoring of content for function calls
*/
export const stopDirectMonitoring = (): void => {
// Disconnect the main mutation observer
if (functionCallObserver) {
functionCallObserver.disconnect();
functionCallObserver = null;
}
// Disconnect all streaming observers
streamingObservers.forEach(observer => observer.disconnect());
streamingObservers.clear();
// Clear the streaming update timer
if (updateThrottleTimer) {
clearTimeout(updateThrottleTimer);
updateThrottleTimer = null;
}
// Stop stalled stream detection
stopStalledStreamDetection();
if (CONFIG.debug) logger.debug('Direct monitoring stopped for function calls');
};
/**
* Initialize the observer for function calls
*/
export const initializeObserver = (): void => {
if (CONFIG.enableDirectMonitoring) {
startDirectMonitoring();
}
};
import { CONFIG } from '../core/config';
import { streamingLastUpdated, checkStreamingUpdates } from './streamObserver';
import { renderedFunctionBlocks, renderFunctionCall } from '../renderer/index';
import { createLogger } from '@extension/shared/lib/logger';
// Extend Window interface to include our custom properties
const logger = createLogger('StalledStreamHandler');
declare global {
interface Window {
_isProcessing?: boolean;
_stalledStreams?: Set<string>;
_stalledStreamRetryCount?: Map<string, number>;
_updateQueue?: Map<string, HTMLElement>;
_processUpdateQueue?: () => void;
preExistingIncompleteBlocks?: Set<string>;
customRenderEvent?: boolean;
}
}
// Tracking stalled streams
export const stalledStreams = new Set<string>(); // Set of stalled blockIds
export const stalledStreamRetryCount = new Map<string, number>(); // Track retry attempts
export const preExistingIncompleteBlocks = new Set<string>(); // Track function calls that were incomplete at page load
// Timer for stalled stream detection
export let stalledStreamCheckTimer: ReturnType<typeof setInterval> | null = null;
/**
* Create an SVG element with the specified paths for stalled messages
*
* @param isPreExisting Whether to create a clock icon (pre-existing) or warning icon (stalled)
* @returns SVG element
*/
const createStalledSvgIcon = (isPreExisting: boolean): SVGSVGElement => {
// Create SVG element
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', '16');
svg.setAttribute('height', '16');
svg.setAttribute('viewBox', '0 0 16 16');
svg.setAttribute('fill', 'none');
if (isPreExisting) {
// Clock icon for pre-existing incomplete blocks
const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path1.setAttribute('d', 'M8 15A7 7 0 108 1a7 7 0 000 14z');
path1.setAttribute('stroke', 'currentColor');
path1.setAttribute('stroke-opacity', '0.8');
path1.setAttribute('stroke-width', '1.5');
path1.setAttribute('fill', 'none');
const path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path2.setAttribute('d', 'M8 4v4.5l3 1.5');
path2.setAttribute('stroke', 'currentColor');
path2.setAttribute('stroke-opacity', '0.8');
path2.setAttribute('stroke-width', '1.5');
path2.setAttribute('stroke-linecap', 'round');
path2.setAttribute('stroke-linejoin', 'round');
path2.setAttribute('fill', 'none');
svg.appendChild(path1);
svg.appendChild(path2);
} else {
// Warning icon for stalled streams
const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path1.setAttribute('d', 'M7.5 1.5l-5.6 8.8c-.5.8.1 1.7 1 1.7h11.2c.9 0 1.5-.9 1-1.7l-5.6-8.8c-.5-.7-1.5-.7-2 0z');
path1.setAttribute('stroke', 'currentColor');
path1.setAttribute('stroke-width', '1.5');
path1.setAttribute('fill', 'none');
const path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path2.setAttribute('d', 'M8 6v2.5');
path2.setAttribute('stroke', 'currentColor');
path2.setAttribute('stroke-width', '1.5');
path2.setAttribute('stroke-linecap', 'round');
path2.setAttribute('fill', 'none');
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', '8');
circle.setAttribute('cy', '11');
circle.setAttribute('r', '0.75');
circle.setAttribute('fill', 'currentColor');
svg.appendChild(path1);
svg.appendChild(path2);
svg.appendChild(circle);
}
return svg;
};
/**
* Detect pre-existing incomplete blocks at page load
*/
export const detectPreExistingIncompleteBlocks = (): void => {
if (!document.body) return; // Guard against calling before document.body is available
// Find all function blocks that are incomplete
const functionBlocks = document.querySelectorAll('.function-block.function-loading');
// Also try to find potential function call content that might not be properly marked yet
const potentialFunctionCalls = Array.from(document.querySelectorAll('pre, code')).filter(el => {
const content = el.textContent || '';
return content.includes('<function_calls>') || content.includes('<invoke') || content.includes('<parameter');
});
// Create a set of elements to process
const elementsToProcess = new Set([...Array.from(functionBlocks), ...potentialFunctionCalls]);
// Mark each block as pre-existing incomplete
for (const block of elementsToProcess) {
const blockId =
block.getAttribute('data-block-id') || `pre-existing-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// Set block ID if not already present
if (!block.getAttribute('data-block-id')) {
block.setAttribute('data-block-id', blockId);
}
preExistingIncompleteBlocks.add(blockId);
// Add an indicator to the block if needed
if (!block.querySelector(`.stalled-indicator[data-pre-existing="true"]`)) {
const indicator = document.createElement('div');
indicator.className = 'stalled-indicator';
indicator.setAttribute('data-stalled-for', blockId);
indicator.setAttribute('data-pre-existing', 'true');
const message = document.createElement('div');
message.className = 'stalled-message';
// Create the SVG icon using DOM methods
const svg = createStalledSvgIcon(true);
// Create and append the message text
const span = document.createElement('span');
span.textContent = 'This function call was incomplete when the page loaded.';
// Append elements
message.appendChild(svg);
message.appendChild(span);
indicator.appendChild(message);
block.appendChild(indicator);
}
// Try to initiate rendering for this block
const event = new CustomEvent('render-function-call', {
detail: { blockId, element: block },
});
document.dispatchEvent(event);
}
};
/**
* Create a stalled indicator for the specified block
*/
export const createStalledIndicator = (blockId: string, block: HTMLElement, isAbrupt: boolean = false): void => {
// Mark as stalled
stalledStreams.add(blockId);
stalledStreamRetryCount.set(blockId, 0);
// Remove the loading spinner
block.classList.remove('function-loading');
// Add a stalled class for styling
block.classList.add('function-stalled');
// Remove any existing stalled indicators to avoid duplicates
const existingIndicator = block.querySelector(`.stalled-indicator[data-stalled-for="${blockId}"]`);
if (existingIndicator) {
existingIndicator.remove();
}
// Add visual indicator
const indicator = document.createElement('div');
indicator.className = 'stalled-indicator';
indicator.setAttribute('data-stalled-for', blockId);
// Add a specific class if the stream ended abruptly
if (isAbrupt) {
indicator.classList.add('abruptly-ended-indicator');
}
const message = document.createElement('div');
message.className = 'stalled-message';
// Create the message text
const span = document.createElement('span');
span.textContent = isAbrupt
? 'Stream ended abruptly. Function call may be incomplete.'
: 'Stream appears to be stalled. Updates may be incomplete.';
// Only add the icon if it's a stalled stream, not an abruptly ended one
if (!isAbrupt) {
const svg = createStalledSvgIcon(false); // False indicates warning icon
message.appendChild(svg);
}
message.appendChild(span);
// Add a retry button
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);
};
indicator.appendChild(message);
indicator.appendChild(retryButton);
// Append the indicator to the end of the block
block.appendChild(indicator);
};
/**
* Check for stalled streams
*/
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('<function_calls>') ||
content.includes('<invoke') ||
(content.includes('<parameter') && !content.includes('</parameter>'))
);
});
// 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) {
logger.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)
logger.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('</function_calls>');
const invokeCompleteCheck = (block.textContent || '').includes('<invoke')
? (block.textContent || '').includes('</invoke>')
: true;
// If the function call or invoke tags are complete, don't mark as stalled
if (functionCallCompleteCheck && invokeCompleteCheck) {
if (CONFIG.debug)
logger.debug(`Block ${blockId} appears complete despite loading status, skipping stalled indicator`);
streamingLastUpdated.delete(blockId);
return;
}
// Create stalled indicator for this block
createStalledIndicator(blockId, block, false);
}
});
};
// We should also update the stalled timeout configuration to be more aggressive
export const updateStalledStreamTimeoutConfig = (): void => {
// Set a more aggressive timeout to catch abruptly stopped streams faster
if (CONFIG.stalledStreamTimeout > 3000) {
CONFIG.stalledStreamTimeout = 3000; // 3 seconds is enough to detect abrupt stops
}
// Ensure check interval is frequent enough
if (CONFIG.stalledStreamCheckInterval > 1000) {
CONFIG.stalledStreamCheckInterval = 1000;
}
};
/**
* Start monitoring for stalled streams
*/
export const startStalledStreamDetection = (): void => {
if (!CONFIG.enableStalledStreamDetection) return;
// Update timeout configuration for better detection
updateStalledStreamTimeoutConfig();
// Clear any existing timer
if (stalledStreamCheckTimer) {
clearInterval(stalledStreamCheckTimer);
}
// Create a custom event for rendering function calls
if (typeof window !== 'undefined' && !window.hasOwnProperty('customRenderEvent')) {
window.customRenderEvent = true;
// Event listener for render-function-call events
document.addEventListener('render-function-call', (event: Event) => {
const detail = (event as CustomEvent).detail;
if (detail && detail.element) {
// Attempt to render this function call directly using the imported function
renderFunctionCall(detail.element, { current: false });
}
});
// Event listener for abruptly ended streams
document.addEventListener('stream-abruptly-ended', (event: Event) => {
const detail = (event as CustomEvent).detail;
if (detail && detail.element && detail.blockId) {
if (CONFIG.debug) {
logger.debug('Handling abruptly ended stream', detail);
}
// Create a stalled indicator with the abrupt message
createStalledIndicator(detail.blockId, detail.element, true);
}
});
}
// Add styles for the stalled state
addStalledStreamStyles();
// Set up detection at the configured interval
// stalledStreamCheckTimer = setInterval(
// checkStalledStreams,
// CONFIG.stalledStreamCheckInterval
// );
// Detect pre-existing incomplete blocks on startup
detectPreExistingIncompleteBlocks();
};
/**
* Stop monitoring for stalled streams
*/
export const stopStalledStreamDetection = (): void => {
if (stalledStreamCheckTimer) {
clearInterval(stalledStreamCheckTimer);
stalledStreamCheckTimer = null;
}
};
/**
* Add styles for stalled streams
*/
const addStalledStreamStyles = (): void => {
// Check if styles already exist
if (document.getElementById('stalled-stream-styles')) return;
const styles = document.createElement('style');
styles.id = 'stalled-stream-styles';
styles.textContent = `
.function-stalled .stalled-indicator {
background-color: rgba(255, 200, 0, 0.1);
border-left: 3px solid #ff9800;
padding: 10px;
margin: 10px 0;
border-radius: 4px;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}
.function-stalled .abruptly-ended-indicator {
/* Optional: Style abruptly ended indicators differently */
/* background-color: rgba(255, 100, 0, 0.1); */
/* border-left-color: #e65100; */
border-top: 1px solid rgba(255, 152, 0, 0.3); /* Add a top border */
border-left: none; /* Remove left border */
margin-top: 15px; /* Add some space above */
padding-top: 15px; /* Add padding above */
}
.function-stalled .stalled-message {
display: flex;
align-items: center;
color: #e65100;
font-size: 14px;
margin-bottom: 8px;
}
.function-stalled .stalled-message svg {
margin-right: 8px;
flex-shrink: 0;
}
.function-stalled .stalled-retry-button {
background-color: #ff9800;
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.function-stalled .stalled-retry-button:hover {
background-color: #f57c00;
}
`;
document.head.appendChild(styles);
};
// Declare global window properties for TypeScript
import { createLogger } from '@extension/shared/lib/logger';
const logger = createLogger('streamingObservers');
declare global {
interface Window {
_isProcessing?: boolean;
_stalledStreams?: Set<string>;
_stalledStreamRetryCount?: Map<string, number>;
_updateQueue?: Map<string, HTMLElement>;
_processUpdateQueue?: () => void;
}
}
// Import required functions
import { CONFIG } from '../core/config';
import { renderFunctionCall } from '../renderer/index';
import { extractParameters, containsFunctionCalls, extractLanguageTag } from '../parser/index';
import { extractJSONParameters } from '../parser/jsonFunctionParser';
// Maps to store observers and state for streaming content
export const streamingObservers = new Map<string, MutationObserver>();
export const streamingLastUpdated = new Map<string, number>(); // blockId -> timestamp
export const updateQueue = new Map<string, HTMLElement>(); // Store target elements (pre, code, etc.)
// A flag to indicate if updates are currently being processed
const isProcessing = false;
// Flag to detect abrupt ending of streams
export const abruptlyEndedStreams = new Set<string>();
// Map to track which blocks are currently resyncing to prevent jitter
export const resyncingBlocks = new Set<string>();
// Map to track which blocks have completed streaming
export const completedStreams = new Map<string, boolean>();
// Track completion stability to prevent rapid state changes that cause jitter
const completionStabilityTracker = new Map<
string,
{
lastCheckTime: number;
isStable: boolean;
consecutiveCompletionChecks: number;
}
>();
// Minimum time between completion checks to ensure stability
const COMPLETION_STABILITY_THRESHOLD = 200; // 200ms
const REQUIRED_STABLE_CHECKS = 2; // Require 2 consecutive stable checks
/**
* Check if completion state is stable to prevent jitter
*/
const isCompletionStable = (blockId: string): boolean => {
const now = Date.now();
const tracker = completionStabilityTracker.get(blockId);
if (!tracker) {
completionStabilityTracker.set(blockId, {
lastCheckTime: now,
isStable: false,
consecutiveCompletionChecks: 1,
});
return false;
}
// Check if enough time has passed since last check
if (now - tracker.lastCheckTime < COMPLETION_STABILITY_THRESHOLD) {
return false; // Too soon, not stable
}
// Increment consecutive checks
tracker.consecutiveCompletionChecks++;
tracker.lastCheckTime = now;
// Consider stable after required number of checks
if (tracker.consecutiveCompletionChecks >= REQUIRED_STABLE_CHECKS) {
tracker.isStable = true;
return true;
}
return false;
};
// Performance cache for pattern matching
const PATTERN_CACHE = {
functionCallsStart: /<function_calls>/g,
functionCallsEnd: /<\/function_calls>/g,
invokeStart: /<invoke[^>]*>/g,
invokeEnd: /<\/invoke>/g,
parameterStart: /<parameter[^>]*>/g,
parameterEnd: /<\/parameter>/g,
allFunctionPatterns: /(<function_calls>|<\/function_calls>|<invoke[^>]*>|<\/invoke>|<parameter[^>]*>|<\/parameter>)/g,
};
// Fast content analysis cache to avoid repeated parsing
const contentAnalysisCache = new Map<
string,
{
hasFunction: boolean;
isComplete: boolean;
timestamp: number;
}
>();
// Debounced rendering to prevent rapid-fire updates
const renderingDebouncer = new Map<string, number>();
const RENDER_DEBOUNCE_MS = 50; // 50ms debounce for smooth rendering
// Make resyncingBlocks globally accessible to prevent re-rendering during resync
if (typeof window !== 'undefined') {
(window as any).resyncingBlocks = resyncingBlocks;
}
// Fast chunk detection system for immediate response
const CHUNK_PATTERNS = {
functionStart: /<function_calls>/,
invokeStart: /<invoke\s+name="[^"]*"/,
parameterStart: /<parameter\s+name="[^"]*">/,
anyClosingTag: /<\/(?:function_calls|invoke|parameter)>/,
// Pre-compiled for faster detection
functionChunkStart: /(<function_calls>|<invoke\s+name="[^"]*"|<parameter\s+name="[^"]*">)/,
significantChunk: /(<function_calls>|<invoke|<parameter|<\/)/,
// JSON patterns - enhanced for Unicode support
jsonFunctionStart: /"type"\s*:\s*"function_call_start"/,
jsonParameter: /"type"\s*:\s*"parameter"/,
jsonDescription: /"type"\s*:\s*"description"/,
jsonFunctionEnd: /"type"\s*:\s*"function_call_end"/,
jsonSignificant: /"type"\s*:\s*"(?:function_call_start|parameter|description|function_call_end)"/,
// Enhanced patterns for partial/incomplete JSON during streaming
jsonPartialFunctionCall: /"type"\s*:\s*"function_call_/,
jsonPartialParameter: /"type"\s*:\s*"parameter"/,
};
// Track parameter content during streaming to prevent loss
const parameterContentCache = new Map<string, Map<string, string>>(); // blockId -> paramName -> content
/**
* Store parameter content to prevent loss during streaming (supports both XML and JSON)
*/
const cacheParameterContent = (blockId: string, content: string): void => {
// Detect format
const isJSON =
content.includes('"type"') && (content.includes('function_call_start') || content.includes('parameter'));
let params;
if (isJSON) {
// Extract JSON parameters
const jsonParams = extractJSONParameters(content);
// Convert to array format
params = Object.entries(jsonParams).map(([name, value]) => ({
name,
value: typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value),
}));
} else {
// Extract XML parameters
params = extractParameters(content);
}
if (params.length > 0) {
const blockCache = parameterContentCache.get(blockId) || new Map();
params.forEach(param => {
// Only update if new content is longer (more complete)
const existing = blockCache.get(param.name) || '';
if (param.value.length > existing.length) {
blockCache.set(param.name, param.value);
}
});
parameterContentCache.set(blockId, blockCache);
if (CONFIG.debug) {
logger.debug(
`Cached ${isJSON ? 'JSON' : 'XML'} parameter content for ${blockId}:`,
Array.from(blockCache.entries()),
);
}
}
};
/**
* Get cached parameter content to prevent loss
*/
const getCachedParameterContent = (blockId: string): Map<string, string> => {
return parameterContentCache.get(blockId) || new Map();
};
/**
* Ultra-fast chunk detection for immediate streaming response
* Enhanced to handle Unicode characters and partial JSON during streaming
*/
const detectFunctionChunk = (
content: string,
previousContent: string = '',
): {
hasNewChunk: boolean;
chunkType: 'function_start' | 'invoke' | 'parameter' | 'closing' | 'content' | null;
isSignificant: boolean;
} => {
// Get only the new content since last check
const newContent = content.slice(previousContent.length);
if (newContent.length === 0) {
return { hasNewChunk: false, chunkType: null, isSignificant: false };
}
// Check for JSON patterns first - handle partial matches during streaming
if (CHUNK_PATTERNS.jsonFunctionStart.test(newContent)) {
return { hasNewChunk: true, chunkType: 'function_start', isSignificant: true };
}
if (CHUNK_PATTERNS.jsonParameter.test(newContent)) {
return { hasNewChunk: true, chunkType: 'parameter', isSignificant: true };
}
if (CHUNK_PATTERNS.jsonDescription.test(newContent)) {
return { hasNewChunk: true, chunkType: 'content', isSignificant: true };
}
if (CHUNK_PATTERNS.jsonFunctionEnd.test(newContent)) {
return { hasNewChunk: true, chunkType: 'closing', isSignificant: true };
}
// Enhanced: Check for partial/incomplete JSON function calls during streaming
// This catches cases where "function_call" is being typed character by character
if (CHUNK_PATTERNS.jsonPartialFunctionCall.test(newContent)) {
return { hasNewChunk: true, chunkType: 'content', isSignificant: newContent.length > 30 };
}
// Check for XML patterns
if (CHUNK_PATTERNS.functionStart.test(newContent)) {
return { hasNewChunk: true, chunkType: 'function_start', isSignificant: true };
}
if (CHUNK_PATTERNS.invokeStart.test(newContent)) {
return { hasNewChunk: true, chunkType: 'invoke', isSignificant: true };
}
if (CHUNK_PATTERNS.parameterStart.test(newContent)) {
return { hasNewChunk: true, chunkType: 'parameter', isSignificant: true };
}
if (CHUNK_PATTERNS.anyClosingTag.test(newContent)) {
return { hasNewChunk: true, chunkType: 'closing', isSignificant: true };
}
// Check if it's any significant content (XML or JSON)
// Enhanced to handle Unicode content length detection
const contentLength = [...newContent].length; // Count Unicode characters correctly
if (
CHUNK_PATTERNS.significantChunk.test(newContent) ||
CHUNK_PATTERNS.jsonSignificant.test(newContent) ||
contentLength > 20
) {
return { hasNewChunk: true, chunkType: 'content', isSignificant: contentLength > 20 };
}
return { hasNewChunk: false, chunkType: null, isSignificant: false };
};
// Track previous content for chunk detection
const previousContentCache = new Map<string, string>();
/**
* Immediate chunk processor for instant response
*/
const processChunkImmediate = (
blockId: string,
newContent: string,
chunkInfo: ReturnType<typeof detectFunctionChunk>,
): void => {
if (!chunkInfo.hasNewChunk || !chunkInfo.isSignificant) return;
// Find target element immediately
const target = document.querySelector(`div[data-block-id="${blockId}"]`) as HTMLElement;
if (!target) return;
// Skip if already processing or complete
if (completedStreams.has(blockId) || resyncingBlocks.has(blockId)) return;
if (CONFIG.debug) {
logger.debug(
`Immediate chunk detected for ${blockId}: ${chunkInfo.chunkType}, content length: ${newContent.length}`,
);
}
// For parameter content, use longer delays to allow content to accumulate
let delay = 25; // Default delay
if (chunkInfo.chunkType === 'function_start') {
delay = 10; // Very fast for function starts
} else if (chunkInfo.chunkType === 'parameter') {
delay = 100; // Longer delay for parameters to accumulate content
} else if (chunkInfo.chunkType === 'content') {
delay = 150; // Even longer for parameter content
}
// Use immediate scheduling with appropriate delay for chunk type
const timer = setTimeout(() => {
if (!completedStreams.has(blockId) && !resyncingBlocks.has(blockId)) {
const targetQueue = window._updateQueue || updateQueue;
targetQueue.set(blockId, target);
if (typeof window !== 'undefined' && window._processUpdateQueue) {
window._processUpdateQueue();
}
}
}, delay);
// Clear any existing timer and set new one
const existingTimer = renderingDebouncer.get(blockId);
if (existingTimer) clearTimeout(existingTimer);
renderingDebouncer.set(blockId, timer);
};
/**
* Fast content analysis using pre-compiled patterns and caching
*/
const analyzeFunctionContent = (
content: string,
useCache: boolean = true,
): {
hasFunction: boolean;
isComplete: boolean;
functionCallPattern: boolean;
} => {
if (useCache) {
const cached = contentAnalysisCache.get(content);
if (cached && Date.now() - cached.timestamp < 1000) {
// Cache for 1 second
return {
hasFunction: cached.hasFunction,
isComplete: cached.isComplete,
functionCallPattern: cached.hasFunction,
};
}
}
// Check for JSON format first
const hasJSONStart = CHUNK_PATTERNS.jsonFunctionStart.test(content);
const hasJSONEnd = CHUNK_PATTERNS.jsonFunctionEnd.test(content);
const hasJSONParam = CHUNK_PATTERNS.jsonParameter.test(content);
if (hasJSONStart || hasJSONParam) {
const result = {
hasFunction: true,
isComplete: hasJSONStart && hasJSONEnd,
functionCallPattern: true,
};
if (useCache) {
contentAnalysisCache.set(content, { ...result, timestamp: Date.now() });
}
return result;
}
// Reset regex states for accurate matching (XML)
PATTERN_CACHE.functionCallsStart.lastIndex = 0;
PATTERN_CACHE.functionCallsEnd.lastIndex = 0;
PATTERN_CACHE.invokeStart.lastIndex = 0;
PATTERN_CACHE.invokeEnd.lastIndex = 0;
PATTERN_CACHE.parameterStart.lastIndex = 0;
PATTERN_CACHE.parameterEnd.lastIndex = 0;
// Fast pattern detection using pre-compiled regex
const hasFunctionCalls = PATTERN_CACHE.functionCallsStart.test(content);
const hasInvoke = PATTERN_CACHE.invokeStart.test(content);
const hasParameter = PATTERN_CACHE.parameterStart.test(content);
const hasFunction = hasFunctionCalls || hasInvoke || hasParameter;
if (!hasFunction) {
const result = { hasFunction: false, isComplete: false, functionCallPattern: false };
if (useCache) {
contentAnalysisCache.set(content, { ...result, timestamp: Date.now() });
}
return result;
}
// Check completion using efficient counting
PATTERN_CACHE.functionCallsStart.lastIndex = 0;
PATTERN_CACHE.functionCallsEnd.lastIndex = 0;
PATTERN_CACHE.invokeStart.lastIndex = 0;
PATTERN_CACHE.invokeEnd.lastIndex = 0;
PATTERN_CACHE.parameterStart.lastIndex = 0;
PATTERN_CACHE.parameterEnd.lastIndex = 0;
const functionCallsOpen = (content.match(PATTERN_CACHE.functionCallsStart) || []).length;
const functionCallsClosed = (content.match(PATTERN_CACHE.functionCallsEnd) || []).length;
const invokeOpen = (content.match(PATTERN_CACHE.invokeStart) || []).length;
const invokeClosed = (content.match(PATTERN_CACHE.invokeEnd) || []).length;
const parameterOpen = (content.match(PATTERN_CACHE.parameterStart) || []).length;
const parameterClosed = (content.match(PATTERN_CACHE.parameterEnd) || []).length;
const isComplete =
functionCallsOpen <= functionCallsClosed && invokeOpen <= invokeClosed && parameterOpen <= parameterClosed;
const result = { hasFunction, isComplete, functionCallPattern: hasFunction };
if (useCache) {
contentAnalysisCache.set(content, { ...result, timestamp: Date.now() });
}
return result;
};
/**
* Optimized debounced rendering to prevent excessive updates
*/
const scheduleOptimizedRender = (blockId: string, target: HTMLElement): void => {
// Clear any existing debounce timer
const existingTimer = renderingDebouncer.get(blockId);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Schedule new render with debouncing
const timer = setTimeout(() => {
renderingDebouncer.delete(blockId);
// Only render if not completed or resyncing
if (!completedStreams.has(blockId) && !resyncingBlocks.has(blockId)) {
// Update the queue for rendering
const targetQueue = window._updateQueue || updateQueue;
targetQueue.set(blockId, target);
// Process updates using the global function if available
if (typeof window !== 'undefined' && window._processUpdateQueue) {
window._processUpdateQueue();
}
}
}, RENDER_DEBOUNCE_MS);
renderingDebouncer.set(blockId, timer);
};
/**
* Monitors a node for changes to detect streaming content updates
*
* @param node The HTML element to monitor
* @param blockId ID of the function block
*/
export const monitorNode = (node: HTMLElement, blockId: string): void => {
if (streamingObservers.has(blockId)) return;
const content = node.textContent?.substring(0, 100) || '';
const isJSON = content.includes('"type"');
if (CONFIG.debug) {
logger.debug(
`Setting up monitoring for block: ${blockId}, element: ${node.tagName}, format: ${isJSON ? 'JSON' : 'XML'}`,
);
logger.debug(`Content preview:`, content);
}
// Initialize the last updated timestamp
streamingLastUpdated.set(blockId, Date.now());
// Set an attribute on the node for easier identification
node.setAttribute('data-monitored-node', blockId);
// Track consecutive inactive periods (no content changes)
let inactivePeriods = 0;
let lastContentLength = node.textContent?.length || 0;
let detectedIncompleteTags = false;
// Setup a periodic checker for this node that can detect abrupt endings
const periodicChecker = setInterval(() => {
// If node is no longer in the DOM, clean up
if (!document.body.contains(node)) {
clearInterval(periodicChecker);
return;
}
const currentContent = node.textContent || '';
const currentLength = currentContent.length;
// Check if content has incomplete tags (XML or JSON)
const hasOpenFunctionCallsTag =
currentContent.includes('<function_calls>') && !currentContent.includes('</function_calls>');
const hasOpenInvokeTag = currentContent.includes('<invoke') && !currentContent.includes('</invoke>');
const hasOpenParameterTags =
(currentContent.match(/<parameter[^>]*>/g) || []).length > (currentContent.match(/<\/parameter>/g) || []).length;
// Check for incomplete JSON
const hasJSONStart = currentContent.includes('"type"') && currentContent.includes('function_call_start');
const hasJSONEnd =
currentContent.includes('"type"') &&
(currentContent.includes('"function_call_end"') || currentContent.includes('"type": "function_call_end"'));
const hasIncompleteJSON = hasJSONStart && !hasJSONEnd;
// Detect incomplete tags (XML or JSON)
if (hasOpenFunctionCallsTag || hasOpenInvokeTag || hasOpenParameterTags || hasIncompleteJSON) {
detectedIncompleteTags = true;
}
// If we previously detected incomplete tags, but content hasn't changed in 3 checks,
// this might be an abruptly ended stream
if (detectedIncompleteTags && currentLength === lastContentLength) {
inactivePeriods++;
if (inactivePeriods >= 3) {
// This stream has likely ended abruptly
abruptlyEndedStreams.add(blockId);
// Signal this as stalled right away instead of waiting for timeout
const functionBlock = document.querySelector(`.function-block[data-block-id="${blockId}"]`);
if (functionBlock && functionBlock.classList.contains('function-loading')) {
// Use our custom event to trigger stalled stream handling
const event = new CustomEvent('stream-abruptly-ended', {
detail: { blockId, element: functionBlock },
});
document.dispatchEvent(event);
if (CONFIG.debug) {
logger.debug(`Detected abruptly ended stream for block ${blockId}`);
}
// We can clear this interval now
clearInterval(periodicChecker);
}
}
} else {
// Reset if content changed
inactivePeriods = 0;
lastContentLength = currentLength;
}
}, 1000); // Check every second
const observer = new MutationObserver(mutations => {
const isProcessingFlag = window._isProcessing !== undefined ? window._isProcessing : isProcessing;
if (isProcessingFlag) return;
// Skip if this block is already complete to prevent thrashing
if (completedStreams.has(blockId)) return;
// Skip if block is currently transitioning to prevent conflicts
const functionBlock = document.querySelector(`.function-block[data-block-id="${blockId}"]`);
if (functionBlock?.hasAttribute('data-completing')) return;
if (CONFIG.debug) {
logger.debug(`Mutation detected for blockId: ${blockId}, mutations: ${mutations.length}`);
}
let contentChanged = false;
let significantChange = false;
let functionCallPattern = false;
// Batch analyze all mutations for better performance
for (const mutation of mutations) {
if (mutation.type === 'characterData' || mutation.type === 'childList') {
contentChanged = true;
// Get the content once for analysis
const targetNode = mutation.target;
const textContent = targetNode.textContent || '';
// Use fast pattern matching for both XML and JSON
if (!functionCallPattern) {
PATTERN_CACHE.allFunctionPatterns.lastIndex = 0;
const hasXMLPattern = PATTERN_CACHE.allFunctionPatterns.test(textContent);
const hasJSONPattern =
textContent.includes('"type"') &&
(textContent.includes('function_call') || textContent.includes('parameter'));
functionCallPattern = hasXMLPattern || hasJSONPattern;
}
// Check for significant size changes in content
if (mutation.type === 'characterData') {
const oldValue = mutation.oldValue || '';
const newValue = textContent;
// Get previous content for chunk detection
const previousContent = previousContentCache.get(blockId) || '';
// Use immediate chunk detection for instant response
const chunkInfo = detectFunctionChunk(newValue, previousContent);
if (CONFIG.debug && chunkInfo.hasNewChunk) {
logger.debug(`Chunk detected - type: ${chunkInfo.chunkType}, significant: ${chunkInfo.isSignificant}`);
}
if (chunkInfo.hasNewChunk && chunkInfo.isSignificant) {
significantChange = true;
// Cache parameter content during streaming
cacheParameterContent(blockId, newValue);
// Process chunk immediately for instant response
processChunkImmediate(blockId, newValue, chunkInfo);
} else if (Math.abs(newValue.length - oldValue.length) > 10) {
significantChange = true;
}
// Update previous content cache
previousContentCache.set(blockId, newValue);
}
if (mutation.type === 'childList' && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) {
significantChange = true;
}
}
}
if (contentChanged) {
// Reset the inactive periods counter since we've seen new content
inactivePeriods = 0;
lastContentLength = node.textContent?.length || 0;
// Update the last updated timestamp when content changes
streamingLastUpdated.set(blockId, Date.now());
// Remove from abruptly ended if it was previously marked
if (abruptlyEndedStreams.has(blockId)) {
abruptlyEndedStreams.delete(blockId);
}
// Find the nearest element that contains our monitored node
let target = node;
while (target && !CONFIG.targetSelectors.includes(target.tagName.toLowerCase())) {
target = target.parentElement as HTMLElement;
if (!target) break;
}
if (target) {
// Log significant changes if debugging is enabled
if (CONFIG.debug && (significantChange || functionCallPattern)) {
logger.debug(`Significant content change detected in block ${blockId}`, {
significantChange,
functionCallPattern,
});
}
// Use optimized scheduling for better performance
scheduleOptimizedRender(blockId, target);
}
}
});
// Configure the observer to watch the node and its descendants with optimized options
observer.observe(node, {
childList: true,
characterData: true,
characterDataOldValue: true, // Track old values for better change detection
subtree: true,
// Optimize by focusing only on critical attributes that indicate streaming state
attributes: false, // Disable general attribute watching for performance
// Only watch specific attributes if needed
// attributeFilter: ['class', 'data-status'],
});
// Store the observer for later cleanup
streamingObservers.set(blockId, observer);
};
/**
* Check for streaming updates in all active blocks
*/
export const checkStreamingUpdates = (): void => {
if (CONFIG.debug) {
logger.debug('Checking streaming updates...');
}
const targetContainers = [];
for (const selector of CONFIG.streamingContainerSelectors) {
const containers = document.querySelectorAll<HTMLElement>(selector);
targetContainers.push(...Array.from(containers));
}
// Find all elements in these containers
for (const container of targetContainers) {
for (const selector of CONFIG.targetSelectors) {
const elements = container.querySelectorAll<HTMLElement>(selector);
for (const element of elements) {
const blockId = element.getAttribute('data-block-id');
if (!blockId) continue;
renderFunctionCall(element as HTMLPreElement, { current: false });
}
}
}
};
/**
* Start progressive updates for large streaming content
*/
export let progressiveUpdateTimer: ReturnType<typeof setInterval> | null = null;
/**
* Perform seamless completion transition without DOM disruption
*
* @param blockId ID of the function block to complete
* @param finalContent Final content of the stream
*/
const performSeamlessCompletion = (blockId: string, finalContent: string): void => {
if (CONFIG.debug) {
logger.debug(`Performing seamless completion for block ${blockId}`);
}
// Find the function block
const functionBlock = document.querySelector(`.function-block[data-block-id="${blockId}"]`);
if (!functionBlock) {
if (CONFIG.debug) {
logger.debug(`Function block not found for completion: ${blockId}`);
}
return;
}
// Skip if already completed or currently transitioning
if (functionBlock.classList.contains('function-complete') || functionBlock.hasAttribute('data-completing')) {
if (CONFIG.debug) {
logger.debug(`Block ${blockId} already completed or completing`);
}
return;
}
// Mark as transitioning to prevent duplicate completion attempts
functionBlock.setAttribute('data-completing', 'true');
// Use multiple requestAnimationFrame calls for ultra-smooth transition
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// Remove loading state and add complete state
functionBlock.classList.remove('function-loading');
functionBlock.classList.add('function-complete');
// Remove spinner if present
const spinner = functionBlock.querySelector('.spinner');
if (spinner) {
spinner.remove();
}
// Remove data-completing attribute
functionBlock.removeAttribute('data-completing');
// Mark as completed
completedStreams.set(blockId, true);
});
});
};
/**
* Re-sync the rendered function block with the original pre block content
* This is a truly seamless update that never removes or replaces DOM elements
*
* @param blockId ID of the function block to re-sync
*/
export const resyncWithOriginalContent = (blockId: string): void => {
if (CONFIG.debug) {
logger.debug(`Starting seamless content resync for block ${blockId}`);
}
// Skip if already completed to prevent jitter
if (completedStreams.has(blockId)) {
if (CONFIG.debug) {
logger.debug(`Skipping resync for already completed block ${blockId}`);
}
resyncingBlocks.delete(blockId);
return;
}
// Mark as resyncing to prevent conflicting updates
resyncingBlocks.add(blockId);
// Find the original pre element
const originalPre = document.querySelector(`div[data-block-id="${blockId}"]`);
if (!originalPre || !originalPre.textContent) {
if (CONFIG.debug) {
logger.debug(`Original pre element not found for block ${blockId}`);
}
resyncingBlocks.delete(blockId);
return;
}
// Find the rendered function block
const functionBlock = document.querySelector(`.function-block[data-block-id="${blockId}"]`);
if (!functionBlock) {
if (CONFIG.debug) {
logger.debug(`Rendered function block not found for block ${blockId}`);
}
resyncingBlocks.delete(blockId);
return;
}
// Extract final parameters from the original content
const originalContent = originalPre.textContent.trim();
const originalParams = extractParameters(originalContent);
// Get cached parameter content to ensure we don't lose any streaming content
const cachedParams = getCachedParameterContent(blockId);
// Merge original parameters with cached content (cached takes priority if longer)
const mergedParams = originalParams.map(param => {
const cachedContent = cachedParams.get(param.name);
if (cachedContent && cachedContent.length > param.value.length) {
return { ...param, value: cachedContent };
}
return param;
});
// Extract function name directly
const invokeMatch = originalContent.match(/<invoke name="([^"]+)"(?:\s+call_id="([^"]+)")?>/);
const originalFunctionName = invokeMatch && invokeMatch[1] ? invokeMatch[1] : null;
if (CONFIG.debug) {
logger.debug(`Resync found ${mergedParams.length} parameters and function name: ${originalFunctionName}`);
}
// **CRITICAL**: Only update content seamlessly within existing elements
// NEVER disconnect observers or modify DOM structure
// Use minimal, gradual updates to prevent jitter
// Check if the function block is already stable before making changes
const isAlreadyComplete = functionBlock.classList.contains('function-complete');
const isCurrentlyLoading = functionBlock.classList.contains('function-loading');
// Use a single requestAnimationFrame to batch all updates
requestAnimationFrame(() => {
// Only update function name if it's actually different and won't cause jitter
if (originalFunctionName && !isAlreadyComplete) {
const functionNameElement = functionBlock.querySelector('.function-name-text');
if (functionNameElement && functionNameElement.textContent !== originalFunctionName) {
functionNameElement.textContent = originalFunctionName;
}
}
// Update parameter content with minimal disruption
let hasContentChanges = false;
mergedParams.forEach(param => {
const paramId = `${blockId}-${param.name}`;
const paramValueElement = functionBlock.querySelector(`.param-value[data-param-id="${paramId}"]`);
if (paramValueElement) {
const currentContent = paramValueElement.textContent || '';
if (currentContent !== param.value) {
// Find or create pre element for seamless update
const preElement = paramValueElement.querySelector('pre');
if (preElement) {
if (preElement.textContent !== param.value) {
preElement.textContent = param.value;
hasContentChanges = true;
}
} else if (paramValueElement.textContent !== param.value) {
paramValueElement.textContent = param.value;
hasContentChanges = true;
}
}
}
});
// Only transition to complete state if there were actual content changes and we're not already complete
if (hasContentChanges && isCurrentlyLoading && !isAlreadyComplete) {
// Use the seamless completion function
performSeamlessCompletion(blockId, originalContent);
} else if (isAlreadyComplete || !isCurrentlyLoading) {
// Already in a stable state, just clean up
completedStreams.set(blockId, true);
}
// Always clean up the resync state quickly
setTimeout(() => {
resyncingBlocks.delete(blockId);
}, 150);
});
};
/**
* Start progressive updates for large streaming content
*/
export const startProgressiveUpdates = (): void => {
if (progressiveUpdateTimer) {
clearInterval(progressiveUpdateTimer);
}
progressiveUpdateTimer = setInterval(() => {
if (!document.body.contains(document.querySelector('.large-content'))) {
// No more large content visible, stop the timer
clearInterval(progressiveUpdateTimer!);
progressiveUpdateTimer = null;
return;
}
checkStreamingUpdates();
}, CONFIG.progressiveUpdateInterval);
};
import type { FunctionInfo } from '../core/types';
import { extractLanguageTag } from './languageParser';
import { containsJSONFunctionCalls, extractJSONFunctionInfo } from './jsonFunctionParser';
/**
* Analyzes content to determine if it contains function calls
* and related information about their completeness
*
* @param block The HTML element containing potential function call content
* @returns Information about the detected function calls
*/
export const containsFunctionCalls = (block: HTMLElement): FunctionInfo => {
const content = block.textContent?.trim() || '';
const result: FunctionInfo = {
hasFunctionCalls: false,
isComplete: false,
hasInvoke: false,
hasParameters: false,
hasClosingTags: false,
languageTag: null,
detectedBlockType: null,
partialTagDetected: false,
};
// First, check for JSON function calls
const jsonResult = containsJSONFunctionCalls(block);
if (jsonResult.hasFunctionCalls) {
// Extract description for JSON format
const { description } = extractJSONFunctionInfo(content);
return {
...jsonResult,
description: description ?? undefined,
};
}
// Check for XML function call content
if (
!content.includes('<') &&
!content.includes('<function_calls>') &&
!content.includes('<invoke') &&
!content.includes('</invoke>') &&
!content.includes('<parameter')
) {
return result;
}
// Detect language tag and update content to examine
const langTagResult = extractLanguageTag(content);
if (langTagResult.tag) {
result.languageTag = langTagResult.tag;
}
// The content to analyze (with or without language tag)
const contentToExamine = langTagResult.content || content;
// Check for Claude Opus style function calls (XML)
if (contentToExamine.includes('<function_calls>') || contentToExamine.includes('<invoke')) {
result.hasFunctionCalls = true;
result.detectedBlockType = 'antml';
result.hasInvoke = contentToExamine.includes('<invoke');
result.hasParameters = contentToExamine.includes('<parameter');
// Extract function name from invoke tag if present
if (result.hasInvoke) {
const invokeMatch = contentToExamine.match(/<invoke name="([^"]+)"(?:\s+call_id="([^"]+)")?>/);
if (invokeMatch && invokeMatch[1]) {
result.invokeName = invokeMatch[1];
}
}
// Check for complete structure
const hasOpeningTag = contentToExamine.includes('<function_calls>');
const hasClosingTag = contentToExamine.includes('</function_calls>');
result.hasClosingTags = hasOpeningTag && hasClosingTag;
result.isComplete = result.hasClosingTags;
}
return result;
};
// Re-export all parser functionality
export * from './functionParser';
export * from './languageParser';
export * from './parameterParser';
export * from './jsonFunctionParser';
export * from './parseUtils';
import type { FunctionInfo } from '../core/types';
import { CONFIG } from '../core/config';
import { createLogger } from '@extension/shared/lib/logger';
/**
* JSON function call line types
*/
const logger = createLogger('parseJSONLine');
interface JSONFunctionLine {
type: 'function_call_start' | 'description' | 'parameter' | 'function_call_end';
name?: string;
call_id?: number | string;
text?: string;
key?: string;
value?: any;
}
/**
* State for tracking JSON function call parsing
*/
interface JSONFunctionState {
hasFunctionStart: boolean;
hasFunctionEnd: boolean;
functionName: string | null;
callId: string | null;
description: string | null;
parameterCount: number;
lines: JSONFunctionLine[];
}
/**
* Parse a single line of JSON function call
*/
const parseJSONLine = (line: string): JSONFunctionLine | null => {
try {
const trimmed = line.trim();
if (!trimmed) return null;
// First try: strip language tags (fast path for clean content)
let cleaned = stripLanguageTags(trimmed);
// If cleaned content doesn't start with { or [, extract JSON directly
// This handles localized UI labels like "json复制代码{...}"
if (cleaned && !cleaned.startsWith('{') && !cleaned.startsWith('[')) {
// Find first { or [ and try to extract from there
const jsonStart = cleaned.search(/[\[{]/);
if (jsonStart > 0) {
cleaned = cleaned.substring(jsonStart);
}
}
if (!cleaned) return null;
const parsed = JSON.parse(cleaned);
// Validate it's a function call line
if (!parsed.type || typeof parsed.type !== 'string') {
return null;
}
return parsed as JSONFunctionLine;
} catch (e) {
return null;
}
};
/**
* Strip Language tags and prefixes from a line
* Handles various formats:
* - Language identifiers: json, jsonl, javascript, typescript, python, etc.
* - Copy code buttons: "Copy code", "copy", etc.
* - Combined formats: "jsonCopy code", "javascriptcopy", etc.
* - Markdown code fence indicators: ```json, ```javascript, etc.
* - Multiple spaces and case variations
*/
export const stripLanguageTags = (line: string): string => {
const trimmed = line.trim();
// First, strip markdown code fence markers (```)
let cleaned = trimmed.replace(
/^```\s*(javascript|typescript|markdown|csharp|kotlin|python|jsonl|bash|rust|java|scala|swift|shell|json|text|perl|yaml|toml|html|ruby|cpp|php|lua|css|sql|yml|ini|xml|ts|js|py|sh|md|cs|go|rb|c|r)?\s*/i,
'',
);
// Then strip language tags with optional "copy" or "copy code" suffix
// Supports: json, jsonCopy, json Copy, json copy code, jsonCopycode, jsonlCopy code, etc.
// IMPORTANT: Order matters! Longer language names must come first (e.g., jsonl before json)
cleaned = cleaned.replace(
/^(javascript|typescript|markdown|csharp|kotlin|python|jsonl|bash|rust|java|scala|swift|shell|json|text|perl|yaml|toml|html|ruby|cpp|php|lua|css|sql|yml|ini|xml|ts|js|py|sh|md|cs|go|rb|c|r)(\s*copy(\s*code)?)?\s*/i,
'',
);
// Strip standalone "copy" or "copy code" buttons that might remain
cleaned = cleaned.replace(/^[cC]opy(\s+code)?\s*/i, '');
return cleaned;
};
/**
* Extract JSON objects/arrays directly from content using bracket matching.
* Ignores any surrounding text (localized UI labels, language tags, etc.)
* This is language-agnostic and handles any prefix/suffix around JSON.
*/
export const extractJSONObjects = (content: string): string[] => {
const objects: string[] = [];
let i = 0;
while (i < content.length) {
// Find start of JSON object or array
if (content[i] === '{' || content[i] === '[') {
const startChar = content[i];
const endChar = startChar === '{' ? '}' : ']';
const startIndex = i;
let depth = 1;
let inString = false;
let escapeNext = false;
i++;
while (i < content.length && depth > 0) {
const char = content[i];
if (escapeNext) {
escapeNext = false;
} else if (char === '\\' && inString) {
escapeNext = true;
} else if (char === '"') {
inString = !inString;
} else if (!inString) {
if (char === startChar) depth++;
else if (char === endChar) depth--;
}
i++;
}
if (depth === 0) {
const jsonStr = content.substring(startIndex, i);
// Validate it's actually JSON with expected structure
try {
const parsed = JSON.parse(jsonStr);
// For function calls, ensure it has a 'type' field or is an array
if (parsed.type || Array.isArray(parsed)) {
objects.push(jsonStr);
}
} catch {
// Not valid JSON, skip
}
}
} else {
i++;
}
}
return objects;
};
/**
* Extract clean content for display - finds all JSON objects and joins them.
* Use this to sanitize content that may contain localized UI labels.
*/
export const extractCleanContent = (content: string): string => {
const jsonObjects = extractJSONObjects(content);
if (jsonObjects.length > 0) {
return jsonObjects.join('\n');
}
// Fallback: if no valid JSON found, return original with basic cleanup
return content.trim();
};
/**
* Reconstruct complete JSON objects from pretty-printed multi-line format
* Converts multi-line formatted JSON into compact single-line JSON objects
*
* Example input:
* {
* "type": "function_call_start",
* "name": "foo"
* }
*
* Example output:
* {"type": "function_call_start", "name": "foo"}
*/
function reconstructJSONObjects(lines: string[]): string[] {
const reconstructed: string[] = [];
let currentObject = '';
let braceDepth = 0;
let inObject = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue; // Skip empty lines
// Count braces to track object boundaries
for (const char of trimmed) {
if (char === '{') {
braceDepth++;
inObject = true;
} else if (char === '}') {
braceDepth--;
}
}
// Accumulate lines for current object (join with space)
currentObject += (currentObject ? ' ' : '') + trimmed;
// When braces balance back to 0, we have a complete object
if (inObject && braceDepth === 0) {
reconstructed.push(currentObject);
currentObject = '';
inObject = false;
}
}
// If there's leftover content (incomplete object), add it
if (currentObject.trim()) {
reconstructed.push(currentObject);
}
return reconstructed;
}
/**
* Check if content contains JSON-style function calls
* Returns detailed information about the JSON function call state
*/
export const containsJSONFunctionCalls = (block: HTMLElement): FunctionInfo => {
let content = '';
// Skip if this element is inside a function-block (avoid re-parsing rendered UI)
if (block.closest('.function-block')) {
return {
hasFunctionCalls: false,
isComplete: false,
hasInvoke: false,
hasParameters: false,
hasClosingTags: false,
languageTag: null,
detectedBlockType: null,
partialTagDetected: false,
};
}
// Priority 1: Check if this element IS the hidden pre from codemirror-accessor
// Hidden pre elements have id="cm-hidden-pre-*" or data-cm-source attribute
const isHiddenPre = block.id?.startsWith('cm-hidden-pre-') ||
block.hasAttribute('data-cm-source');
if (isHiddenPre && block.textContent) {
content = block.textContent.trim();
if (CONFIG.debug) {
logger.debug('[JSON Parser] Using content from hidden pre element directly');
}
}
// Priority 2: Check for hidden pre element linked to this block
// This is for Monaco/CodeMirror editors
if (!content) {
const cmMonitoredId = block.getAttribute('data-cm-monitored');
const blockId = block.getAttribute('data-block-id');
const cmSourceId = block.getAttribute('data-cm-source');
const sourceId = cmMonitoredId || blockId || cmSourceId;
if (sourceId) {
const hiddenPre = document.getElementById(`cm-hidden-pre-${sourceId}`) ||
document.querySelector(`pre[data-cm-source="${sourceId}"]`);
if (hiddenPre?.textContent) {
content = hiddenPre.textContent.trim();
if (CONFIG.debug) {
logger.debug('[JSON Parser] Using clean content from hidden pre:', sourceId);
}
}
}
}
// Priority 3: Check for code child (for syntax-highlighted blocks, avoiding .function-block children)
if (!content) {
const codeChild = block.querySelector('code:not(.function-block code)');
if (codeChild?.textContent) {
content = codeChild.textContent.trim();
}
}
// Priority 4: Use raw textContent but extract JSON objects directly
if (!content) {
const rawContent = block.textContent?.trim() || '';
// Use extractJSONObjects to get clean JSON from potentially polluted content
const jsonObjects = extractJSONObjects(rawContent);
if (jsonObjects.length > 0) {
content = jsonObjects.join('\n');
if (CONFIG.debug) {
logger.debug('[JSON Parser] Extracted', jsonObjects.length, 'JSON objects from raw content');
}
} else {
content = rawContent;
}
}
const result: FunctionInfo = {
hasFunctionCalls: false,
isComplete: false,
hasInvoke: false,
hasParameters: false,
hasClosingTags: false,
languageTag: null,
detectedBlockType: null,
partialTagDetected: false,
};
// Always log for debugging
if (CONFIG.debug) {
logger.debug('[JSON Parser] Checking element:', block.tagName, block.className);
logger.debug('[JSON Parser] Content length:', content.length);
logger.debug('[JSON Parser] Content preview:', content.substring(0, 200));
}
// Enhanced quick check: must contain JSON-like patterns (lenient for streaming)
// Handle both single and double quotes for type field
const hasTypeField = /['"]?type['"]?\s*:/i.test(content) || content.includes('"type"') || content.includes("'type'");
// Accept partial matches during streaming (e.g., "function_ca" while typing "function_call")
// Enhanced to handle Unicode word boundaries
const hasFunctionCall =
content.includes('function_call') ||
(hasTypeField && /function_call_\w*/u.test(content)) ||
(hasTypeField && content.includes('function_ca')) || // Partial match
(hasTypeField && /function_call_start/u.test(content));
const hasParameter =
content.includes('"parameter"') ||
content.includes("'parameter'") ||
(hasTypeField && /\bparameter\b/u.test(content));
// Also check if it looks like start of JSON object with type field
const looksLikeJSONStart =
content.includes('{"type"') ||
content.includes('{ "type"') ||
content.includes("{'type'") ||
content.includes("{ 'type'");
if (CONFIG.debug) {
logger.debug('[JSON Parser] Pattern check:', {
hasTypeField,
hasFunctionCall,
hasParameter,
looksLikeJSONStart,
willProceed: hasTypeField && (hasFunctionCall || hasParameter || looksLikeJSONStart),
});
}
// Accept if it looks like JSON function call structure (lenient for streaming)
if (!(hasTypeField && (hasFunctionCall || hasParameter || looksLikeJSONStart))) {
if (CONFIG.debug) {
logger.debug('[JSON Parser] Quick check failed - not JSON function call');
}
return result;
}
if (CONFIG.debug) {
logger.debug('[JSON Parser] Quick check passed - parsing JSON lines');
}
const state: JSONFunctionState = {
hasFunctionStart: false,
hasFunctionEnd: false,
functionName: null,
callId: null,
description: null,
parameterCount: 0,
lines: [],
};
// Parse line by line - enhanced to handle Unicode line separators
let lines = content.split(/\r?\n|\u2028|\u2029/);
let hasPartialJSON = false;
// Detect if JSON objects are on a single line (multiple objects without newlines)
// Example: json {"type": "function_call_start"} {"type": "description"} {"type": "parameter"}
const isSingleLineFormat = lines.length === 1 && (content.match(/\{/g) || []).length > 1;
// Detect pretty-printed JSON (objects span multiple lines with indentation)
// Example:
// {
// "type": "function_call_start",
// "name": "foo"
// }
const isPrettyPrinted =
lines.length > 1 &&
(content.includes('{\n') || content.includes('{ \n') || lines.some(line => line.trim() === '{'));
if (isSingleLineFormat) {
if (CONFIG.debug) {
logger.debug('[JSON Parser] Detected single-line multiple JSON objects format');
}
// Split by "} {" pattern to separate individual JSON objects
// Use regex to handle optional whitespace between objects
const splitContent = content.split(/\}\s*\{/);
lines = splitContent.map((part, index, array) => {
// First object: add closing brace
if (index === 0) return part + '}';
// Last object: add opening brace
if (index === array.length - 1) return '{' + part;
// Middle objects: add both braces
return '{' + part + '}';
});
if (CONFIG.debug) {
logger.debug('[JSON Parser] Split into', lines.length, 'separate JSON objects');
}
} else if (isPrettyPrinted) {
if (CONFIG.debug) {
logger.debug('[JSON Parser] Detected pretty-printed multi-line JSON format');
}
// Reconstruct complete JSON objects from multi-line format
lines = reconstructJSONObjects(lines);
if (CONFIG.debug) {
logger.debug('[JSON Parser] Reconstructed into', lines.length, 'compact JSON objects');
if (lines.length > 0) {
logger.debug('[JSON Parser] First reconstructed object:', lines[0].substring(0, 100));
}
}
}
for (const line of lines) {
const parsed = parseJSONLine(line);
if (!parsed) {
// Check if line looks like incomplete JSON
const trimmed = line.trim();
if (trimmed.startsWith('{') && !trimmed.endsWith('}')) {
hasPartialJSON = true;
if (CONFIG.debug) {
logger.debug('[JSON Parser] Detected partial JSON line:', trimmed.substring(0, 50));
}
}
continue;
}
state.lines.push(parsed);
switch (parsed.type) {
case 'function_call_start':
state.hasFunctionStart = true;
state.functionName = parsed.name || null;
state.callId = parsed.call_id?.toString() || null;
break;
case 'description':
state.description = parsed.text || null;
break;
case 'parameter':
state.parameterCount++;
break;
case 'function_call_end':
state.hasFunctionEnd = true;
break;
}
}
// Determine if this is a JSON function call
// Accept if we found a function start OR if we have partial JSON that looks like a function call
if (state.hasFunctionStart || (hasPartialJSON && looksLikeJSONStart)) {
result.hasFunctionCalls = true;
result.detectedBlockType = 'json';
result.hasInvoke = state.hasFunctionStart;
result.hasParameters = state.parameterCount > 0;
result.hasClosingTags = state.hasFunctionEnd;
result.isComplete = state.hasFunctionStart && state.hasFunctionEnd;
result.invokeName = state.functionName || undefined;
result.textContent = state.description || undefined;
result.partialTagDetected = hasPartialJSON;
}
if (typeof window !== 'undefined' && (window as any).__DEBUG_JSON_PARSER) {
logger.debug('[JSON Parser] Final result:', {
hasFunctionCalls: result.hasFunctionCalls,
detectedBlockType: result.detectedBlockType,
isComplete: result.isComplete,
functionName: state.functionName,
paramCount: state.parameterCount,
hasEnd: state.hasFunctionEnd,
});
}
return result;
};
/**
* Extract function name and call_id from JSON function calls (handles partial/streaming content)
*/
export const extractJSONFunctionInfo = (
content: string,
): {
functionName: string | null;
callId: string | null;
description: string | null;
} => {
const lines = content.split('\n');
let functionName: string | null = null;
let callId: string | null = null;
let description: string | null = null;
for (const line of lines) {
const parsed = parseJSONLine(line);
if (!parsed) {
// Try to extract from partial JSON line
let trimmed = line.trim();
// Strip language tags and copy-code prefixes before checking
// IMPORTANT: Order matters! Longer language names must come first (e.g., jsonl before json)
trimmed = trimmed.replace(
/^(javascript|typescript|markdown|csharp|kotlin|python|jsonl|bash|rust|java|scala|swift|shell|json|text|perl|yaml|toml|html|ruby|cpp|php|lua|css|sql|yml|ini|xml|ts|js|py|sh|md|cs|go|rb|c|r)(\s*copy(\s*code)?)?\s*/i,
'',
);
if (trimmed.startsWith('{') && trimmed.includes('"type"') && trimmed.includes('function_call_start')) {
// Try to extract name from partial JSON
const nameMatch = trimmed.match(/"name"\s*:\s*"([^"]+)"/);
if (nameMatch) {
functionName = nameMatch[1];
}
const callIdMatch = trimmed.match(/"call_id"\s*:\s*(\d+|"[^"]+")/);
if (callIdMatch) {
callId = callIdMatch[1].replace(/"/g, '');
}
}
continue;
}
if (parsed.type === 'function_call_start') {
functionName = parsed.name || null;
callId = parsed.call_id?.toString() || null;
} else if (parsed.type === 'description') {
description = parsed.text || null;
}
// Early exit once we have all info
if (functionName && callId && description) {
break;
}
}
return { functionName, callId, description };
};
/**
* Extract parameters from JSON function calls
*/
export const extractJSONParameters = (content: string): Record<string, any> => {
const parameters: Record<string, any> = {};
if (!content || typeof content !== 'string') {
if (CONFIG.debug) {
logger.debug('[JSON Parser] extractJSONParameters: Invalid content');
}
return parameters;
}
const lines = content.split('\n');
// First pass: Extract from complete, parseable JSON lines
for (const line of lines) {
const parsed = parseJSONLine(line);
if (!parsed) continue;
if (parsed.type === 'parameter' && parsed.key) {
parameters[parsed.key] = parsed.value ?? ''; // Ensure value is never undefined
}
}
// Second pass: Fallback regex extraction for incomplete/streaming parameter lines
// This handles cases where parameter values are long and arrive in chunks
// Pattern matches: {"type": "parameter", "key": "keyname", "value": "partial content...
// Even if the closing quote and brace are missing (streaming incomplete JSON)
// Note: No trailing quote required - matches partial values during streaming
const parameterPattern = /"type"\s*:\s*"parameter"[^}]*"key"\s*:\s*"([^"]+)"[^}]*"value"\s*:\s*"((?:[^"\\]|\\.)*)/g;
let match;
while ((match = parameterPattern.exec(content)) !== null) {
const key = match[1];
const value = match[2];
// Only use regex-extracted value if we don't already have this parameter
// (prefer complete values from successfully parsed JSON lines)
if (!parameters.hasOwnProperty(key)) {
// Unescape the value (handle \n, \t, \", etc.)
const unescapedValue = value
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\r/g, '\r')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\');
parameters[key] = unescapedValue;
if (CONFIG.debug) {
logger.debug(`Extracted partial parameter via regex: ${key} (${unescapedValue.length} chars)`);
}
}
}
if (CONFIG.debug && Object.keys(parameters).length > 0) {
logger.debug('[JSON Parser] extractJSONParameters result:', Object.keys(parameters));
}
return parameters;
};
/**
* Check if JSON function call is streaming (incomplete)
*/
export const isJSONFunctionStreaming = (content: string): boolean => {
const lines = content.split('\n');
let hasStart = false;
let hasEnd = false;
for (const line of lines) {
const parsed = parseJSONLine(line);
if (!parsed) continue;
if (parsed.type === 'function_call_start') {
hasStart = true;
} else if (parsed.type === 'function_call_end') {
hasEnd = true;
}
}
return hasStart && !hasEnd;
};
import { CONFIG } from '../core/config';
/**
* Extract language tag from content if present
*
* @param content The content to extract the language tag from
* @returns Object containing the extracted tag and the remaining content
*/
export const extractLanguageTag = (content: string): { tag: string | null; content: string } => {
if (!CONFIG.handleLanguageTags) {
return { tag: null, content };
}
// 1. Check for common formats: ```language, ```language:, or [language]
const langRegexes = [
/^```(\w+)[\s:]?\s*\n([\s\S]+)$/, // ```language followed by newline
/^\[(\w+)\]\s*\n([\s\S]+)$/, // [language] followed by newline
];
for (const regex of langRegexes) {
const match = content.match(regex);
if (match) {
const tag = match[1].toLowerCase();
// Verify it's likely a language tag, not just random text in backticks
if (CONFIG.knownLanguages.includes(tag)) {
return { tag, content: match[2] };
}
}
}
// 2. Check for language comments
const commentRegexes = [
/^\/\/\s*language:\s*(\w+)\s*\n([\s\S]+)$/i, // // language: python
/^#\s*language:\s*(\w+)\s*\n([\s\S]+)$/i, // # language: python
/^<!--\s*language:\s*(\w+)\s*-->\s*\n([\s\S]+)$/i, // <!-- language: html -->
];
for (const regex of commentRegexes) {
const match = content.match(regex);
if (match) {
const tag = match[1].toLowerCase();
if (CONFIG.knownLanguages.includes(tag)) {
return { tag, content: match[2] };
}
}
}
// 3. Look for language hints in the first few lines
const lines = content.split('\n');
const checkLines = Math.min(CONFIG.maxLinesAfterLangTag, lines.length);
for (let i = 0; i < checkLines; i++) {
const line = lines[i].trim().toLowerCase();
// Check for shebang in first line
if (i === 0 && line.startsWith('#!')) {
if (line.includes('python')) return { tag: 'python', content };
if (line.includes('node')) return { tag: 'javascript', content };
if (line.includes('bash') || line.includes('/sh')) return { tag: 'bash', content };
if (line.includes('ruby')) return { tag: 'ruby', content };
}
// Check for language-specific patterns
if (line.includes('<?php')) return { tag: 'php', content };
if (line.includes('<!doctype html>') || line.includes('<html')) return { tag: 'html', content };
if (line.match(/^(import|from)\s+[\w.]+\s+import/)) return { tag: 'python', content };
if (line.match(/^(const|let|var)\s+\w+\s*=/)) return { tag: 'javascript', content };
if (line.match(/^function\s+\w+\(/)) return { tag: 'javascript', content };
if (line.match(/^package\s+\w+;?/)) return { tag: 'java', content };
if (line.match(/^using\s+[\w.]+;/)) return { tag: 'csharp', content };
if (line.match(/^#include\s+[<"][\w.]+[>"]/)) return { tag: 'c', content };
}
return { tag: null, content };
};
import { CONFIG } from '../core/config';
import type { Parameter, PartialParameterState } from '../core/types';
import { extractJSONParameters } from './jsonFunctionParser';
// State storage for streaming parameters
export const partialParameterState = new Map<string, PartialParameterState>();
export const streamingContentLengths = new Map<string, number>();
/**
* Detect if content is JSON format
*/
const isJSONFormat = (content: string): boolean => {
const trimmed = content.trim();
return trimmed.includes('"type"') &&
(trimmed.includes('function_call_start') || trimmed.includes('parameter'));
};
/**
* Extract parameters from JSON format with state tracking for real-time streaming
*/
const extractParametersFromJSON = (content: string, blockId: string | null = null): Parameter[] => {
const parameters: Parameter[] = [];
const jsonParams = extractJSONParameters(content);
// Get previous state for tracking changes
const partialParams: PartialParameterState = blockId ? partialParameterState.get(blockId) || {} : {};
const newPartialState: PartialParameterState = {};
// Check if streaming (no function_call_end)
const isStreaming = !content.includes('"type":"function_call_end"') &&
!content.includes('"type": "function_call_end"');
Object.entries(jsonParams).forEach(([name, value]) => {
const displayValue = typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
// Track content length for large content handling
if (blockId && displayValue.length > CONFIG.largeContentThreshold) {
streamingContentLengths.set(`${blockId}-${name}`, displayValue.length);
}
// Determine if this is a new or changed parameter
const isNew = !partialParams[name] || partialParams[name] !== displayValue;
// Store current state for next iteration
newPartialState[name] = displayValue;
parameters.push({
name,
value: displayValue,
isComplete: !isStreaming,
isStreaming,
isNew,
originalContent: displayValue,
contentLength: displayValue.length,
isLargeContent: displayValue.length > CONFIG.largeContentThreshold,
});
});
// Update state for next iteration
if (blockId) {
partialParameterState.set(blockId, newPartialState);
}
return parameters;
};
/**
* Extract parameters from function call content
*
* @param content The content to extract parameters from
* @param blockId Optional block ID for tracking streaming parameters
* @returns Array of extracted parameters
*/
export const extractParameters = (content: string, blockId: string | null = null): Parameter[] => {
// Check if JSON format
if (isJSONFormat(content)) {
return extractParametersFromJSON(content, blockId);
}
// XML format extraction
const parameters: Parameter[] = [];
// Improved regex to handle more edge cases in opening tag - allow for attributes in any order
const regex = /<parameter\s+(?:name="([^"]+)"[^>]*|[^>]*name="([^"]+)")(?:>|$)/g;
const partialParams: PartialParameterState = blockId ? partialParameterState.get(blockId) || {} : {};
let match;
let lastIndex = 0;
const newPartialState: PartialParameterState = {};
// Process all complete and partial parameter tags
while ((match = regex.exec(content)) !== null) {
// Use the first non-undefined group as the parameter name
const paramName = match[1] || match[2];
const fullMatch = match[0];
const startPos = match.index + fullMatch.length;
const isIncompleteTag = !fullMatch.endsWith('>');
if (isIncompleteTag) {
// Handle incomplete opening tag
const partialContent = content.substring(startPos);
newPartialState[paramName] = partialContent;
parameters.push({
name: paramName,
value: partialContent,
isComplete: false,
isNew: !partialParams[paramName] || partialParams[paramName] !== partialContent,
isStreaming: true,
originalContent: partialContent,
isIncompleteTag: true,
});
continue;
}
// Find the matching closing tag, handling nested parameters
let endPos = startPos;
let nestLevel = 1;
let foundEnd = false;
// More robust tag matching algorithm
for (let i = startPos; i < content.length; i++) {
// Check for another opening parameter tag
if (i + 10 < content.length && content.substring(i, i + 10) === '<parameter') {
// Only increment if it's actually a tag and not part of a string
// Look for whitespace or '>' after the tag name to confirm it's a tag
if (content.charAt(i + 10) === ' ' || content.charAt(i + 10) === '>') {
nestLevel++;
}
}
// Check for closing parameter tag
else if (i + 12 < content.length && content.substring(i, i + 12) === '</parameter>') {
nestLevel--;
if (nestLevel === 0) {
endPos = i;
foundEnd = true;
break;
}
i += 11; // Skip past the closing tag
}
}
if (foundEnd) {
// Complete parameter with both start and end tags
const paramValue = content.substring(startPos, endPos);
if (blockId && paramValue.length > CONFIG.largeContentThreshold) {
streamingContentLengths.set(`${blockId}-${paramName}`, paramValue.length);
}
parameters.push({
name: paramName,
value: paramValue,
isComplete: true,
});
lastIndex = endPos + 12; // Move past the closing tag
} else {
// Parameter with start tag but no end tag (still streaming)
const partialValue = content.substring(startPos);
newPartialState[paramName] = partialValue;
if (blockId) {
const key = `${blockId}-${paramName}`;
const prevLength = streamingContentLengths.get(key) || 0;
const newLength = partialValue.length;
streamingContentLengths.set(key, newLength);
const isLargeContent = newLength > CONFIG.largeContentThreshold;
const hasGrown = newLength > prevLength;
const isNew = !partialParams[paramName] || partialParams[paramName] !== partialValue;
parameters.push({
name: paramName,
value: partialValue,
isComplete: false,
isNew: isNew || hasGrown,
isStreaming: true,
originalContent: partialValue,
isLargeContent: isLargeContent,
contentLength: newLength,
truncated: isLargeContent,
});
} else {
parameters.push({
name: paramName,
value: partialValue,
isComplete: false,
isNew: !partialParams[paramName] || partialParams[paramName] !== partialValue,
isStreaming: true,
originalContent: partialValue,
});
}
}
}
// Handle partial parameter tags at the end of content
if (blockId && content.includes('<parameter')) {
// More robust regex for partial opening tags
const partialTagRegex = /<parameter(?:\s+(?:name="([^"]*)")?[^>]*)?$/;
const partialTagMatch = content.match(partialTagRegex);
if (partialTagMatch) {
const paramName = partialTagMatch[1] || 'unnamed_parameter';
const partialTag = partialTagMatch[0];
// Store the partial tag with timestamp to avoid collisions
newPartialState[`__partial_tag_${Date.now()}`] = partialTag;
if (paramName && paramName !== 'unnamed_parameter') {
const existingParam = parameters.find(p => p.name === paramName);
if (!existingParam) {
parameters.push({
name: paramName,
value: '(streaming...)',
isComplete: false,
isStreaming: true,
isIncompleteTag: true,
});
}
}
}
// Enhanced regex to detect open parameter that might be streaming content
const lastParamTagRegex = /<parameter\s+(?:name="([^"]+)"[^>]*|[^>]*name="([^"]+)")>([^<]*?)$/i;
const lastParamTagMatch = content.match(lastParamTagRegex);
if (lastParamTagMatch) {
// Use the first non-undefined group as the parameter name
const paramName = lastParamTagMatch[1] || lastParamTagMatch[2];
const partialContent = lastParamTagMatch[3] || '';
if (paramName && partialContent) {
newPartialState[`__streaming_content_${paramName}`] = partialContent;
const existingParam = parameters.find(p => p.name === paramName);
if (!existingParam) {
parameters.push({
name: paramName,
value: partialContent,
isComplete: false,
isStreaming: true,
originalContent: partialContent,
});
}
}
}
}
// Update the partial state for this block if we have an ID
if (blockId) {
partialParameterState.set(blockId, newPartialState);
}
return parameters;
};
/**
* Utility functions for robust JSON and XML parsing
* Handles Unicode characters, streaming content, and edge cases
*/
/**
* Check if a string looks like valid JSON (even if incomplete)
* Enhanced to handle Unicode characters and various edge cases
*/
export const isJSONLike = (content: string): boolean => {
const trimmed = content.trim();
// Must start with { or [
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
return false;
}
// Must contain valid JSON patterns
const hasKeyValuePair = /["']?\w+["']?\s*:/u.test(trimmed);
const hasQuotes = trimmed.includes('"') || trimmed.includes("'");
return hasKeyValuePair || hasQuotes;
};
/**
* Find the first complete JSON object in a string
* This is useful for extracting JSON from content that has prefixes
*/
export const extractFirstJSONObject = (content: string): string | null => {
const firstBraceIndex = content.indexOf('{');
if (firstBraceIndex === -1) return null;
// Try to parse from the first brace
let braceCount = 0;
let inString = false;
let escapeNext = false;
for (let i = firstBraceIndex; i < content.length; i++) {
const char = content[i];
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (char === '"' || char === "'") {
inString = !inString;
continue;
}
if (!inString) {
if (char === '{') braceCount++;
if (char === '}') braceCount--;
if (braceCount === 0) {
// Found a complete object
return content.substring(firstBraceIndex, i + 1);
}
}
}
return null;
};
/**
* Strip non-ASCII prefixes from JSON content
* Handles cases where Chinese/Japanese/Arabic text appears before JSON
*/
export const stripNonASCIIPrefix = (content: string): string => {
const firstBrace = content.indexOf('{');
if (firstBrace <= 0) return content;
const prefix = content.substring(0, firstBrace);
// Check if prefix contains only non-ASCII characters and whitespace
const hasNonASCII = /[^\x00-\x7F]/.test(prefix);
const hasValidJSONPrefix = /^(?:\s*[a-zA-Z_]+\s*:\s*)?$/.test(prefix);
// If prefix has non-ASCII or looks like invalid JSON, strip it
if (hasNonASCII || !hasValidJSONPrefix) {
return content.substring(firstBrace);
}
return content;
};
/**
* Detect if JSON is likely streaming (incomplete)
*/
export const isStreamingJSON = (content: string): boolean => {
const trimmed = content.trim();
// Check for unbalanced braces
let braceCount = 0;
let inString = false;
let escapeNext = false;
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\') {
escapeNext = true;
continue;
}
if (char === '"' || char === "'") {
inString = !inString;
continue;
}
if (!inString) {
if (char === '{' || char === '[') braceCount++;
if (char === '}' || char === ']') braceCount--;
}
}
// If braces are not balanced, it's streaming
if (braceCount !== 0) return true;
// Check for trailing comma or incomplete value
if (/,\s*$/.test(trimmed) || /:\s*["']?$/.test(trimmed)) {
return true;
}
return false;
};
/**
* Extract all JSON objects from a string, handling partial/streaming content
*/
export const extractAllJSONObjects = (content: string): string[] => {
const objects: string[] = [];
let searchStart = 0;
while (searchStart < content.length) {
const startIndex = content.indexOf('{', searchStart);
if (startIndex === -1) break;
const obj = extractFirstJSONObject(content.substring(startIndex));
if (obj) {
objects.push(obj);
searchStart = startIndex + obj.length;
} else {
searchStart = startIndex + 1;
}
}
return objects;
};
/**
* Normalize Unicode string for comparison
* This helps with content deduplication and caching
*/
export const normalizeUnicodeString = (str: string): string => {
return str.normalize('NFC');
};
/**
* Count Unicode characters correctly (not just code units)
*/
export const countUnicodeChars = (str: string): number => {
return [...str].length;
};
/**
* Extract parameter values from JSON with proper Unicode and escape handling
*/
export const extractJSONParameterValues = (content: string): Map<string, string> => {
const parameters = new Map<string, string>();
// First try to parse as complete JSON
try {
const parsed = JSON.parse(content);
if (parsed.type === 'parameter' && parsed.key && parsed.value !== undefined) {
const value = typeof parsed.value === 'string' ? parsed.value : JSON.stringify(parsed.value);
parameters.set(parsed.key, unescapeJSONString(value));
}
} catch (e) {
// Fall back to regex extraction for partial/streaming content
const paramPattern =
/"type"\s*:\s*"parameter"[\s\S]*?"key"\s*:\s*"([^"]+)"[\s\S]*?"value"\s*:\s*"((?:[\s\S](?!""\s*}))*)/;
const match = content.match(paramPattern);
if (match && match[1] && match[2]) {
parameters.set(match[1], unescapeJSONString(match[2]));
}
}
return parameters;
};
/**
* Unescape JSON string with full support for all escape sequences
*/
export const unescapeJSONString = (str: string): string => {
return str
.replace(/\\n/g, '\n')
.replace(/\\t/g, '\t')
.replace(/\\r/g, '\r')
.replace(/\\"/g, '"')
.replace(/\\\\/g, '\\')
.replace(/\\b/g, '\b')
.replace(/\\f/g, '\f')
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
};
/**
* Validate if content contains function call patterns (both JSON and XML)
*/
export const containsFunctionCallPattern = (content: string): boolean => {
const trimmed = content.trim();
// JSON patterns
if (isJSONLike(trimmed)) {
return /["']?type["']?\s*:\s*["']?function_call/u.test(trimmed) || /"type"\s*:\s*"parameter"/u.test(trimmed);
}
// XML patterns
return /<function_calls>|<invoke\s+name|<parameter\s+name/u.test(trimmed);
};
/**
* Merge parameter updates while preserving streaming state
*/
export const mergeParameterUpdates = (
existing: Map<string, string>,
updates: Map<string, string>,
): Map<string, string> => {
const merged = new Map(existing);
for (const [key, value] of updates) {
// Only update if the new value is longer (more complete)
const existingValue = merged.get(key);
if (!existingValue || value.length > existingValue.length) {
merged.set(key, value);
}
}
return merged;
};
import type { ParamValueElement } from '../core/types';
import { StabilizedBlock } from '../core/types';
import { CONFIG } from '../core/config';
import { safelySetContent } from '../utils/index';
import { storeExecutedFunction, generateContentSignature } from '../mcpexecute/storage';
import { checkAndDisplayFunctionHistory, createHistoryPanel, updateHistoryPanel } from './functionHistory';
import { extractJSONParameters, stripLanguageTags, extractCleanContent } from '../parser/jsonFunctionParser';
import { createLogger } from '@extension/shared/lib/logger';
// Add type declarations for the global adapter and mcpClient access
const logger = createLogger('AdapterAccess');
declare global {
interface Window {
mcpAdapter?: any;
getCurrentAdapter?: () => any;
mcpClient?: any;
pluginRegistry?: any;
}
}
/**
* Get the current active adapter through the new plugin-based system
* Falls back to legacy global adapters for backward compatibility
*/
function getCurrentAdapter(): any {
try {
// First try to get adapter through the new plugin registry system
const pluginRegistry = (window as any).pluginRegistry;
if (pluginRegistry && typeof pluginRegistry.getActivePlugin === 'function') {
const activePlugin = pluginRegistry.getActivePlugin();
if (activePlugin && activePlugin.capabilities && activePlugin.capabilities.length > 0) {
logger.debug('[AdapterAccess] Using active plugin adapter:', activePlugin.name);
return activePlugin;
}
}
// Fallback to legacy global adapter access for backward compatibility
const legacyAdapter = window.mcpAdapter || window.getCurrentAdapter?.();
if (legacyAdapter) {
logger.debug('[AdapterAccess] Using legacy adapter access');
return legacyAdapter;
}
logger.warn('[AdapterAccess] No adapter found through plugin registry or legacy access');
return null;
} catch (error) {
logger.error('[AdapterAccess] Error getting current adapter:', error);
// Final fallback to legacy system
try {
const legacyAdapter = window.mcpAdapter || window.getCurrentAdapter?.();
if (legacyAdapter) {
logger.debug('[AdapterAccess] Using legacy adapter as fallback');
return legacyAdapter;
}
} catch (fallbackError) {
logger.error('[AdapterAccess] Fallback adapter access also failed:', fallbackError);
}
return null;
}
}
/**
* Check if the current adapter supports a specific capability
*/
function adapterSupportsCapability(capability: string): boolean {
try {
const adapter = getCurrentAdapter();
if (!adapter) return false;
// Check capabilities array (new plugin system)
if (adapter.capabilities && Array.isArray(adapter.capabilities)) {
return adapter.capabilities.includes(capability);
}
// Fallback to method existence check (legacy system)
switch (capability) {
case 'text-insertion':
return typeof adapter.insertText === 'function';
case 'form-submission':
return typeof adapter.submitForm === 'function';
case 'file-attachment':
return typeof adapter.attachFile === 'function' && adapter.supportsFileUpload?.() === true;
case 'dom-manipulation':
return typeof adapter.insertText === 'function'; // Basic DOM manipulation
default:
return false;
}
} catch (error) {
logger.error('[AdapterAccess] Error checking capability:', error);
return false;
}
}
// Performance optimizations: Cache constants and pre-compile regexes
const MAX_INSERT_LENGTH = 39000;
const WEBSITE_NAME_FOR_MAX_INSERT_LENGTH_CHECK = ['perplexity'];
const websiteName = window.location.hostname
.toLowerCase()
.replace(/^www\./i, '')
.split('.')[0];
// Pre-compiled regexes for better performance
const INVOKE_REGEX = /<invoke name="([^"]+)"(?:\s+call_id="([^"]+)")?>/;
const PARAM_REGEX = /<parameter\s+name="([^"]+)"\s*(?:type="([^"]+)")?\s*>(.*?)<\/parameter>/gs;
const CDATA_REGEX = /<!\[CDATA\[([\s\S]*?)\]\]>/;
const NUMBER_REGEX = /^-?\d+(\.\d+)?$/;
const BOOLEAN_REGEX = /^(true|false)$/i;
// SVG icons as constants for reuse
const ICONS = {
CODE: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" fill="currentColor"/></svg>',
PLAY: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>',
INSERT:
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 12l-7-7v4H2v6h7v4l7-7z" fill="currentColor"/></svg>',
ATTACH:
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="4" y="2" width="16" height="20" rx="2" fill="none" stroke="currentColor" stroke-width="2"/><path d="M8 6h8M8 10h8M8 14h4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>',
SPINNER:
'<svg width="16" height="16" viewBox="0 0 50 50"><circle cx="25" cy="25" r="20" fill="none" stroke="currentColor" stroke-width="5" stroke-linecap="round" stroke-dasharray="31.4 31.4" transform="rotate(-90 25 25)"><animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="0.8s" repeatCount="indefinite"/></circle></svg>',
};
// Performance utility: Object pooling for DOM elements
class ElementPool {
private static pools = new Map<string, HTMLElement[]>();
static get(tagName: string, className?: string): HTMLElement {
const key = `${tagName}:${className || ''}`;
const pool = this.pools.get(key) || [];
if (pool.length > 0) {
const element = pool.pop()!;
// Reset element state
element.innerHTML = '';
element.className = className || '';
element.removeAttribute('style');
return element;
}
const element = document.createElement(tagName);
if (className) element.className = className;
return element;
}
static release(element: HTMLElement): void {
const key = `${element.tagName.toLowerCase()}:${element.className}`;
const pool = this.pools.get(key) || [];
if (pool.length < 10) {
// Limit pool size
pool.push(element);
this.pools.set(key, pool);
}
}
}
// Performance utility: Create optimized DOM elements with minimal reflows
const createOptimizedElement = (
tagName: string,
options: {
className?: string;
textContent?: string;
innerHTML?: string;
styles?: Record<string, string>;
attributes?: Record<string, string>;
} = {},
): HTMLElement => {
const element = ElementPool.get(tagName, options.className);
// Batch DOM operations to minimize reflows
if (options.textContent) element.textContent = options.textContent;
if (options.innerHTML) element.innerHTML = options.innerHTML;
if (options.styles) {
Object.assign(element.style, options.styles);
}
if (options.attributes) {
Object.entries(options.attributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
}
return element;
};
/**
* Add the raw XML toggle button and pre element to a function block
* Performance optimized version using ElementPool and batch DOM operations
*
* @param blockDiv Function block div container
* @param rawContent Raw XML content to display when toggled
*/
export const addRawXmlToggle = (blockDiv: HTMLDivElement, rawContent: string): void => {
// Check for existing toggle to avoid duplicates
if (blockDiv.querySelector('.raw-toggle')) {
return;
}
// Get the original pre element that contains the function call
const blockId = blockDiv.getAttribute('data-block-id');
if (blockId) {
// Try to find the original element with the complete XML
const originalPre = document.querySelector(`div[data-block-id="${blockId}"]`);
if (originalPre) {
// Extract clean JSON content for display (removes localized UI labels)
rawContent = extractCleanContent(originalPre.textContent?.trim() || rawContent);
}
} else {
// Clean the provided rawContent as well
rawContent = extractCleanContent(rawContent);
}
// Use DocumentFragment for efficient DOM construction
const fragment = document.createDocumentFragment();
// Create container for raw XML content using optimized element creation
const rawXmlContainer = createOptimizedElement('div', {
className: 'function-results-panel xml-results-panel',
styles: {
display: 'none',
marginTop: '12px',
marginBottom: '4px',
},
});
// Create the pre element for displaying raw XML with batch style assignment
const rawXmlPre = createOptimizedElement('pre', {
textContent: rawContent,
styles: {
whiteSpace: 'pre-wrap',
margin: '0',
padding: '12px',
fontFamily: 'inherit',
fontSize: '13px',
lineHeight: '1.5',
},
});
// Create toggle button with optimized element creation
const toggleBtn = createOptimizedElement('button', {
className: 'raw-toggle',
innerHTML: `${ICONS.CODE}<span>Show Raw Info</span>`,
styles: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
},
});
// Cache references for performance
const textSpan = toggleBtn.querySelector('span')!;
let isVisible = false;
// Optimized toggle handler with requestAnimationFrame for smooth transitions
toggleBtn.onclick = () => {
isVisible = !isVisible;
// Use requestAnimationFrame for smooth visual updates
requestAnimationFrame(() => {
rawXmlContainer.style.display = isVisible ? 'block' : 'none';
textSpan.textContent = isVisible ? 'Hide Raw Info' : 'Show Raw Info';
});
};
// Batch DOM operations using fragment
rawXmlContainer.appendChild(rawXmlPre);
fragment.appendChild(toggleBtn);
// Efficiently determine parent and append
const targetParent = blockDiv.classList.contains('function-buttons')
? blockDiv.closest('.function-block') || blockDiv
: blockDiv;
// Single batch DOM update
blockDiv.appendChild(fragment);
targetParent.appendChild(rawXmlContainer);
};
/**
* Setup auto-scroll functionality for parameter value divs
*
* @param paramValueDiv Parameter value div element
*/
export const setupAutoScroll = (paramValueDiv: ParamValueElement): void => {
// Auto scroll disabled.
};
/**
* Stabilize block height to prevent layout shifts during updates
*
* @param block The block element
*/
export const stabilizeBlock = (block: HTMLElement): void => {
if (block.style.height === '') {
const rect = block.getBoundingClientRect();
block.style.height = `${rect.height}px`;
block.style.overflow = 'hidden'; // Optional: prevent content overflow during transition
if (CONFIG.debug) logger.debug(`Stabilized block height: ${rect.height}px`);
}
};
/**
* Remove stabilized height
*
* @param block The block element
*/
export const unstabilizeBlock = (block: HTMLElement): void => {
if (block.style.height !== '') {
block.style.height = '';
block.style.overflow = ''; // Reset overflow
if (CONFIG.debug) logger.debug('Unstabilized block height');
}
};
/**
* Optimized smooth block content update with improved performance
* Reduces DOM operations and uses efficient transition management
*
* @param block The function block to update
* @param newContent New HTML content to place inside the block
* @param isStreaming Whether the content is still streaming
*/
export const smoothlyUpdateBlockContent = (
block: HTMLElement,
newContent: string | HTMLElement,
isStreaming: boolean = false,
): void => {
if (!block) return;
// Performance: Check update lock more efficiently
if (!isStreaming && block.hasAttribute('data-smooth-updating')) return;
// Skip updates for completed blocks to prevent jitter
const blockId = block.getAttribute('data-block-id');
if (blockId && (window as any).completedStreams?.has(blockId)) {
if (CONFIG.debug) logger.debug(`Skipping update for completed block ${blockId}`);
return;
}
// Skip updates if block is currently resyncing
if (blockId && (window as any).resyncingBlocks?.has(blockId)) {
if (CONFIG.debug) logger.debug(`Skipping update for resyncing block ${blockId}`);
return;
}
// Cache essential properties for performance
const originalClasses = Array.from(block.classList);
const originalParent = block.parentNode;
// Mark block as updating
block.setAttribute('data-smooth-updating', 'true');
// Performance: Cache dimensions and scroll state
const originalRect = block.getBoundingClientRect();
const scrollState = {
top: block.scrollTop,
height: block.scrollHeight,
clientHeight: block.clientHeight,
wasScrollable: block.scrollHeight > block.clientHeight,
};
// Optimized shadow tracker creation
const shadowTracker = createOptimizedElement('div', {
styles: { display: 'none' },
attributes: {
'data-shadow-for': blockId || 'unknown-block',
'data-update-in-progress': 'true',
},
});
if (originalParent) {
originalParent.insertBefore(shadowTracker, block.nextSibling);
}
// Performance: Use more efficient content parsing
const parseNewContent = (content: string | HTMLElement): DocumentFragment => {
const fragment = document.createDocumentFragment();
if (typeof content === 'string') {
// Optimized parsing with better error handling
try {
const template = document.createElement('template');
template.innerHTML = content;
fragment.appendChild(template.content);
} catch (e) {
// Fallback for CSP-restricted environments
const div = document.createElement('div');
div.textContent = content;
fragment.appendChild(div);
}
} else {
fragment.appendChild(content.cloneNode(true));
}
return fragment;
};
// Pre-calculate new content dimensions efficiently
const measureNewContent = (fragment: DocumentFragment): number => {
const tempContainer = createOptimizedElement('div', {
styles: {
position: 'absolute',
visibility: 'hidden',
width: `${originalRect.width}px`,
left: '-9999px',
},
});
tempContainer.appendChild(fragment.cloneNode(true));
document.body.appendChild(tempContainer);
const height = tempContainer.offsetHeight;
document.body.removeChild(tempContainer);
ElementPool.release(tempContainer);
return height;
};
const newContentFragment = parseNewContent(newContent);
const newHeight = measureNewContent(newContentFragment);
// Optimized transition setup with batch style assignment
const transitionDuration = isStreaming ? 150 : 250;
Object.assign(block.style, {
height: `${originalRect.height}px`,
overflow: 'hidden',
transition: `height ${transitionDuration}ms ease-in-out`,
});
// Efficient content wrapper management
const createContentWrapper = (className: string, opacity: string = '1'): HTMLElement => {
return createOptimizedElement('div', {
className: `function-content-wrapper ${className}`,
styles: {
opacity,
transform: opacity === '0' ? 'translateY(10px)' : 'translateY(0)',
transition: `opacity ${transitionDuration * 0.6}ms ease-out, transform ${transitionDuration * 0.6}ms ease-out`,
},
});
};
// Move existing content to wrapper
const oldWrapper = createContentWrapper('function-content-old');
while (block.firstChild) {
oldWrapper.appendChild(block.firstChild);
}
// Create new content wrapper
const newWrapper = createContentWrapper('function-content-new', '0');
newWrapper.appendChild(newContentFragment);
// Batch DOM operations
const fragment = document.createDocumentFragment();
fragment.appendChild(oldWrapper);
fragment.appendChild(newWrapper);
block.appendChild(fragment);
// Use requestAnimationFrame for smoother animations
requestAnimationFrame(() => {
// Start height transition
block.style.height = `${newHeight}px`;
// Fade out old content
Object.assign(oldWrapper.style, {
opacity: '0',
transform: 'translateY(-10px)',
});
// Delayed fade in of new content
setTimeout(
() => {
requestAnimationFrame(() => {
Object.assign(newWrapper.style, {
opacity: '1',
transform: 'translateY(0)',
});
});
},
isStreaming ? 30 : 50,
);
});
// Optimized mutation observer for DOM changes
let blockRemoved = false;
let replacementBlock: HTMLElement | null = null;
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && Array.from(mutation.removedNodes).includes(block)) {
blockRemoved = true;
// Efficiently find replacement block
const addedElement = Array.from(mutation.addedNodes).find(
(node): node is HTMLElement =>
node.nodeType === Node.ELEMENT_NODE &&
(node as HTMLElement).classList.contains('function-block') &&
(node as HTMLElement).getAttribute('data-block-id') === blockId,
) as HTMLElement | undefined;
if (addedElement) {
replacementBlock = addedElement;
}
observer.disconnect();
return;
}
}
});
observer.observe(originalParent || document.body, { childList: true, subtree: true });
// Optimized cleanup with better performance
setTimeout(() => {
observer.disconnect();
if (blockRemoved && replacementBlock) {
// Efficiently transfer state to replacement
originalClasses.forEach(cls => {
if (!replacementBlock!.classList.contains(cls)) {
replacementBlock!.classList.add(cls);
}
});
// Batch style cleanup
Object.assign(replacementBlock.style, {
transition: '',
height: '',
overflow: '',
});
replacementBlock.removeAttribute('data-smooth-updating');
if (scrollState.wasScrollable) {
replacementBlock.scrollTop = scrollState.top;
}
} else if (document.body.contains(block)) {
// Efficient content finalization
while (newWrapper.firstChild) {
block.appendChild(newWrapper.firstChild);
}
// Batch remove old elements
[oldWrapper, newWrapper].forEach(wrapper => {
if (block.contains(wrapper)) {
ElementPool.release(wrapper);
block.removeChild(wrapper);
}
});
// Batch style reset
Object.assign(block.style, {
height: '',
overflow: '',
transition: '',
});
if (scrollState.wasScrollable) {
block.scrollTop = scrollState.top;
}
block.removeAttribute('data-smooth-updating');
}
// Cleanup shadow tracker
if (shadowTracker.parentNode) {
shadowTracker.parentNode.removeChild(shadowTracker);
ElementPool.release(shadowTracker);
}
}, transitionDuration + 50);
};
/**
* Optimized execute button creation with efficient DOM operations
* Performance improvements: batch DOM operations, use ElementPool, cache queries
*
* @param blockDiv Function block div container
* @param rawContent Raw XML content containing the function call
*/
export const addExecuteButton = (blockDiv: HTMLDivElement, rawContent: string): void => {
// Check for existing execute button to avoid duplicates
if (blockDiv.querySelector('.execute-button')) {
return;
}
// Detect format and extract function name and parameters
const isJSON = rawContent.includes('"type"') && rawContent.includes('function_call');
const functionName = extractFunctionName(rawContent);
let parameters: Record<string, any>;
let callId: string;
if (isJSON) {
// Extract JSON parameters
parameters = extractJSONParameters(rawContent);
if (CONFIG.debug) {
logger.debug('[Execute Button] Extracted JSON parameters:', parameters);
}
// Extract call_id from JSON
const lines = rawContent.split('\n');
let extractedCallId: string | null = null;
for (const line of lines) {
try {
const trimmed = stripLanguageTags(line);
if (!trimmed) continue;
const parsed = JSON.parse(trimmed);
if (parsed.type === 'function_call_start' && parsed.call_id) {
extractedCallId = parsed.call_id.toString();
break;
}
} catch (e) {
// Skip invalid lines
}
}
callId = extractedCallId || `call-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
} else {
// XML format
parameters = extractFunctionParameters(rawContent);
if (CONFIG.debug) {
logger.debug('[Execute Button] Extracted XML parameters:', parameters);
}
// Extract call_id from XML using pre-compiled regex
const callIdMatch = INVOKE_REGEX.exec(rawContent);
callId = callIdMatch?.[2] || `call-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
// If we couldn't extract a function name, don't add the button
if (!functionName) {
if (CONFIG.debug) {
logger.debug('[Execute Button] No function name found, skipping button');
}
return;
}
if (CONFIG.debug) {
logger.debug('[Execute Button] Creating button for:', functionName, 'with params:', Object.keys(parameters));
}
// Generate content signature for this function call
const contentSignature = generateContentSignature(functionName, parameters);
// Use DocumentFragment for efficient DOM construction
const fragment = document.createDocumentFragment();
// Create optimized execute button
const executeButton = createOptimizedElement('button', {
className: 'execute-button',
innerHTML: `${ICONS.PLAY}<span>Run</span>`,
styles: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
marginLeft: '0',
},
}) as HTMLButtonElement;
// Create optimized results panel
const resultsPanel = createOptimizedElement('div', {
className: 'function-results-panel',
styles: {
display: 'none',
maxHeight: '200px',
overflow: 'auto',
},
attributes: {
'data-call-id': callId,
'data-function-name': functionName,
},
}) as HTMLDivElement;
// Create optimized loading indicator
const loadingIndicator = createOptimizedElement('div', {
className: 'function-loading',
styles: {
display: 'none',
marginTop: '12px',
padding: '10px',
borderRadius: '8px',
backgroundColor: 'rgba(0, 0, 0, 0.03)',
border: '1px solid rgba(0, 0, 0, 0.06)',
},
}) as HTMLDivElement;
// Cache DOM references for performance
const buttonText = executeButton.querySelector('span')!;
// Optimized click handler with better performance and mcpClient integration
executeButton.onclick = async () => {
// Batch button state changes
executeButton.disabled = true;
buttonText.style.display = 'none';
// Add spinner efficiently
const spinner = createOptimizedElement('span', {
className: 'execute-spinner',
innerHTML: ICONS.SPINNER,
styles: {
display: 'inline-flex',
marginLeft: '8px',
},
});
executeButton.appendChild(spinner);
// Reset results panel state efficiently
resultsPanel.style.display = 'none';
resultsPanel.innerHTML = '';
// Function to reset button state
const resetButtonState = () => {
executeButton.disabled = false;
buttonText.style.display = '';
if (executeButton.contains(spinner)) {
executeButton.removeChild(spinner);
ElementPool.release(spinner);
}
};
try {
// Use global mcpClient instead of mcpHandler
const mcpClient = (window as any).mcpClient;
if (!mcpClient) {
resetButtonState();
displayResult(resultsPanel, loadingIndicator, false, 'Error: mcpClient not found');
resultsPanel.style.display = 'block';
return;
}
// Check if mcpClient is ready
if (!mcpClient.isReady || !mcpClient.isReady()) {
resetButtonState();
displayResult(resultsPanel, loadingIndicator, false, 'Error: MCP client not ready');
resultsPanel.style.display = 'block';
return;
}
logger.debug(`Executing function ${functionName}, call_id: ${callId} with arguments:`, parameters);
// Show results panel and loading indicator
resultsPanel.style.display = 'block';
resultsPanel.innerHTML = '';
resultsPanel.appendChild(loadingIndicator);
// Call tool using the new mcpClient async API
try {
const result = await mcpClient.callTool(functionName, parameters);
resetButtonState();
displayResult(resultsPanel, loadingIndicator, true, result);
// Store execution and update history efficiently
const executionData = storeExecutedFunction(functionName, callId, parameters, contentSignature);
const historyPanel = (blockDiv.querySelector('.function-history-panel') ||
createHistoryPanel(blockDiv, callId, contentSignature)) as HTMLDivElement;
// Update history panel with mcpClient reference
updateHistoryPanel(historyPanel, executionData, mcpClient);
} catch (toolError: any) {
resetButtonState();
// Enhanced error handling for connection issues
let errorMessage = toolError instanceof Error ? toolError.message : String(toolError);
// Check for connection-related errors and provide better user feedback
if (errorMessage.includes('not connected') || errorMessage.includes('connection')) {
errorMessage = 'Connection lost. Please check your MCP server connection.';
} else if (errorMessage.includes('timeout')) {
errorMessage = 'Request timed out. Please try again.';
} else if (errorMessage.includes('server unavailable') || errorMessage.includes('SERVER_UNAVAILABLE')) {
errorMessage = 'MCP server is unavailable. Please check the server status.';
}
displayResult(resultsPanel, loadingIndicator, false, errorMessage);
}
} catch (error: any) {
resetButtonState();
resultsPanel.style.display = 'block';
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Execute button error:', error);
displayResult(
resultsPanel,
loadingIndicator,
false,
`Unexpected error: ${errorMessage}`,
);
}
};
// Batch DOM operations
fragment.appendChild(executeButton);
blockDiv.appendChild(fragment);
// Efficiently determine target parent
const targetParent = blockDiv.classList.contains('function-buttons')
? blockDiv.closest('.function-block') || blockDiv
: blockDiv;
targetParent.appendChild(resultsPanel);
// Check for previous executions efficiently
checkAndDisplayFunctionHistory(blockDiv, functionName, callId, contentSignature);
};
/**
* Extract function name from raw content (supports both XML and JSON formats)
*
* @param rawContent Raw XML or JSON content
* @returns The function name or null if not found
*/
const extractFunctionName = (rawContent: string): string | null => {
// Check for JSON format first
const isJSON = rawContent.includes('"type"') && rawContent.includes('function_call_start');
if (isJSON) {
// Extract from JSON format
const lines = rawContent.split('\n');
for (const line of lines) {
try {
const trimmed = stripLanguageTags(line);
if (!trimmed) continue;
const parsed = JSON.parse(trimmed);
if (parsed.type === 'function_call_start' && parsed.name) {
return parsed.name;
}
} catch (e) {
// Skip invalid JSON lines
}
}
return null;
}
// XML format
const invokeMatch = rawContent.match(/<invoke name="([^"]+)"(?:\s+call_id="([^"]+)")?>/);
return invokeMatch && invokeMatch[1] ? invokeMatch[1] : null;
};
/**
* Optimized function parameter extraction with pre-compiled regex patterns
* Performance improvements: use pre-compiled regexes, reduce string operations
*
* @param rawContent Raw XML content
* @returns Object with parameter names and values
*/
export const extractFunctionParameters = (rawContent: string): Record<string, any> => {
const parameters: Record<string, any> = {};
// Use pre-compiled regex for better performance
let match;
while ((match = PARAM_REGEX.exec(rawContent)) !== null) {
const name = match[1];
const type = match[2] || 'string';
let value: any = match[3].trim();
// Check for CDATA using pre-compiled regex
const cdataMatch = CDATA_REGEX.exec(value);
if (cdataMatch) {
try {
value = cdataMatch[1].trim();
if (CONFIG.debug) logger.debug(`Extracted CDATA content for parameter ${name}`);
} catch (e) {
logger.error(`Failed to extract CDATA content for parameter ${name}:`, e);
// value already set to original
}
}
// Optimized type parsing with pre-compiled regexes
switch (type) {
case 'json':
try {
value = JSON.parse(value);
} catch (e) {
logger.warn(`Failed to parse JSON for parameter '${name}'.`, e);
}
break;
case 'number':
const num = parseFloat(value);
if (!isNaN(num)) value = num;
break;
case 'boolean':
value = value.toLowerCase() === 'true';
break;
default:
// Auto-detect numeric, boolean, and JSON-like values
if (NUMBER_REGEX.test(value)) {
value = parseFloat(value);
} else if (BOOLEAN_REGEX.test(value)) {
value = value.toLowerCase() === 'true';
} else {
// Try to parse as JSON if it looks like JSON (starts with { or [)
const trimmedValue = value.trim();
if ((trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) ||
(trimmedValue.startsWith('[') && trimmedValue.endsWith(']'))) {
try {
value = JSON.parse(trimmedValue);
if (CONFIG.debug) logger.debug(`Auto-parsed JSON for parameter ${name}:`, value);
} catch (e) {
// If JSON parsing fails, keep as string
if (CONFIG.debug) logger.debug(`Failed to auto-parse JSON for parameter ${name}, keeping as string`);
}
}
}
}
parameters[name] = value;
}
// Reset regex lastIndex for reuse (important for global regexes)
PARAM_REGEX.lastIndex = 0;
CDATA_REGEX.lastIndex = 0;
return parameters;
};
/**
* Optimized file attachment helper with improved performance
* Performance improvements: reduce DOM operations, batch state changes, efficient event handling
* Updated to work with the new plugin-based adapter system
*/
const attachResultAsFile = async (
adapter: any,
functionName: string,
callId: string,
rawResultText: string,
button: HTMLButtonElement,
iconSpan: HTMLElement | null,
skipAutoInsertCheck: boolean = false,
): Promise<{ success: boolean; message: string | null }> => {
// Early validation for better performance
if (!adapter) {
logger.error('No adapter provided for file attachment.');
const handleUnsupported = () => {
const originalText = button.classList.contains('insert-result-button') ? 'Insert' : 'Attach File';
button.innerHTML = `${ICONS.ATTACH}<span>No Adapter</span>`;
button.classList.add('attach-error');
setTimeout(() => {
button.innerHTML = `${ICONS.ATTACH}<span>${originalText}</span>`;
button.classList.remove('attach-error');
}, 2000);
};
handleUnsupported();
return { success: false, message: null };
}
// Check if adapter supports file attachment using the new capability system
if (!adapterSupportsCapability('file-attachment')) {
logger.error('Current adapter does not support file attachment.');
const handleUnsupported = () => {
const originalText = button.classList.contains('insert-result-button') ? 'Insert' : 'Attach File';
button.innerHTML = `${ICONS.ATTACH}<span>Attach Not Supported</span>`;
button.classList.add('attach-error');
setTimeout(() => {
button.innerHTML = `${ICONS.ATTACH}<span>${originalText}</span>`;
button.classList.remove('attach-error');
}, 2000);
};
handleUnsupported();
return { success: false, message: null };
}
const fileName = `${functionName}_result_call_id_${callId}.txt`;
const file = new File([rawResultText], fileName, { type: 'text/plain' });
const originalButtonText = button.textContent || 'Attach File';
let confirmationText: string | null = null;
// Optimized button state management
const setButtonState = (text: string, className?: string, disabled: boolean = true) => {
// Clear button content and rebuild properly
button.innerHTML = '';
// Create new icon element to avoid DOM reference issues
const iconElement = createOptimizedElement('span', {
innerHTML: ICONS.ATTACH,
styles: {
display: 'inline-flex',
marginRight: '6px',
},
});
const textElement = createOptimizedElement('span', {
textContent: text,
});
button.appendChild(iconElement);
button.appendChild(textElement);
button.disabled = disabled;
if (className) {
button.classList.add(className);
}
};
const resetButtonState = (delay: number = 2000) => {
setTimeout(() => {
// Reset to original button structure
button.innerHTML = `${ICONS.ATTACH}<span>Attach File</span>`;
button.classList.remove('attach-success', 'attach-error');
button.disabled = false;
}, delay);
};
try {
setButtonState('Attaching...', undefined, true);
// Try the new plugin system attachFile method first
if (typeof adapter.attachFile === 'function') {
try {
const success = await adapter.attachFile(file);
if (success) {
confirmationText = `File attached successfully: ${fileName}`;
setButtonState('Attached!', 'attach-success', true);
// Insert the confirmation text into the input field
if (typeof adapter.insertText === 'function') {
try {
await adapter.insertText(confirmationText);
logger.debug('Confirmation text inserted successfully');
} catch (insertError) {
logger.warn('Failed to insert confirmation text:', insertError);
// Fallback to legacy method if available
if (typeof adapter.insertTextIntoInput === 'function') {
try {
// Dispatch event for legacy insertion
requestAnimationFrame(() => {
document.dispatchEvent(
new CustomEvent('mcp:tool-execution-complete', {
detail: {
result: confirmationText,
isFileAttachment: false,
fileName: '',
skipAutoInsertCheck: true,
},
}),
);
});
} catch (legacyError) {
logger.warn('Legacy insertion also failed:', legacyError);
}
}
}
} else if (typeof adapter.insertTextIntoInput === 'function') {
// Use legacy method directly
try {
requestAnimationFrame(() => {
document.dispatchEvent(
new CustomEvent('mcp:tool-execution-complete', {
detail: {
result: confirmationText,
isFileAttachment: false,
fileName: '',
skipAutoInsertCheck: true,
},
}),
);
});
} catch (legacyError) {
logger.warn('Legacy insertion failed:', legacyError);
}
}
// Efficient event dispatch for file attachment
const eventDetail = {
file,
result: confirmationText,
isFileAttachment: true,
fileName,
confirmationText,
skipAutoInsertCheck,
};
// Use requestAnimationFrame for better performance
requestAnimationFrame(() => {
document.dispatchEvent(new CustomEvent('mcp:tool-execution-complete', { detail: eventDetail }));
});
resetButtonState();
return { success: true, message: confirmationText };
} else {
throw new Error('Adapter attachFile method returned false');
}
} catch (error) {
logger.error('New adapter attachFile method failed:', error);
// For now, we'll consider it successful since it's a complex operation
// This is optimistic handling for better UX
confirmationText = `File attachment initiated: ${fileName}`;
setButtonState('Attached!', 'attach-success', true);
// Insert the confirmation text into the input field
if (typeof adapter.insertText === 'function') {
try {
await adapter.insertText(confirmationText);
logger.debug('Confirmation text inserted successfully');
} catch (insertError) {
logger.warn('Failed to insert confirmation text:', insertError);
// Fallback to legacy method if available
if (typeof adapter.insertTextIntoInput === 'function') {
try {
// Dispatch event for legacy insertion
requestAnimationFrame(() => {
document.dispatchEvent(
new CustomEvent('mcp:tool-execution-complete', {
detail: {
result: confirmationText,
isFileAttachment: false,
fileName: '',
skipAutoInsertCheck: true,
},
}),
);
});
} catch (legacyError) {
logger.warn('Legacy insertion also failed:', legacyError);
}
}
}
} else if (typeof adapter.insertTextIntoInput === 'function') {
// Use legacy method directly
try {
requestAnimationFrame(() => {
document.dispatchEvent(
new CustomEvent('mcp:tool-execution-complete', {
detail: {
result: confirmationText,
isFileAttachment: false,
fileName: '',
skipAutoInsertCheck: true,
},
}),
);
});
} catch (legacyError) {
logger.warn('Legacy insertion failed:', legacyError);
}
}
const eventDetail = {
file,
result: confirmationText,
isFileAttachment: true,
fileName,
confirmationText,
skipAutoInsertCheck,
};
requestAnimationFrame(() => {
document.dispatchEvent(new CustomEvent('mcp:tool-execution-complete', { detail: eventDetail }));
});
resetButtonState();
return { success: true, message: confirmationText };
}
} else {
// Fallback: Optimistic success for adapters without explicit attachFile method
// This maintains compatibility while providing user feedback
logger.debug('Adapter does not have attachFile method, using optimistic success');
confirmationText = `File attachment completed: ${fileName}`;
setButtonState('Attached!', 'attach-success', true);
// Insert the confirmation text into the input field
if (typeof adapter.insertText === 'function') {
try {
await adapter.insertText(confirmationText);
logger.debug('Confirmation text inserted successfully');
} catch (insertError) {
logger.warn('Failed to insert confirmation text:', insertError);
// Fallback to legacy method if available
if (typeof adapter.insertTextIntoInput === 'function') {
try {
// Dispatch event for legacy insertion
requestAnimationFrame(() => {
document.dispatchEvent(
new CustomEvent('mcp:tool-execution-complete', {
detail: {
result: confirmationText,
isFileAttachment: false,
fileName: '',
skipAutoInsertCheck: true,
},
}),
);
});
} catch (legacyError) {
logger.warn('Legacy insertion also failed:', legacyError);
}
}
}
} else if (typeof adapter.insertTextIntoInput === 'function') {
// Use legacy method directly
try {
requestAnimationFrame(() => {
document.dispatchEvent(
new CustomEvent('mcp:tool-execution-complete', {
detail: {
result: confirmationText,
isFileAttachment: false,
fileName: '',
skipAutoInsertCheck: true,
},
}),
);
});
} catch (legacyError) {
logger.warn('Legacy insertion failed:', legacyError);
}
}
const eventDetail = {
file,
result: confirmationText,
isFileAttachment: true,
fileName,
confirmationText,
skipAutoInsertCheck,
};
requestAnimationFrame(() => {
document.dispatchEvent(new CustomEvent('mcp:tool-execution-complete', { detail: eventDetail }));
});
resetButtonState();
return { success: true, message: confirmationText };
}
} catch (e) {
logger.error('File attachment error:', e);
setButtonState('Failed', 'attach-error', true);
resetButtonState();
}
return { success: false, message: null };
};
/**
* Optimized result display with efficient DOM operations and batch processing
* Performance improvements: reduce DOM queries, batch operations, efficient element creation
*
* @param resultsPanel Results panel element
* @param loadingIndicator Loading indicator element
* @param success Whether the execution was successful
* @param result Result or error message
*/
export const displayResult = (
resultsPanel: HTMLDivElement,
loadingIndicator: HTMLDivElement,
success: boolean,
result: any,
): void => {
// Cache attributes for performance
const callId = resultsPanel.getAttribute('data-call-id') || '';
const functionName = resultsPanel.getAttribute('data-function-name') || '';
// Efficient cleanup of previous results
const cleanupPreviousResults = () => {
// Remove loading indicator if present
if (loadingIndicator.parentNode === resultsPanel) {
resultsPanel.removeChild(loadingIndicator);
}
// Batch remove existing result content
const existingResults = resultsPanel.querySelectorAll('.function-result-success, .function-result-error');
existingResults.forEach(el => resultsPanel.removeChild(el));
// Remove previous button container
const existingButtonContainer = resultsPanel.nextElementSibling;
if (existingButtonContainer?.classList.contains('insert-button-container')) {
existingButtonContainer.parentNode?.removeChild(existingButtonContainer);
}
};
// Optimized error message processing
const processErrorMessage = (errorResult: any): string => {
let errorMessage = '';
if (typeof errorResult === 'string') {
errorMessage = errorResult;
} else if (errorResult && typeof errorResult === 'object') {
errorMessage = errorResult.message || 'An unknown error occurred';
} else {
errorMessage = 'An unknown error occurred';
}
// Optimize server error message handling
if (typeof errorMessage === 'string') {
const errorMap = {
SERVER_UNAVAILABLE: 'Server is disconnected. Please check your connection settings.',
CONNECTION_ERROR: 'Connection to server failed. Please try reconnecting.',
RECONNECT_ERROR: 'Connection to server failed. Please try reconnecting.',
SERVER_ERROR: 'Server error occurred. Please check server status.',
};
for (const [key, message] of Object.entries(errorMap)) {
if (errorMessage.includes(key)) {
return message;
}
}
}
return errorMessage;
};
// Clean up previous results
cleanupPreviousResults();
loadingIndicator.style.display = 'none';
if (success) {
// Optimized success result processing
let rawResultText = '';
// Create result content efficiently
const resultContent = createOptimizedElement('div', {
className: 'function-result-success',
});
// Process result data efficiently
if (typeof result === 'object') {
try {
// Check if result has the new format with content array
if (result && result.content && Array.isArray(result.content)) {
// Extract text from content array
const textParts = result.content
.filter((item: any) => item.type === 'text' && item.text)
.map((item: any) => item.text);
if (textParts.length > 0) {
rawResultText = textParts.join('\n');
resultContent.textContent = rawResultText;
} else {
// Fallback to full JSON if no text content found
rawResultText = JSON.stringify(result, null, 2);
const pre = createOptimizedElement('pre', {
textContent: rawResultText,
styles: {
fontFamily: 'inherit',
fontSize: '13px',
lineHeight: '1.5',
padding: '0',
margin: '0',
},
});
resultContent.appendChild(pre);
}
} else {
// Original object handling for backward compatibility
rawResultText = JSON.stringify(result, null, 2);
const pre = createOptimizedElement('pre', {
textContent: rawResultText,
styles: {
fontFamily: 'inherit',
fontSize: '13px',
lineHeight: '1.5',
padding: '0',
margin: '0',
},
});
resultContent.appendChild(pre);
}
} catch (e) {
rawResultText = String(result);
resultContent.textContent = rawResultText;
}
} else {
rawResultText = String(result);
resultContent.textContent = rawResultText;
}
// Add result to panel
resultsPanel.appendChild(resultContent);
// Create button container efficiently using DocumentFragment
const fragment = document.createDocumentFragment();
const buttonContainer = createOptimizedElement('div', {
className: 'function-buttons insert-button-container',
styles: {
display: 'flex',
justifyContent: 'flex-end',
marginTop: '10px',
marginBottom: '10px',
},
});
// Create optimized insert button
const insertButton = createOptimizedElement('button', {
className: 'insert-result-button',
innerHTML: `${ICONS.INSERT}<span>Insert</span>`,
styles: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
},
attributes: {
'data-result-id': `result-${callId}-${Date.now()}`,
},
}) as HTMLButtonElement;
// Cache button text element
const insertButtonText = insertButton.querySelector('span')!;
// Optimized insert button click handler
insertButton.onclick = async () => {
const adapter = getCurrentAdapter();
if (!adapter) {
const setErrorState = () => {
insertButton.textContent = 'Failed (No Adapter)';
insertButton.classList.add('insert-error');
setTimeout(() => {
insertButton.innerHTML = `${ICONS.INSERT}<span>Insert</span>`;
insertButton.classList.remove('insert-error');
}, 2000);
};
setErrorState();
logger.error('No adapter available for text insertion.');
return;
}
// Check if adapter supports text insertion
if (!adapterSupportsCapability('text-insertion')) {
const setErrorState = () => {
insertButton.textContent = 'Not Supported';
insertButton.classList.add('insert-error');
setTimeout(() => {
insertButton.innerHTML = `${ICONS.INSERT}<span>Insert</span>`;
insertButton.classList.remove('insert-error');
}, 2000);
};
setErrorState();
logger.error('Current adapter does not support text insertion.');
return;
}
const wrapperText = `<function_result call_id="${callId}">\n${rawResultText}\n</function_result>`;
// Check result length and handle accordingly
if (rawResultText.length > MAX_INSERT_LENGTH && WEBSITE_NAME_FOR_MAX_INSERT_LENGTH_CHECK.includes(websiteName)) {
logger.debug(`Result length (${wrapperText.length}) exceeds ${MAX_INSERT_LENGTH}. Attaching as file.`);
await attachResultAsFile(
adapter,
functionName,
callId,
wrapperText,
insertButton,
insertButton.querySelector('span') as HTMLElement,
true,
);
} else {
// Try the new plugin system insertText method first
if (typeof adapter.insertText === 'function') {
try {
const success = await adapter.insertText(wrapperText);
if (success) {
// Optimized success state handling
insertButton.textContent = 'Inserted!';
insertButton.classList.add('insert-success');
insertButton.disabled = true;
setTimeout(() => {
insertButton.innerHTML = `${ICONS.INSERT}<span>Insert</span>`;
insertButton.classList.remove('insert-success');
insertButton.disabled = false;
}, 2000);
// Efficient event dispatch with requestAnimationFrame
requestAnimationFrame(() => {
document.dispatchEvent(
new CustomEvent('mcp:tool-execution-complete', {
detail: {
result: wrapperText,
isFileAttachment: false,
fileName: '',
skipAutoInsertCheck: true,
},
}),
);
});
} else {
throw new Error('Adapter insertText method returned false');
}
} catch (error) {
logger.error('New adapter insertText method failed:', error);
// Fallback to legacy method if available
if (typeof adapter.insertTextIntoInput === 'function') {
logger.debug('Falling back to legacy insertTextIntoInput method');
// Efficient event dispatch with requestAnimationFrame
requestAnimationFrame(() => {
document.dispatchEvent(
new CustomEvent('mcp:tool-execution-complete', {
detail: {
result: wrapperText,
isFileAttachment: false,
fileName: '',
skipAutoInsertCheck: true,
},
}),
);
});
// Optimized success state handling
insertButton.textContent = 'Inserted!';
insertButton.classList.add('insert-success');
insertButton.disabled = true;
setTimeout(() => {
insertButton.innerHTML = `${ICONS.INSERT}<span>Insert</span>`;
insertButton.classList.remove('insert-success');
insertButton.disabled = false;
}, 2000);
} else {
// Optimized error state
logger.error('No valid insert method found on adapter');
insertButton.textContent = 'Failed (No Insert Method)';
insertButton.classList.add('insert-error');
setTimeout(() => {
insertButton.innerHTML = `${ICONS.INSERT}<span>Insert</span>`;
insertButton.classList.remove('insert-error');
}, 2000);
}
}
} else if (typeof adapter.insertTextIntoInput === 'function') {
// Legacy method fallback
logger.debug('Using legacy insertTextIntoInput method');
// Efficient event dispatch with requestAnimationFrame
requestAnimationFrame(() => {
document.dispatchEvent(
new CustomEvent('mcp:tool-execution-complete', {
detail: {
result: wrapperText,
isFileAttachment: false,
fileName: '',
skipAutoInsertCheck: true,
},
}),
);
});
// Optimized success state handling
insertButton.textContent = 'Inserted!';
insertButton.classList.add('insert-success');
insertButton.disabled = true;
setTimeout(() => {
insertButton.innerHTML = `${ICONS.INSERT}<span>Insert</span>`;
insertButton.classList.remove('insert-success');
insertButton.disabled = false;
}, 2000);
} else {
// Optimized error state
logger.error('Adapter has no insert method available');
insertButton.textContent = 'Failed (No Insert Method)';
insertButton.classList.add('insert-error');
setTimeout(() => {
insertButton.innerHTML = `${ICONS.INSERT}<span>Insert</span>`;
insertButton.classList.remove('insert-error');
}, 2000);
}
}
};
// Create attach button efficiently
const attachButton = createOptimizedElement('button', {
className: 'attach-file-button',
innerHTML: `${ICONS.ATTACH}<span>Attach File</span>`,
styles: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '6px',
},
attributes: {
'data-result-id': `attach-${callId}-${Date.now()}`,
},
}) as HTMLButtonElement;
// Optimized attach button handler
attachButton.onclick = async () => {
const adapter = getCurrentAdapter();
await attachResultAsFile(
adapter,
functionName,
callId,
rawResultText,
attachButton,
null, // No longer need iconSpan parameter
true, // Set skipAutoInsertCheck to true to prevent AutomationService from auto-inserting the same file
);
};
// Efficiently build button container
buttonContainer.appendChild(insertButton);
// Only add attach button if supported
const adapter = getCurrentAdapter();
if (adapter && adapterSupportsCapability('file-attachment')) {
buttonContainer.appendChild(attachButton);
}
// Batch DOM update
fragment.appendChild(buttonContainer);
resultsPanel.parentNode?.insertBefore(fragment, resultsPanel.nextSibling);
// Handle auto-attachment for large results
if (
rawResultText.length > MAX_INSERT_LENGTH &&
adapter && adapterSupportsCapability('file-attachment') &&
WEBSITE_NAME_FOR_MAX_INSERT_LENGTH_CHECK.includes(websiteName)
) {
logger.debug(`Auto-attaching file: Result length (${rawResultText.length}) exceeds ${MAX_INSERT_LENGTH}`);
// Create efficient fake button for auto-attachment
const fakeElements = {
button: createOptimizedElement('button', {
className: 'insert-result-button',
styles: { display: 'none' },
}) as HTMLButtonElement,
};
attachResultAsFile(adapter, functionName, callId, rawResultText, fakeElements.button, null, true) // Set to true to prevent double attachment
.then(async ({ success, message }) => {
if (success && message) {
logger.debug(`Auto-attached file successfully: ${message}`);
// Insert the auto-attachment confirmation text
if (typeof adapter.insertText === 'function') {
try {
await adapter.insertText(message);
logger.debug('Auto-attachment confirmation text inserted successfully');
} catch (insertError) {
logger.warn('Failed to insert auto-attachment confirmation text:', insertError);
// Fallback to legacy method if available
if (typeof adapter.insertTextIntoInput === 'function') {
try {
// Dispatch event for legacy insertion
requestAnimationFrame(() => {
document.dispatchEvent(
new CustomEvent('mcp:tool-execution-complete', {
detail: {
result: message,
isFileAttachment: false,
fileName: '',
skipAutoInsertCheck: true,
},
}),
);
});
} catch (legacyError) {
logger.warn('Legacy insertion for auto-attachment also failed:', legacyError);
}
}
}
} else if (typeof adapter.insertTextIntoInput === 'function') {
// Use legacy method directly
try {
requestAnimationFrame(() => {
document.dispatchEvent(
new CustomEvent('mcp:tool-execution-complete', {
detail: {
result: message,
isFileAttachment: false,
fileName: '',
skipAutoInsertCheck: true,
},
}),
);
});
} catch (legacyError) {
logger.warn('Legacy insertion for auto-attachment failed:', legacyError);
}
}
} else {
logger.error('Failed to auto-attach file.');
// Fallback to manual attach button
setTimeout(() => attachButton.click(), 100);
}
// Cleanup fake elements
ElementPool.release(fakeElements.button);
})
.catch(err => {
logger.error('Error auto-attaching file:', err);
ElementPool.release(fakeElements.button);
});
} else {
// Dispatch event for normal-sized results
const wrappedResult = `<function_result call_id="${callId}">\n${rawResultText}\n</function_result>`;
// Dispatch event - delays are handled by automation service
requestAnimationFrame(() => {
document.dispatchEvent(
new CustomEvent('mcp:tool-execution-complete', {
detail: {
result: wrappedResult,
skipAutoInsertCheck: false
},
}),
);
});
}
} else {
// Optimized error result handling
const errorMessage = processErrorMessage(result);
const resultContent = createOptimizedElement('div', {
className: 'function-result-error',
textContent: errorMessage,
});
resultsPanel.appendChild(resultContent);
}
};
import { CONFIG } from '../core/config';
import { containsFunctionCalls, extractLanguageTag } from '../parser/index';
import { safelySetContent } from '../utils/index';
import {
addRawXmlToggle,
addExecuteButton,
setupAutoScroll,
smoothlyUpdateBlockContent,
extractFunctionParameters,
} from './components';
import { applyThemeClass } from '../utils/themeDetector';
import { getPreviousExecution, getPreviousExecutionLegacy, generateContentSignature } from '../mcpexecute/storage';
import type { ParamValueElement } from '../core/types';
import { extractJSONFunctionInfo, extractJSONParameters } from '../parser/jsonFunctionParser';
import { createLogger } from '@extension/shared/lib/logger';
// Define custom property for tracking scroll state
const logger = createLogger('FunctionBlockRenderer');
declare global {
interface HTMLElement {
_userHasScrolled?: boolean;
_scrollInitialized?: boolean;
_scrollCleanup?: () => void;
_scrollHandlersInitialized?: boolean;
value?: string; // For compatibility with input elements
}
}
// Constants
const STREAMING_DEBOUNCE_MS = 16; // ~60fps for smooth updates
const MAX_AUTO_EXECUTE_ATTEMPTS = 3;
const CACHE_TTL = 1000; // 1 second cache TTL
const STREAMING_TIMEOUT = 1500;
// Performance optimizations: Pre-compiled regex patterns
const REGEX_CACHE = {
paramStartRegex: /<parameter\s+name="([^"]+)"[^>]*>/gs,
invokeMatch: /<invoke name="([^"]+)"(?:\s+call_id="([^"]+)")?>/i,
cdataMatch: /<!\[CDATA\[(.*?)(?:\]\]>)?$/s,
endParameterTag: '</parameter>',
} as const;
// Type definitions
interface ParsedContent {
functionName: string;
callId: string;
parameters: Record<string, string>;
}
interface CachedElements {
functionNameElement?: HTMLDivElement;
paramsContainer?: HTMLDivElement;
buttonContainer?: HTMLDivElement;
lastCacheTime: number;
}
interface ContentCache {
content: string;
functionName: string;
callId: string;
parameters: Record<string, string>;
lastHash: string;
}
interface ScrollHandler {
element: HTMLElement;
timeout?: number;
cleanup: () => void;
}
// Performance caches
const contentParsingCache = new WeakMap<HTMLElement, ContentCache>();
const elementQueryCache = new WeakMap<HTMLElement, CachedElements>();
const pendingDOMUpdates = new Map<string, (() => void)[]>();
const streamingDebouncers = new Map<string, number>();
const activeTimeouts = new Map<string, number>();
// RAF scheduling
let rafScheduled = false;
// Utility function to get automation state
function getAutomationState() {
// First try the new store-based state (exposed by automation service)
const automationState = (window as any).__mcpAutomationState;
if (automationState) {
return {
autoInsert: automationState.autoInsert || false,
autoSubmit: automationState.autoSubmit || false,
autoExecute: automationState.autoExecute || false,
};
}
// Fallback to legacy toggle state
const legacyState = (window as any).toggleState;
return {
autoInsert: legacyState?.autoInsert === true,
autoSubmit: legacyState?.autoSubmit === true,
autoExecute: legacyState?.autoExecute === true,
};
}
// Common style configurations
const STREAMING_STYLES = {
pre: {
margin: '0',
padding: '12px 14px',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
width: '100%',
fontFamily: 'var(--font-mono)',
fontSize: '13px',
lineHeight: '1.5',
transition: 'opacity 0.1s ease-out',
transform: 'translateZ(0)',
backfaceVisibility: 'hidden',
perspective: '1000px',
color: 'inherit',
background: 'transparent',
border: 'none',
overflow: 'auto',
maxHeight: '300px',
scrollBehavior: 'smooth',
},
paramValue: {
transition: 'all 0.15s cubic-bezier(0.4, 0, 0.2, 1)',
transformOrigin: 'top left',
willChange: 'auto',
contain: 'layout style paint',
minHeight: '1.2em',
position: 'relative',
},
contentWrapper: {
position: 'relative',
overflow: 'hidden',
minHeight: 'inherit',
},
paramsContainer: {
display: 'flex',
flexDirection: 'column',
gap: '4px',
width: '100%',
},
} as const;
// Common DOM utilities
const DOMUtils = {
applyStyles: (element: HTMLElement, styles: Record<string, any>): void => {
Object.assign(element.style, styles);
},
createElement: <T extends HTMLElement>(
tag: string,
className?: string,
attributes?: Record<string, string>,
styles?: Record<string, any>,
): T => {
const element = document.createElement(tag) as T;
if (className) element.className = className;
if (attributes) {
Object.entries(attributes).forEach(([key, value]) => {
element.setAttribute(key, value);
});
}
if (styles) DOMUtils.applyStyles(element, styles);
return element;
},
setContent: (element: HTMLElement, content: string, isHTML = false): void => {
if (isHTML) {
element.innerHTML = content;
} else {
element.textContent = content;
}
},
updateTextIfChanged: (element: HTMLElement, newText: string): boolean => {
if (element.textContent !== newText) {
element.textContent = newText;
return true;
}
return false;
},
};
// Cache management utilities
const CacheUtils = {
generateContentHash: (content: string): string => {
let hash = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash.toString(36);
},
getCachedElements: (
blockDiv: HTMLElement,
): {
functionNameElement?: HTMLDivElement;
paramsContainer?: HTMLDivElement;
buttonContainer?: HTMLDivElement;
} => {
const now = Date.now();
let cache = elementQueryCache.get(blockDiv);
if (!cache || now - cache.lastCacheTime > CACHE_TTL) {
cache = {
functionNameElement: blockDiv.querySelector<HTMLDivElement>('.function-name') || undefined,
paramsContainer: blockDiv.querySelector<HTMLDivElement>('.function-params') || undefined,
buttonContainer: blockDiv.querySelector<HTMLDivElement>('.function-buttons') || undefined,
lastCacheTime: now,
};
elementQueryCache.set(blockDiv, cache);
}
return cache;
},
parseContentEfficiently: (block: HTMLElement, rawContent: string): ParsedContent => {
const contentHash = CacheUtils.generateContentHash(rawContent);
let cached = contentParsingCache.get(block);
if (cached && cached.lastHash === contentHash) {
return {
functionName: cached.functionName,
callId: cached.callId,
parameters: cached.parameters,
};
}
const invokeMatch = REGEX_CACHE.invokeMatch.exec(rawContent);
const functionName = invokeMatch ? invokeMatch[1] : 'function';
const callId =
invokeMatch && invokeMatch[2]
? invokeMatch[2]
: `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
const parameters: Record<string, string> = {};
REGEX_CACHE.paramStartRegex.lastIndex = 0;
let match;
while ((match = REGEX_CACHE.paramStartRegex.exec(rawContent)) !== null) {
const paramName = match[1];
const startIndex = match.index + match[0].length;
const endTagIndex = rawContent.indexOf(REGEX_CACHE.endParameterTag, startIndex);
let extractedValue =
endTagIndex !== -1 ? rawContent.substring(startIndex, endTagIndex) : rawContent.substring(startIndex);
const cdataMatch = REGEX_CACHE.cdataMatch.exec(extractedValue);
extractedValue = cdataMatch ? cdataMatch[1] : extractedValue.trim();
parameters[paramName] = extractedValue;
}
cached = {
content: rawContent,
functionName,
callId,
parameters,
lastHash: contentHash,
};
contentParsingCache.set(block, cached);
return { functionName, callId, parameters };
},
};
// Performance utilities
const PerformanceUtils = {
batchDOMOperation: (blockId: string, operation: () => void): void => {
if (!pendingDOMUpdates.has(blockId)) {
pendingDOMUpdates.set(blockId, []);
}
pendingDOMUpdates.get(blockId)!.push(operation);
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
pendingDOMUpdates.forEach(operations => {
operations.forEach(op => op());
});
pendingDOMUpdates.clear();
rafScheduled = false;
});
}
},
batchStreamingUpdate: (paramId: string, operation: () => void): void => {
const existing = streamingDebouncers.get(paramId);
if (existing) {
clearTimeout(existing);
}
streamingDebouncers.set(
paramId,
window.setTimeout(() => {
requestAnimationFrame(() => {
operation();
streamingDebouncers.delete(paramId);
});
}, STREAMING_DEBOUNCE_MS),
);
},
cleanupTimeout: (key: string): void => {
const timeoutId = activeTimeouts.get(key);
if (timeoutId) {
clearTimeout(timeoutId);
activeTimeouts.delete(key);
}
},
setManagedTimeout: (key: string, callback: () => void, delay: number): void => {
PerformanceUtils.cleanupTimeout(key);
const timeoutId = window.setTimeout(() => {
callback();
activeTimeouts.delete(key);
}, delay);
activeTimeouts.set(key, timeoutId);
},
};
// Scroll management utilities
const ScrollUtils = {
createScrollHandler: (element: HTMLElement): ScrollHandler => {
let scrollTimeout: number | undefined;
const onScroll = () => {
(element as any)._userHasScrolled = true;
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = window.setTimeout(() => {
const isNearBottom = element.scrollTop >= element.scrollHeight - element.clientHeight - 50;
if (isNearBottom) {
(element as any)._userHasScrolled = false;
}
}, 3000);
};
const cleanup = () => {
element.removeEventListener('scroll', onScroll);
if (scrollTimeout) clearTimeout(scrollTimeout);
(element as any)._scrollInitialized = false;
};
element.addEventListener('scroll', onScroll, { passive: true });
(element as any)._scrollInitialized = true;
(element as any)._scrollCleanup = cleanup;
return { element, timeout: scrollTimeout, cleanup };
},
setupScrollTracking: (paramValueElement: HTMLElement): void => {
if (!(paramValueElement as any)._scrollHandlersInitialized) {
ScrollUtils.createScrollHandler(paramValueElement);
const preElement = paramValueElement.querySelector('pre');
if (preElement) {
ScrollUtils.createScrollHandler(preElement);
}
(paramValueElement as any)._scrollHandlersInitialized = true;
}
},
performOptimizedScroll: (paramValueElement: HTMLElement, forceScroll: boolean = false): void => {
requestAnimationFrame(() => {
// Auto-scroll the parameter value container
if (paramValueElement.scrollHeight > paramValueElement.clientHeight) {
const shouldAutoScroll = forceScroll || !(paramValueElement as any)._userHasScrolled;
if (shouldAutoScroll) {
const targetScroll = paramValueElement.scrollHeight - paramValueElement.clientHeight;
const currentScroll = paramValueElement.scrollTop;
const diff = targetScroll - currentScroll;
if (diff > 10) {
paramValueElement.scrollTo({
top: targetScroll,
behavior: 'smooth',
});
} else if (diff > 0) {
paramValueElement.scrollTop = targetScroll;
}
}
}
// Auto-scroll the inner pre element if it exists and has content
const preElement = paramValueElement.querySelector('pre');
if (preElement && preElement.scrollHeight > preElement.clientHeight) {
const shouldAutoScrollPre = forceScroll || !(preElement as any)._userHasScrolled;
if (shouldAutoScrollPre) {
const targetScroll = preElement.scrollHeight - preElement.clientHeight;
const currentScroll = preElement.scrollTop;
const diff = targetScroll - currentScroll;
if (diff > 10) {
preElement.scrollTo({
top: targetScroll,
behavior: 'smooth',
});
} else if (diff > 0) {
preElement.scrollTop = targetScroll;
}
}
}
});
},
// Enhanced scroll function specifically for streaming content
performStreamingScroll: (paramValueElement: HTMLElement): void => {
// Reset user scroll tracking during active streaming
(paramValueElement as any)._userHasScrolled = false;
const preElement = paramValueElement.querySelector('pre');
if (preElement) {
(preElement as any)._userHasScrolled = false;
}
// Force scroll to bottom for streaming content
ScrollUtils.performOptimizedScroll(paramValueElement, true);
},
};
// Monaco editor CSP-compatible configuration
const configureMonacoEditorForCSP = (): void => {
if (typeof window !== 'undefined' && (window as any).monaco) {
try {
(window as any).monaco.editor.onDidCreateEditor((editor: any) => {
editor.updateOptions({
wordBasedSuggestions: false,
snippetSuggestions: false,
suggestOnTriggerCharacters: false,
semanticHighlighting: { enabled: false },
codeLens: false,
formatOnType: false,
folding: false,
});
});
(window as any).MonacoEnvironment = {
getWorkerUrl: () =>
'data:text/javascript;charset=utf-8,logger.debug("Monaco worker disabled for CSP compatibility");',
};
logger.debug('Monaco editor configured for CSP compatibility');
} catch (e) {
logger.error('Failed to configure Monaco editor for CSP:', e);
}
}
};
// Inject enhanced streaming styles for better UX
const injectStreamingStyles = (() => {
let injected = false;
return () => {
if (injected) return;
injected = true;
const style = DOMUtils.createElement<HTMLStyleElement>('style');
style.textContent = `
.streaming-param-name {
position: relative;
}
.param-value[data-streaming="true"] {
position: relative;
background: linear-gradient(135deg,
rgba(0, 212, 255, 0.03) 0%,
rgba(0, 153, 204, 0.01) 100%);
border-left: 2px solid rgba(0, 212, 255, 0.2);
padding-left: 8px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.param-value[data-streaming="true"] .content-wrapper {
animation: subtle-breathe 3s ease-in-out infinite;
}
@keyframes subtle-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.001); }
}
/* Enhanced scrolling styles */
.param-value[data-streaming="true"] {
overflow-y: auto !important;
max-height: 300px !important;
scroll-behavior: smooth !important;
}
.param-value[data-streaming="true"]::-webkit-scrollbar {
width: 6px;
}
.param-value[data-streaming="true"]::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
}
.param-value[data-streaming="true"]::-webkit-scrollbar-thumb {
background: rgba(0, 212, 255, 0.5);
border-radius: 3px;
transition: background 0.2s ease;
}
.param-value[data-streaming="true"]::-webkit-scrollbar-thumb:hover {
background: rgba(0, 212, 255, 0.8);
}
/* Fix text color inheritance for both themes */
.function-block.theme-light .param-value[data-streaming="true"] pre,
.function-block:not(.theme-dark) .param-value[data-streaming="true"] pre {
color: inherit !important;
}
.function-block.theme-dark .param-value[data-streaming="true"] pre {
color: inherit !important;
}
/* Spinner animation styles - ensure they're loaded */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(26, 115, 232, 0.3);
border-top: 2px solid #1a73e8;
border-radius: 50%;
animation: spinner-spin 1s linear infinite;
flex-shrink: 0;
will-change: transform;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
contain: layout style;
}
.function-block.theme-dark .spinner {
border: 2px solid rgba(138, 180, 248, 0.3);
border-top: 2px solid #8ab4f8;
}
@keyframes spinner-spin {
0% { transform: rotate(0deg) translate3d(0,0,0); }
100% { transform: rotate(360deg) translate3d(0,0,0); }
}
`;
document.head.appendChild(style);
};
})();
// State management for rendered elements
export const processedElements = new WeakSet<HTMLElement>();
export const renderedFunctionBlocks = new Map<string, HTMLDivElement>();
// Centralized execution tracking system to prevent race conditions and duplicate executions
interface ExecutionTracker {
attempts: Map<string, number>;
executed: Set<string>;
executedFunctions: Set<string>;
isFunctionExecuted(callId: string, contentSignature: string, functionName?: string): boolean;
markFunctionExecuted(callId: string, contentSignature: string, functionName?: string): void;
isBlockExecuted(blockId: string): boolean;
markBlockExecuted(blockId: string): void;
getAttempts(blockId: string): number;
incrementAttempts(blockId: string): number;
cleanupBlock(blockId: string): void;
}
// Implementation of the execution tracker
export const executionTracker: ExecutionTracker = {
attempts: new Map<string, number>(),
executed: new Set<string>(),
executedFunctions: new Set<string>(),
isFunctionExecuted(callId: string, contentSignature: string, functionName?: string): boolean {
logger.debug(`isFunctionExecuted called with: callId='${callId}', signature='${contentSignature}', funcName='${functionName || 'undefined'}'`,
);
let effectiveFunctionName = functionName;
if (typeof effectiveFunctionName === 'undefined' || effectiveFunctionName === null) {
let functionNameFromMemory = '';
for (const key of this.executedFunctions) {
const parts = key.split(':');
if (parts.length === 3 && parts[1] === callId && parts[2] === contentSignature) {
functionNameFromMemory = parts[0];
break;
}
}
if (functionNameFromMemory) {
effectiveFunctionName = functionNameFromMemory;
logger.debug(`Found functionName='${effectiveFunctionName}' from executedFunctions set`);
}
}
if (typeof effectiveFunctionName === 'string') {
const key = `${effectiveFunctionName}:${callId}:${contentSignature}`;
const inMemory = this.executedFunctions.has(key);
const inStorage = getPreviousExecution(effectiveFunctionName, callId, contentSignature) !== null;
logger.debug(`isFunctionExecuted (Standard Check): Key='${key}', inMemory=${inMemory}, inStorage=${inStorage}`,
);
return inMemory || inStorage;
} else {
const key = `${callId}:${contentSignature}`;
const inMemory = this.executedFunctions.has(key) || this.executedFunctions.has(`:${callId}:${contentSignature}`);
const inStorage = getPreviousExecutionLegacy(callId, contentSignature) !== null;
logger.debug(`isFunctionExecuted (Legacy Check): Key='${key}', inMemory=${inMemory}, inStorage=${inStorage}`,
);
return inMemory || inStorage;
}
},
markFunctionExecuted(callId: string, contentSignature: string, functionName?: string): void {
const key = functionName ? `${functionName}:${callId}:${contentSignature}` : `${callId}:${contentSignature}`;
this.executedFunctions.add(key);
},
isBlockExecuted(blockId: string): boolean {
return this.executed.has(blockId) === true;
},
markBlockExecuted(blockId: string): void {
this.executed.add(blockId);
},
getAttempts(blockId: string): number {
return this.attempts.get(blockId) || 0;
},
incrementAttempts(blockId: string): number {
const current = this.getAttempts(blockId);
const newValue = current + 1;
this.attempts.set(blockId, newValue);
return newValue;
},
cleanupBlock(blockId: string): void {
this.attempts.delete(blockId);
},
};
// Auto expand/collapse utilities
const AutoExpandUtils = {
expandBlock: (blockDiv: HTMLDivElement, animate: boolean = true): void => {
if (blockDiv.classList.contains('expanded')) return;
const expandButton = blockDiv.querySelector('.expand-button') as HTMLButtonElement;
const expandableContent = blockDiv.querySelector('.expandable-content') as HTMLDivElement;
if (!expandButton || !expandableContent) return;
blockDiv.classList.add('expanded', 'auto-expanded');
if (animate) {
// Smooth expansion animation
DOMUtils.applyStyles(expandableContent, {
display: 'block',
maxHeight: '0px',
opacity: '0',
paddingTop: '0',
paddingBottom: '0',
});
const targetHeight = expandableContent.scrollHeight + 24;
requestAnimationFrame(() => {
DOMUtils.applyStyles(expandableContent, {
maxHeight: targetHeight + 'px',
opacity: '1',
paddingTop: '12px',
paddingBottom: '12px',
transition: 'all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
});
const expandIcon = expandButton.querySelector('svg path');
if (expandIcon) {
expandIcon.setAttribute('d', 'M16 14l-4-4-4 4');
}
expandButton.title = 'Collapse function details';
});
} else {
// Instant expansion
DOMUtils.applyStyles(expandableContent, {
display: 'block',
maxHeight: 'none',
opacity: '1',
paddingTop: '12px',
paddingBottom: '12px',
});
}
},
collapseBlock: (blockDiv: HTMLDivElement, animate: boolean = true): void => {
if (!blockDiv.classList.contains('expanded') || !blockDiv.classList.contains('auto-expanded')) return;
const expandButton = blockDiv.querySelector('.expand-button') as HTMLButtonElement;
const expandableContent = blockDiv.querySelector('.expandable-content') as HTMLDivElement;
if (!expandButton || !expandableContent) return;
blockDiv.classList.remove('expanded', 'auto-expanded');
if (animate) {
// Smooth collapse animation
const currentHeight = expandableContent.scrollHeight;
expandableContent.style.maxHeight = currentHeight + 'px';
expandableContent.offsetHeight; // Force reflow
requestAnimationFrame(() => {
DOMUtils.applyStyles(expandableContent, {
maxHeight: '0px',
opacity: '0',
paddingTop: '0',
paddingBottom: '0',
transition: 'all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
});
const expandIcon = expandButton.querySelector('svg path');
if (expandIcon) {
expandIcon.setAttribute('d', 'M8 10l4 4 4-4');
}
expandButton.title = 'Expand function details';
});
// Hide after animation completes
setTimeout(() => {
if (!blockDiv.classList.contains('expanded')) {
expandableContent.style.display = 'none';
}
}, 400);
} else {
// Instant collapse
DOMUtils.applyStyles(expandableContent, {
display: 'none',
maxHeight: '0px',
opacity: '0',
paddingTop: '0',
paddingBottom: '0',
});
}
},
scheduleAutoCollapse: (blockDiv: HTMLDivElement, delay: number = 2000): void => {
const blockId = blockDiv.getAttribute('data-block-id');
if (!blockId) return;
const timeoutKey = `auto-collapse-${blockId}`;
PerformanceUtils.cleanupTimeout(timeoutKey);
PerformanceUtils.setManagedTimeout(
timeoutKey,
() => {
// Only collapse if no streaming is active and block is auto-expanded
const stillStreaming = blockDiv.querySelector('[data-streaming="true"]');
if (!stillStreaming && blockDiv.classList.contains('auto-expanded')) {
AutoExpandUtils.collapseBlock(blockDiv, true);
}
},
delay
);
},
};
// Function block element creation utilities
const BlockElementUtils = {
createFunctionNameSection: (
functionName: string,
callId: string,
isComplete: boolean,
isPreExistingIncomplete: boolean,
): HTMLDivElement => {
const functionNameElement = DOMUtils.createElement<HTMLDivElement>('div', 'function-name');
const leftSection = DOMUtils.createElement<HTMLDivElement>('div', 'function-name-left');
// Create a container for function name and spinner (inline)
const functionNameRow = DOMUtils.createElement<HTMLDivElement>('div', 'function-name-row');
DOMUtils.applyStyles(functionNameRow, {
display: 'flex',
alignItems: 'center',
gap: '8px',
});
const functionNameText = DOMUtils.createElement<HTMLSpanElement>('span', 'function-name-text');
functionNameText.textContent = functionName;
functionNameRow.appendChild(functionNameText);
if (!isComplete && !isPreExistingIncomplete) {
const spinner = DOMUtils.createElement<HTMLDivElement>('div', 'spinner');
functionNameRow.appendChild(spinner);
}
leftSection.appendChild(functionNameRow);
const rightSection = DOMUtils.createElement<HTMLDivElement>('div', 'function-name-right');
if (callId) {
const callIdElement = DOMUtils.createElement<HTMLSpanElement>('span', 'call-id');
callIdElement.textContent = callId;
rightSection.appendChild(callIdElement);
}
functionNameElement.appendChild(leftSection);
functionNameElement.appendChild(rightSection);
return functionNameElement;
},
createExpandButton: (): HTMLButtonElement => {
const expandButton = DOMUtils.createElement<HTMLButtonElement>('button', 'expand-button');
expandButton.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 10l4 4 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
expandButton.title = 'Expand function details';
return expandButton;
},
createExpandableContent: (): HTMLDivElement => {
const expandableContent = DOMUtils.createElement<HTMLDivElement>('div', 'expandable-content');
DOMUtils.applyStyles(expandableContent, {
display: 'none',
overflow: 'hidden',
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
maxHeight: '0px',
opacity: '0',
});
return expandableContent;
},
setupExpandCollapse: (
blockDiv: HTMLDivElement,
expandButton: HTMLButtonElement,
expandableContent: HTMLDivElement,
): void => {
const toggleExpandCollapse = (e: Event) => {
e.preventDefault();
e.stopPropagation();
const isCurrentlyExpanded = blockDiv.classList.contains('expanded');
const expandIcon = expandButton.querySelector('svg path');
if (isCurrentlyExpanded) {
// Collapse
blockDiv.classList.remove('expanded');
// Get current computed height including padding
const currentHeight = expandableContent.scrollHeight;
expandableContent.style.maxHeight = currentHeight + 'px';
expandableContent.offsetHeight; // Force reflow
requestAnimationFrame(() => {
DOMUtils.applyStyles(expandableContent, {
maxHeight: '0px',
opacity: '0',
paddingTop: '0',
paddingBottom: '0',
});
if (expandIcon) {
expandIcon.setAttribute('d', 'M8 10l4 4 4-4');
}
expandButton.title = 'Expand function details';
});
// Hide after animation completes
setTimeout(() => {
if (!blockDiv.classList.contains('expanded')) {
expandableContent.style.display = 'none';
}
}, 250);
} else {
// Expand
blockDiv.classList.add('expanded');
// Prepare for expansion
DOMUtils.applyStyles(expandableContent, {
display: 'block',
maxHeight: '0px',
opacity: '0',
paddingTop: '0',
paddingBottom: '0',
});
// Calculate target height with padding
const targetHeight = expandableContent.scrollHeight + 24; // 12px top + 12px bottom padding
requestAnimationFrame(() => {
DOMUtils.applyStyles(expandableContent, {
maxHeight: targetHeight + 'px',
opacity: '1',
paddingTop: '12px',
paddingBottom: '12px',
});
if (expandIcon) {
expandIcon.setAttribute('d', 'M16 14l-4-4-4 4');
}
expandButton.title = 'Collapse function details';
});
}
};
// Attach click handler to expand button
expandButton.onclick = toggleExpandCollapse;
// Make entire function-name area clickable
const functionNameElement = blockDiv.querySelector('.function-name') as HTMLDivElement;
if (functionNameElement) {
DOMUtils.applyStyles(functionNameElement, {
cursor: 'pointer',
});
functionNameElement.onclick = toggleExpandCollapse;
}
},
};
// Parameter element utilities
const ParamElementUtils = {
createParamName: (name: string, paramId: string): HTMLDivElement => {
const paramNameElement = DOMUtils.createElement<HTMLDivElement>('div', 'param-name', { 'data-param-id': paramId });
paramNameElement.textContent = name;
return paramNameElement;
},
createParamValue: (paramId: string, name: string): HTMLDivElement => {
const paramValueElement = DOMUtils.createElement<HTMLDivElement>('div', 'param-value', {
'data-param-id': paramId,
'data-param-name': name,
});
DOMUtils.applyStyles(paramValueElement, STREAMING_STYLES.paramValue);
return paramValueElement;
},
createStreamingContent: (
paramValueElement: HTMLDivElement,
): { preElement: HTMLPreElement; contentWrapper: HTMLDivElement } => {
paramValueElement.innerHTML = '';
const contentWrapper = DOMUtils.createElement<HTMLDivElement>('div', 'content-wrapper');
DOMUtils.applyStyles(contentWrapper, STREAMING_STYLES.contentWrapper);
const preElement = DOMUtils.createElement<HTMLPreElement>('pre');
DOMUtils.applyStyles(preElement, STREAMING_STYLES.pre);
contentWrapper.appendChild(preElement);
paramValueElement.appendChild(contentWrapper);
return { preElement, contentWrapper };
},
updateContent: (preElement: HTMLPreElement, displayValue: string, isStreaming: boolean): void => {
const currentText = preElement.textContent || '';
if (currentText !== displayValue) {
if (isStreaming && displayValue.length > currentText.length + 50) {
preElement.style.opacity = '0.85';
setTimeout(() => {
preElement.textContent = displayValue;
preElement.style.opacity = '1';
}, 8);
} else {
preElement.textContent = displayValue;
}
}
},
handleStreamingState: (
paramNameElement: HTMLDivElement,
paramValueElement: HTMLDivElement,
paramId: string,
isStreaming: boolean,
): void => {
const timeoutKey = `streaming-timeout-${paramId}`;
PerformanceUtils.cleanupTimeout(timeoutKey);
if (isStreaming) {
if (!paramNameElement.classList.contains('streaming-param-name')) {
paramNameElement.classList.add('streaming-param-name');
}
paramValueElement.setAttribute('data-streaming', 'true');
if (!paramValueElement.hasAttribute('data-streaming-styled')) {
DOMUtils.applyStyles(paramValueElement, {
willChange: 'scroll-position, contents',
containIntrinsicSize: 'auto 1.2em',
});
ParamElementUtils.checkAndApplyOverflow(paramValueElement);
paramValueElement.setAttribute('data-streaming-styled', 'true');
ScrollUtils.setupScrollTracking(paramValueElement);
}
setupAutoScroll(paramValueElement as ParamValueElement);
ScrollUtils.performStreamingScroll(paramValueElement);
PerformanceUtils.setManagedTimeout(
timeoutKey,
() => {
if (paramNameElement && document.body.contains(paramNameElement)) {
paramNameElement.classList.remove('streaming-param-name');
if (paramValueElement) {
paramValueElement.removeAttribute('data-streaming');
paramValueElement.removeAttribute('data-streaming-styled');
DOMUtils.applyStyles(paramValueElement, {
willChange: 'auto',
containIntrinsicSize: 'auto',
});
}
}
},
STREAMING_TIMEOUT,
);
} else {
if (paramNameElement.classList.contains('streaming-param-name')) {
setTimeout(() => {
paramNameElement.classList.remove('streaming-param-name');
paramValueElement.removeAttribute('data-streaming');
paramValueElement.removeAttribute('data-streaming-styled');
DOMUtils.applyStyles(paramValueElement, {
willChange: 'auto',
containIntrinsicSize: 'auto',
});
// Check if block should auto-collapse when streaming ends
const blockDiv = paramValueElement.closest('.function-block') as HTMLDivElement;
if (blockDiv && blockDiv.classList.contains('auto-expanded')) {
// Check if any other parameters are still streaming
const stillStreaming = blockDiv.querySelector('[data-streaming="true"]');
if (!stillStreaming) {
AutoExpandUtils.scheduleAutoCollapse(blockDiv, 1200);
}
}
}, 100);
}
setTimeout(() => ParamElementUtils.checkAndApplyOverflow(paramValueElement), 200);
}
},
checkAndApplyOverflow: (paramValueElement: HTMLDivElement): void => {
const needsScroll = paramValueElement.scrollHeight > 300;
const hasScroll = paramValueElement.style.overflow === 'auto';
if (needsScroll && !hasScroll) {
DOMUtils.applyStyles(paramValueElement, {
overflow: 'auto',
maxHeight: '300px',
scrollBehavior: 'smooth',
scrollbarWidth: 'thin',
});
} else if (!needsScroll && hasScroll) {
DOMUtils.applyStyles(paramValueElement, {
overflow: 'visible',
maxHeight: 'none',
});
}
},
};
// Auto-execution utilities
const AutoExecutionUtils = {
setupOptimizedAutoExecution: (blockId: string, functionDetails: any): void => {
const setupAutoExecution = () => {
const attempts = executionTracker.incrementAttempts(blockId);
if (attempts > MAX_AUTO_EXECUTE_ATTEMPTS) {
logger.debug(`Auto-execute: Giving up on block ${blockId} after ${attempts - 1} attempts`);
executionTracker.cleanupBlock(blockId);
return;
}
logger.debug(`Auto-execute attempt ${attempts}/${MAX_AUTO_EXECUTE_ATTEMPTS} for block ${blockId}`);
// Get auto execute delay from window state
const automationState = (window as any).__mcpAutomationState;
const autoExecuteDelay = (automationState?.autoExecuteDelay || 0) * 1000; // Convert to milliseconds
logger.debug(`Using delay of ${autoExecuteDelay}ms for block ${blockId}`);
PerformanceUtils.setManagedTimeout(
`auto-exec-${blockId}-${attempts}`,
() => {
let currentBlock = document.querySelector<HTMLDivElement>(`.function-block[data-block-id="${blockId}"]`);
if (!currentBlock) {
logger.debug(`Auto-execute: Original block ${blockId} not found. Searching for replacement...`);
currentBlock = AutoExecutionUtils.findReplacementBlock(functionDetails);
}
if (!currentBlock) {
logger.debug(
`Auto-execute: Block ${blockId} not found (attempt ${attempts}/${MAX_AUTO_EXECUTE_ATTEMPTS})`,
);
if (attempts < MAX_AUTO_EXECUTE_ATTEMPTS) {
setupAutoExecution();
} else {
logger.debug(`Auto-execute: Giving up on block ${blockId} - not found in DOM`);
executionTracker.cleanupBlock(blockId);
}
return;
}
const finalCheckExecuted = getPreviousExecution(
functionDetails.functionName,
functionDetails.callId,
functionDetails.contentSignature,
);
if (finalCheckExecuted) {
logger.debug(`Auto-execute: Function already executed, skipping.`);
executionTracker.cleanupBlock(blockId);
return;
}
const executeButton = currentBlock.querySelector<HTMLButtonElement>('.execute-button');
if (executeButton) {
logger.debug(`Auto-execute: Executing function ${functionDetails.functionName}`);
executeButton.click();
executionTracker.cleanupBlock(blockId);
} else {
logger.debug(`Auto-execute: Execute button not found (attempt ${attempts}/${MAX_AUTO_EXECUTE_ATTEMPTS})`);
if (attempts < MAX_AUTO_EXECUTE_ATTEMPTS) {
setupAutoExecution();
} else {
logger.debug(`Auto-execute: Giving up on block ${blockId} - button not found`);
executionTracker.cleanupBlock(blockId);
}
}
},
autoExecuteDelay + 500, // Add base delay to the configured delay
);
};
setupAutoExecution();
},
findReplacementBlock: (functionDetails: any): HTMLDivElement | null => {
const potentialBlocks = document.querySelectorAll<HTMLDivElement>('.function-block');
for (const block of potentialBlocks) {
const preElement = block.querySelector('pre');
if (!preElement?.textContent) continue;
const match = REGEX_CACHE.invokeMatch.exec(preElement.textContent);
REGEX_CACHE.invokeMatch.lastIndex = 0;
if (match && match[1] === functionDetails.functionName && match[2] === functionDetails.callId) {
const alreadyExecuted = getPreviousExecution(
functionDetails.functionName,
functionDetails.callId,
functionDetails.contentSignature,
);
if (!alreadyExecuted) {
logger.debug(`Auto-execute: Found replacement block, attempting execution.`);
return block;
}
}
}
return null;
},
};
// Configure Monaco once before rendering any blocks
if (typeof window !== 'undefined') {
configureMonacoEditorForCSP();
}
/**
* Main function to render a function call block
*/
export const renderFunctionCall = (block: HTMLPreElement, isProcessingRef: { current: boolean }): boolean => {
injectStreamingStyles();
const functionInfo = containsFunctionCalls(block);
if (CONFIG.debug) {
logger.debug('[Render] containsFunctionCalls result:', {
hasFunctionCalls: functionInfo.hasFunctionCalls,
detectedBlockType: functionInfo.detectedBlockType,
isComplete: functionInfo.isComplete,
hasParameters: functionInfo.hasParameters,
invokeName: functionInfo.invokeName
});
}
// Early exit checks
if (!functionInfo.hasFunctionCalls || block.closest('.function-block')) {
if (CONFIG.debug && !functionInfo.hasFunctionCalls) {
const textContent = block.textContent?.trim() || '';
if (textContent.length === 0) {
logger.debug('[Render] Skipping empty block - waiting for content');
} else if (textContent.length < 10) {
logger.debug('[Render] Skipping very short content - waiting for more data');
} else {
logger.debug('[Render] Early exit - no function calls detected');
}
}
return false;
}
const textContent = block.textContent?.trim() || '';
if (textContent.length < 10) {
if (CONFIG.debug) {
logger.debug('[Render] Skipping block with insufficient content (length:', textContent.length, ')');
}
return false;
}
const blockId =
block.getAttribute('data-block-id') || `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// Skip if resyncing or already complete and stable
if ((window as any).resyncingBlocks?.has(blockId)) {
if (CONFIG.debug) logger.debug(`Skipping render for resyncing block ${blockId}`);
return false;
}
const existingFunctionBlock = document.querySelector(`.function-block[data-block-id="${blockId}"]`);
if (existingFunctionBlock && existingFunctionBlock.classList.contains('function-complete')) {
if (CONFIG.debug) logger.debug(`Skipping render for completed block ${blockId}`);
return false;
}
const preExistingIncompleteBlocks = (window as any).preExistingIncompleteBlocks || new Set<string>();
const isPreExistingIncomplete = preExistingIncompleteBlocks.has(blockId);
let existingDiv = renderedFunctionBlocks.get(blockId);
let isNewRender = false;
let previousCompletionStatus: boolean | null = null;
// Handle existing div lookup and caching
if (processedElements.has(block)) {
if (!existingDiv) {
existingDiv = document.querySelector<HTMLDivElement>(`.function-block[data-block-id="${blockId}"]`) || undefined;
if (existingDiv) {
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);
}
} else {
// Check if block was previously complete (has function-complete class)
// Once complete, it should stay complete to prevent flickering
previousCompletionStatus = existingDiv.classList.contains('function-complete');
}
const rawContent = block.textContent?.trim() || '';
const { tag, content } = extractLanguageTag(rawContent);
// Determine if JSON or XML format
const isJSONFormat = functionInfo.detectedBlockType === 'json';
let functionName: string;
let callId: string;
let partialParameters: Record<string, string>;
let description: string | null = null;
if (isJSONFormat) {
const jsonInfo = extractJSONFunctionInfo(rawContent);
functionName = jsonInfo.functionName || 'function';
callId = jsonInfo.callId || `block-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
description = jsonInfo.description;
partialParameters = extractJSONParameters(rawContent);
} else {
const parsed = CacheUtils.parseContentEfficiently(block, rawContent);
functionName = parsed.functionName;
callId = parsed.callId;
partialParameters = parsed.parameters;
}
const blockDiv = existingDiv || DOMUtils.createElement<HTMLDivElement>('div');
// Setup new render
if (isNewRender) {
blockDiv.className = 'function-block';
blockDiv.setAttribute('data-block-id', blockId);
applyThemeClass(blockDiv);
renderedFunctionBlocks.set(blockId, blockDiv);
// Ensure blocks start collapsed by default
blockDiv.classList.remove('expanded', 'auto-expanded');
}
// Handle state transitions
if (!isNewRender) {
const justCompleted = previousCompletionStatus === false && functionInfo.isComplete;
// Prevent flickering: once a block is complete, don't allow it to become incomplete
// This prevents re-rendering loops caused by temporary parsing issues
const justBecameIncomplete = previousCompletionStatus === true && !functionInfo.isComplete;
if (justCompleted) {
blockDiv.classList.remove('function-loading');
blockDiv.classList.add('function-complete');
const spinner = blockDiv.querySelector('.spinner');
if (spinner) spinner.remove();
} else if (justBecameIncomplete && !blockDiv.classList.contains('function-complete')) {
// Only transition to incomplete if the block wasn't already marked as complete
// This prevents flickering from temporary state changes
blockDiv.classList.remove('function-complete');
blockDiv.classList.add('function-loading');
}
} else {
if (!functionInfo.isComplete && !isPreExistingIncomplete) {
blockDiv.classList.add('function-loading');
}
if (tag || functionInfo.languageTag) {
const langTag = DOMUtils.createElement<HTMLDivElement>('div', 'language-tag');
langTag.textContent = tag || functionInfo.languageTag;
blockDiv.appendChild(langTag);
}
}
const cachedElements = CacheUtils.getCachedElements(blockDiv);
// Handle function name creation or update
let functionNameElement = cachedElements.functionNameElement;
if (!functionNameElement) {
functionNameElement = BlockElementUtils.createFunctionNameSection(
functionName,
callId,
functionInfo.isComplete,
isPreExistingIncomplete,
);
blockDiv.appendChild(functionNameElement);
cachedElements.functionNameElement = functionNameElement;
elementQueryCache.set(blockDiv, { ...cachedElements, lastCacheTime: Date.now() });
} else {
const nameText = functionNameElement.querySelector<HTMLSpanElement>('.function-name-text');
if (nameText) DOMUtils.updateTextIfChanged(nameText, functionName);
const callIdElement = functionNameElement.querySelector<HTMLSpanElement>('.call-id');
if (callId) {
if (callIdElement) {
DOMUtils.updateTextIfChanged(callIdElement, callId);
} else {
const newCallId = DOMUtils.createElement<HTMLSpanElement>('span', 'call-id');
newCallId.textContent = callId;
functionNameElement.appendChild(newCallId);
}
}
}
// Setup expand/collapse functionality
let expandButton = functionNameElement?.querySelector('.expand-button') as HTMLButtonElement | null;
let expandableContent = blockDiv.querySelector('.expandable-content') as HTMLDivElement | null;
if (!expandButton && functionNameElement) {
expandButton = BlockElementUtils.createExpandButton();
const rightSection = functionNameElement.querySelector('.function-name-right');
if (rightSection) {
rightSection.appendChild(expandButton);
} else {
functionNameElement.appendChild(expandButton);
}
}
if (!expandableContent) {
expandableContent = BlockElementUtils.createExpandableContent();
blockDiv.appendChild(expandableContent);
// Ensure content starts hidden for new blocks
if (isNewRender) {
DOMUtils.applyStyles(expandableContent, {
display: 'none',
maxHeight: '0px',
opacity: '0',
paddingTop: '0',
paddingBottom: '0',
});
}
}
if (expandButton && expandableContent) {
BlockElementUtils.setupExpandCollapse(blockDiv, expandButton, expandableContent);
}
// Add description section if present (JSON format)
// Insert it inside the function-name-left section, below the function name
if (description && functionNameElement) {
const leftSection = functionNameElement.querySelector('.function-name-left') as HTMLDivElement;
if (leftSection && !leftSection.querySelector('.function-description')) {
// Change flex direction to column to stack elements vertically
DOMUtils.applyStyles(leftSection, {
flexDirection: 'column',
alignItems: 'flex-start',
});
const descriptionElement = DOMUtils.createElement<HTMLDivElement>('div', 'function-description');
descriptionElement.textContent = description;
DOMUtils.applyStyles(descriptionElement, {
fontSize: '12px',
color: 'inherit',
marginTop: '4px',
fontStyle: 'bold',
lineHeight: '1.4',
display: 'block',
opacity: '0.8',
});
// Append description to the left section (below function name)
leftSection.appendChild(descriptionElement);
}
}
// Create parameter container
let paramsContainer = cachedElements.paramsContainer;
if (!paramsContainer) {
paramsContainer = DOMUtils.createElement<HTMLDivElement>('div', 'function-params');
DOMUtils.applyStyles(paramsContainer, STREAMING_STYLES.paramsContainer);
expandableContent!.appendChild(paramsContainer);
cachedElements.paramsContainer = paramsContainer;
elementQueryCache.set(blockDiv, { ...cachedElements, lastCacheTime: Date.now() });
}
// Process parameters
Object.entries(partialParameters).forEach(([paramName, extractedValue]) => {
let isParamStreaming: boolean;
if (isJSONFormat) {
// For JSON: parameter is streaming if function_call_end hasn't arrived
isParamStreaming = !functionInfo.isComplete;
} else {
// For XML: check if parameter closing tag exists
isParamStreaming =
!rawContent.includes(`</parameter>`) ||
rawContent.indexOf('</parameter>', rawContent.indexOf(`<parameter name="${paramName}"`)) === -1;
}
const paramId = `${blockId}-${paramName}`;
PerformanceUtils.batchStreamingUpdate(paramId, () => {
createOrUpdateParamElement(paramsContainer!, paramName, extractedValue, blockId, isNewRender, isParamStreaming);
});
});
// Handle completion and auto-execution
let completeParameters: Record<string, any> | null = null;
if (functionInfo.isComplete) {
if (isJSONFormat) {
completeParameters = extractJSONParameters(rawContent);
} else {
completeParameters = extractFunctionParameters(rawContent);
}
// Auto-collapse if the block was auto-expanded and is now complete
if (blockDiv.classList.contains('auto-expanded')) {
AutoExpandUtils.scheduleAutoCollapse(blockDiv, 1500);
}
}
let contentSignature: string | null = null;
if (functionInfo.isComplete && completeParameters) {
contentSignature = generateContentSignature(functionName, completeParameters);
}
// Replace original element on new render
if (isNewRender) {
if (block.parentNode) {
block.parentNode.insertBefore(blockDiv, block);
block.style.display = 'none';
} else {
if (CONFIG.debug) logger.warn('Function call block has no parent element, cannot insert rendered block');
return false;
}
}
// Create button container
let buttonContainer = cachedElements.buttonContainer;
if (!buttonContainer) {
buttonContainer = DOMUtils.createElement<HTMLDivElement>('div', 'function-buttons');
buttonContainer.style.marginTop = '12px';
blockDiv.appendChild(buttonContainer);
cachedElements.buttonContainer = buttonContainer;
elementQueryCache.set(blockDiv, { ...cachedElements, lastCacheTime: Date.now() });
}
// Add buttons for complete functions
if (functionInfo.isComplete) {
if (!blockDiv.querySelector('.raw-toggle')) {
addRawXmlToggle(buttonContainer!, rawContent);
}
if (!blockDiv.querySelector('.execute-button')) {
if (!completeParameters) {
if (isJSONFormat) {
completeParameters = extractJSONParameters(rawContent);
} else {
completeParameters = extractFunctionParameters(rawContent);
}
}
addExecuteButton(buttonContainer!, rawContent);
// Setup auto-execution
const automationState = getAutomationState();
const autoExecuteEnabled = automationState.autoExecute;
if (contentSignature && !executionTracker.isFunctionExecuted(callId, contentSignature, functionName)) {
if (autoExecuteEnabled !== true) {
logger.debug(`Auto-execution disabled by user settings for block ${blockId} (${functionName})`);
return true;
}
if (executionTracker.isBlockExecuted(blockId) === true) {
logger.debug(`Auto-execution skipped: Block ${blockId} (${functionName}) has already been processed`);
return true;
}
executionTracker.markFunctionExecuted(callId, contentSignature, functionName);
executionTracker.markBlockExecuted(blockId);
logger.debug(`Setting up auto-execution for block ${blockId} (${functionName})`);
const functionDetails = {
functionName,
callId,
contentSignature,
params: completeParameters || {},
};
AutoExecutionUtils.setupOptimizedAutoExecution(blockId, functionDetails);
}
}
}
return true;
};
/**
* Create or update a parameter element in the function block
*/
export const createOrUpdateParamElement = (
container: HTMLDivElement,
name: string,
value: any,
blockId: string,
isNewRender: boolean,
isStreaming: boolean = false,
): void => {
const paramId = `${blockId}-${name}`;
const paramElementCache = elementQueryCache.get(container) || { lastCacheTime: Date.now() };
const paramCache = paramElementCache as any;
let paramNameElement = paramCache[`name-${paramId}`] as HTMLDivElement | undefined;
let paramValueElement = paramCache[`value-${paramId}`] as HTMLDivElement | undefined;
// Query DOM if not in cache
if (!paramNameElement || !paramValueElement) {
paramNameElement =
paramNameElement ||
container.querySelector<HTMLDivElement>(`.param-name[data-param-id="${paramId}"]`) ||
document.querySelector<HTMLDivElement>(`.param-name[data-param-id="${paramId}"]`) ||
undefined;
paramValueElement =
paramValueElement ||
container.querySelector<HTMLDivElement>(`.param-value[data-param-id="${paramId}"]`) ||
document.querySelector<HTMLDivElement>(`.param-value[data-param-id="${paramId}"]`) ||
undefined;
if (paramNameElement) paramCache[`name-${paramId}`] = paramNameElement;
if (paramValueElement) paramCache[`value-${paramId}`] = paramValueElement;
elementQueryCache.set(container, paramCache);
}
// Create elements if they don't exist
if (!paramNameElement) {
paramNameElement = ParamElementUtils.createParamName(name, paramId);
container.appendChild(paramNameElement);
paramCache[`name-${paramId}`] = paramNameElement;
elementQueryCache.set(container, paramCache);
}
if (!paramValueElement) {
paramValueElement = ParamElementUtils.createParamValue(paramId, name);
container.appendChild(paramValueElement);
paramCache[`value-${paramId}`] = paramValueElement;
elementQueryCache.set(container, paramCache);
}
// Update content if changed
const displayValue = typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
const currentValue = paramValueElement.getAttribute('data-current-value');
if (currentValue === displayValue && !isStreaming) {
return;
}
paramValueElement.setAttribute('data-current-value', displayValue);
// Handle streaming vs static content
if (isStreaming || paramValueElement.hasAttribute('data-streaming')) {
let preElement = paramValueElement.querySelector('pre') as HTMLPreElement;
let contentWrapper = paramValueElement.querySelector('.content-wrapper') as HTMLDivElement;
if (!preElement || !contentWrapper) {
const elements = ParamElementUtils.createStreamingContent(paramValueElement);
preElement = elements.preElement;
contentWrapper = elements.contentWrapper;
}
const updateContent = () => {
ParamElementUtils.updateContent(preElement, displayValue, isStreaming);
};
if (isStreaming) {
requestAnimationFrame(() => {
updateContent();
// Enhanced scroll for streaming content
ScrollUtils.performStreamingScroll(paramValueElement);
});
} else {
updateContent();
}
} else {
if (paramValueElement.textContent !== displayValue) {
if (paramValueElement.textContent && paramValueElement.textContent.length > 0) {
paramValueElement.style.opacity = '0.9';
setTimeout(() => {
paramValueElement.textContent = displayValue;
paramValueElement.style.opacity = '1';
}, 50);
} else {
paramValueElement.textContent = displayValue;
}
}
}
paramValueElement.setAttribute('data-param-value', JSON.stringify(value));
ParamElementUtils.handleStreamingState(paramNameElement, paramValueElement, paramId, isStreaming);
// Handle auto-expansion for streaming content
if (isStreaming) {
const blockDiv = container.closest('.function-block') as HTMLDivElement;
if (blockDiv && !blockDiv.classList.contains('expanded')) {
AutoExpandUtils.expandBlock(blockDiv, true);
}
}
};
// Performance: Cleanup functions for memory management
export const performanceCleanup = {
clearAllCaches: (): void => {
renderedFunctionBlocks.clear();
pendingDOMUpdates.clear();
activeTimeouts.forEach(timeoutId => clearTimeout(timeoutId));
activeTimeouts.clear();
},
clearBlockCache: (blockId: string): void => {
renderedFunctionBlocks.delete(blockId);
pendingDOMUpdates.delete(blockId);
const timeoutKeysToClean = Array.from(activeTimeouts.keys()).filter(key => key.includes(blockId));
timeoutKeysToClean.forEach(key => {
const timeoutId = activeTimeouts.get(key);
if (timeoutId) {
clearTimeout(timeoutId);
activeTimeouts.delete(key);
}
});
},
getCacheStats: () => ({
contentParsingCacheSize: 'WeakMap (size not available - auto-managed)',
elementQueryCacheSize: 'WeakMap (size not available - auto-managed)',
renderedFunctionBlocksSize: renderedFunctionBlocks.size,
pendingDOMUpdatesSize: pendingDOMUpdates.size,
activeTimeoutsSize: activeTimeouts.size,
}),
};
// Performance: Export utilities for external monitoring
export const performanceUtils = {
generateContentHash: CacheUtils.generateContentHash,
parseContentEfficiently: CacheUtils.parseContentEfficiently,
batchDOMOperation: PerformanceUtils.batchDOMOperation,
getCachedElements: CacheUtils.getCachedElements,
cleanupTimeout: PerformanceUtils.cleanupTimeout,
setManagedTimeout: PerformanceUtils.setManagedTimeout,
REGEX_CACHE,
};
// Cleanup on page unload to prevent memory leaks
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
performanceCleanup.clearAllCaches();
});
}
/**
* Function history component for displaying previously executed functions
* This module provides functionality to display and re-execute previously run functions
* Using URL-based storage to prevent race conditions and isolate function executions by URL
*/
import type { ExecutedFunction } from '../mcpexecute/storage';
import {
formatExecutionTime,
getExecutedFunctionsForCurrentUrl,
storeExecutedFunction,
getPreviousExecution,
} from '../mcpexecute/storage';
import { displayResult } from './components';
import { createLogger } from '@extension/shared/lib/logger';
// Add type declaration for global mcpClient access
const logger = createLogger('FunctionHistory');
declare global {
interface Window {
mcpClient?: any;
}
}
/**
* Create a history panel for previously executed functions
*
* @param blockDiv Function block div container
* @param callId Unique ID for the function call
* @param contentSignature Content signature for the function call
* @returns The created history panel element
*/
export const createHistoryPanel = (
blockDiv: HTMLDivElement,
callId: string,
contentSignature: string,
): HTMLDivElement => {
// First, remove any existing history panels to ensure we only have one
const existingPanels = blockDiv.querySelectorAll('.function-history-panel');
existingPanels.forEach(panel => panel.remove());
// Also check if we're in a function-buttons container and need to clean up the parent block
if (blockDiv.classList.contains('function-buttons')) {
const parentBlock = blockDiv.closest('.function-block');
if (parentBlock) {
const parentPanels = parentBlock.querySelectorAll('.function-history-panel');
parentPanels.forEach(panel => panel.remove());
}
}
// Create history panel
const historyPanel = document.createElement('div');
historyPanel.className = 'function-history-panel';
historyPanel.style.display = 'none';
// Add to block div
if (blockDiv.classList.contains('function-buttons')) {
// If we're in a button container, add historyPanel to the parent
const parentBlock = blockDiv.closest('.function-block');
if (parentBlock) {
parentBlock.appendChild(historyPanel);
} else {
blockDiv.appendChild(historyPanel);
}
} else {
blockDiv.appendChild(historyPanel);
}
return historyPanel;
};
/**
* Update the history panel with execution data
*
* @param historyPanel History panel element
* @param executionData Execution data to display
* @param mcpClient MCP client for re-executing functions (new architecture)
*/
export const updateHistoryPanel = (
historyPanel: HTMLDivElement,
executionData: ExecutedFunction,
mcpClient: any,
): void => {
// Clear existing content
historyPanel.innerHTML = '';
// Create header
const header = document.createElement('div');
header.className = 'function-history-header';
header.textContent = 'Execution History';
historyPanel.appendChild(header);
// Create execution info
const executionInfo = document.createElement('div');
executionInfo.className = 'function-execution-info';
// Format the execution time
const executionTime = formatExecutionTime(executionData.executedAt);
executionInfo.innerHTML = `
<div>Function: <strong>${executionData.functionName}</strong></div>
<div>Last executed: <strong>${executionTime}</strong></div>
`;
historyPanel.appendChild(executionInfo);
// Create re-execute button
const reExecuteBtn = document.createElement('button');
reExecuteBtn.className = 'function-reexecute-button';
reExecuteBtn.textContent = 'Re-execute';
// Handle re-execution with async mcpClient
reExecuteBtn.onclick = async () => {
// Create results panel if it doesn't exist
let resultsPanel = historyPanel.parentElement?.querySelector(
`.function-results-panel[data-call-id="${executionData.callId}"]`,
) as HTMLDivElement;
// overflow
if (resultsPanel) {
resultsPanel.style.overflow = 'auto';
resultsPanel.style.maxHeight = '200px';
}
if (!resultsPanel) {
resultsPanel = document.createElement('div');
resultsPanel.className = 'function-results-panel';
resultsPanel.setAttribute('data-call-id', executionData.callId);
resultsPanel.setAttribute('data-function-name', executionData.functionName);
resultsPanel.style.display = 'block';
historyPanel.parentElement?.appendChild(resultsPanel);
} else {
resultsPanel.style.display = 'block';
resultsPanel.innerHTML = '';
}
// Create loading indicator
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'function-results-loading';
loadingIndicator.textContent = 'Executing...';
resultsPanel.appendChild(loadingIndicator);
try {
if (!mcpClient) {
displayResult(resultsPanel, loadingIndicator, false, 'Error: mcpClient not found');
return;
}
// Check if mcpClient is ready
if (!mcpClient.isReady || !mcpClient.isReady()) {
displayResult(resultsPanel, loadingIndicator, false, 'Error: MCP client not ready');
return;
}
logger.debug(`Re-executing function ${executionData.functionName} with arguments:`, executionData.params);
try {
// Use async/await with the new mcpClient API
const result = await mcpClient.callTool(executionData.functionName, executionData.params);
displayResult(resultsPanel, loadingIndicator, true, result);
// Update the execution record with new timestamp
// Always use the current URL context when storing execution data
const updatedExecutionData = storeExecutedFunction(
executionData.functionName,
executionData.callId,
executionData.params,
executionData.contentSignature,
);
// Update the history panel with the new timestamp
updateHistoryPanel(historyPanel, updatedExecutionData, mcpClient);
} catch (toolError: any) {
// Enhanced error handling for different error types
let errorMessage = toolError instanceof Error ? toolError.message : String(toolError);
// Check for connection-related errors and provide better user feedback
if (errorMessage.includes('not connected') || errorMessage.includes('connection')) {
errorMessage = 'Connection lost. Please check your MCP server connection.';
} else if (errorMessage.includes('timeout')) {
errorMessage = 'Request timed out. Please try again.';
} else if (errorMessage.includes('server unavailable') || errorMessage.includes('SERVER_UNAVAILABLE')) {
errorMessage = 'MCP server is unavailable. Please check the server status.';
}
displayResult(resultsPanel, loadingIndicator, false, errorMessage);
}
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error('Re-execute error:', error);
displayResult(
resultsPanel,
loadingIndicator,
false,
`Unexpected error: ${errorMessage}`,
);
}
};
historyPanel.appendChild(reExecuteBtn);
// Show the panel
historyPanel.style.display = 'block';
};
/**
* Check for previously executed functions and update the UI accordingly
*
* @param blockDiv Function block div container
* @param functionName Name of the function
* @param callId Unique ID for the function call
* @param contentSignature Content signature for the function
*/
export const checkAndDisplayFunctionHistory = (
blockDiv: HTMLDivElement,
functionName: string,
callId: string,
contentSignature: string,
): void => {
// Get executed functions for the current URL
const executedFunctions = getExecutedFunctionsForCurrentUrl();
// Find matching executions - direct lookup from localStorage to prevent race conditions
const exactMatch = getPreviousExecution(functionName, callId, contentSignature);
const matchingExecutions = exactMatch ? [exactMatch] : [];
// Fallback to filter method if exact match not found
if (!exactMatch) {
const filteredMatches = executedFunctions.filter(
func => func.callId === callId && func.contentSignature === contentSignature,
);
filteredMatches.forEach(match => matchingExecutions.push(match));
}
if (matchingExecutions.length > 0) {
// Sort by execution time (newest first) and take only the latest
const latestExecution = matchingExecutions.sort((a, b) => b.executedAt - a.executedAt)[0];
// Create history panel (this will remove any existing panels)
const historyPanel = createHistoryPanel(blockDiv, callId, contentSignature);
// Access the global mcpClient instead of mcpHandler
const mcpClient = (window as any).mcpClient;
// Update the panel with the latest execution data
updateHistoryPanel(historyPanel, latestExecution, mcpClient);
// Log that we're showing only the latest execution
logger.debug(
`Showing only the latest execution from ${matchingExecutions.length} matches for function ${functionName}`,
);
}
};
import { CONFIG } from '../core/config';
import { applyThemeClass, isDarkTheme } from '../utils/themeDetector';
import { createLogger } from '@extension/shared/lib/logger';
// State management for rendered elements
const logger = createLogger('FunctionResultRenderer');
export const processedResultElements = new WeakSet<HTMLElement>();
export const renderedFunctionResults = new Map<string, HTMLDivElement>();
/**
* Common interface for expandable content configuration
*/
interface ExpandableConfig {
blockId: string;
className: string;
headerText: string;
expandTitle: string;
collapseTitle: string;
callId?: string;
}
/**
* Creates a themed content area with consistent styling
*/
const createThemedContentArea = (className: string): HTMLDivElement => {
const contentArea = document.createElement('div');
contentArea.className = className;
contentArea.style.width = '100%';
contentArea.style.boxSizing = 'border-box';
contentArea.style.whiteSpace = 'pre-wrap';
contentArea.style.wordBreak = 'break-word';
// Apply theme-specific styles
if (isDarkTheme()) {
contentArea.style.backgroundColor = '#2d2d2d';
contentArea.style.border = '1px solid rgba(255, 255, 255, 0.1)';
contentArea.style.color = '#e8eaed';
} else {
contentArea.style.backgroundColor = '#f8f9fa';
contentArea.style.border = '1px solid rgba(0, 0, 0, 0.1)';
contentArea.style.color = '#202124';
}
return contentArea;
};
/**
* Creates an expandable content wrapper with consistent styling
*/
const createExpandableContent = (): HTMLDivElement => {
const expandableContent = document.createElement('div');
expandableContent.className = 'expandable-content';
expandableContent.style.overflow = 'hidden';
expandableContent.style.maxHeight = '0px';
expandableContent.style.opacity = '0';
expandableContent.style.padding = '0 12px';
expandableContent.style.width = '100%';
expandableContent.style.boxSizing = 'border-box';
expandableContent.style.transition = 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)';
return expandableContent;
};
/**
* Creates a header with expand/collapse functionality
*/
const createExpandableHeader = (
config: ExpandableConfig,
): { header: HTMLDivElement; expandButton: HTMLButtonElement } => {
const header = document.createElement('div');
header.className = config.className.includes('system') ? 'function-name system-header' : 'function-name';
// Create left section
const leftSection = document.createElement('div');
leftSection.className = 'function-name-left';
const nameText = document.createElement('div');
nameText.className = 'function-name-text';
nameText.textContent = config.headerText;
leftSection.appendChild(nameText);
// Create right section
const rightSection = document.createElement('div');
rightSection.className = 'function-name-right';
// Add call ID if available (for function results)
if (config.callId) {
const callIdElement = document.createElement('div');
callIdElement.className = 'call-id';
callIdElement.textContent = config.callId;
rightSection.appendChild(callIdElement);
}
// Create expand button
const expandButton = document.createElement('button');
expandButton.className = 'expand-button';
expandButton.innerHTML = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 10l4 4 4-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`;
expandButton.title = config.expandTitle;
rightSection.appendChild(expandButton);
header.appendChild(leftSection);
header.appendChild(rightSection);
return { header, expandButton };
};
/**
* Sets up expand/collapse functionality for a container
*/
const setupExpandCollapse = (
container: HTMLDivElement,
expandableContent: HTMLDivElement,
expandButton: HTMLButtonElement,
config: ExpandableConfig,
): void => {
expandButton.onclick = e => {
e.preventDefault();
e.stopPropagation();
const isCurrentlyExpanded = container.classList.contains('expanded');
const expandIcon = expandButton.querySelector('svg path');
if (isCurrentlyExpanded) {
// Collapse
container.classList.remove('expanded');
// Get current computed height including padding
const currentHeight = expandableContent.scrollHeight;
expandableContent.style.maxHeight = currentHeight + 'px';
expandableContent.offsetHeight; // Force reflow
requestAnimationFrame(() => {
expandableContent.style.maxHeight = '0px';
expandableContent.style.opacity = '0';
expandableContent.style.paddingTop = '0';
expandableContent.style.paddingBottom = '0';
if (expandIcon) {
expandIcon.setAttribute('d', 'M8 10l4 4 4-4');
}
expandButton.title = config.expandTitle;
});
} else {
// Expand
container.classList.add('expanded');
expandableContent.style.display = 'block';
expandableContent.style.maxHeight = '0px';
expandableContent.style.opacity = '0';
expandableContent.style.paddingTop = '0';
expandableContent.style.paddingBottom = '0';
// Calculate target height with padding
const targetHeight = expandableContent.scrollHeight + 24; // 12px top + 12px bottom padding
requestAnimationFrame(() => {
expandableContent.style.maxHeight = targetHeight + 'px';
expandableContent.style.opacity = '1';
expandableContent.style.paddingTop = '12px';
expandableContent.style.paddingBottom = '12px';
if (expandIcon) {
expandIcon.setAttribute('d', 'M16 14l-4-4-4 4');
}
expandButton.title = config.collapseTitle;
});
}
};
};
/**
* Creates a themed container with consistent setup
*/
const createThemedContainer = (className: string, blockId: string): HTMLDivElement => {
const container = document.createElement('div');
container.className = className;
container.setAttribute('data-block-id', blockId);
container.style.width = '100%';
container.style.boxSizing = 'border-box';
// Apply theme class
if (CONFIG.useHostTheme) {
applyThemeClass(container);
if (isDarkTheme()) {
container.classList.add('theme-dark');
} else {
container.classList.add('theme-light');
}
}
return container;
};
/**
* Replaces the content of a block with new content
*/
const replaceBlockContent = (block: HTMLElement, newContent: HTMLElement): void => {
while (block.firstChild) {
block.removeChild(block.firstChild);
}
block.appendChild(newContent);
};
/**
* Renders a system message box
*
* @param block HTML element to render the system message in
* @param content The system message content
*/
const renderSystemMessageBox = (block: HTMLElement, content: string): void => {
try {
// Generate a unique ID for this block
const blockId = `system-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
block.setAttribute('data-block-id', blockId);
// Create container
const systemContainer = createThemedContainer('function-block system-message-container', blockId);
// Create header and expand button
const config: ExpandableConfig = {
blockId,
className: 'system-message-container',
headerText: 'MCP SuperAssistant',
expandTitle: 'Expand system message',
collapseTitle: 'Collapse system message',
};
const { header, expandButton } = createExpandableHeader(config);
const expandableContent = createExpandableContent();
const contentArea = createThemedContentArea('param-value system-message-content');
// Fix border style for system messages
if (isDarkTheme()) {
contentArea.style.border = 'solid rgba(255, 255, 255, 0.1)';
} else {
contentArea.style.border = 'solid rgba(0, 0, 0, 0.1)';
}
// Add the system message content with proper newline handling
// Force proper text formatting to override any website CSS
contentArea.style.whiteSpace = 'pre-wrap';
contentArea.style.wordBreak = 'break-word';
contentArea.style.overflowWrap = 'break-word';
contentArea.style.fontFamily =
'var(--font-system), -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
contentArea.textContent = content;
expandableContent.appendChild(contentArea);
// Setup expand/collapse functionality
setupExpandCollapse(systemContainer, expandableContent, expandButton, config);
// Add components to container
systemContainer.appendChild(header);
systemContainer.appendChild(expandableContent);
// Replace the original block with our rendered version
replaceBlockContent(block, systemContainer);
} catch (e) {
logger.error('[renderSystemMessageBox] Error rendering system message:', e);
}
};
/**
* Renders different content types in the function result
*/
const renderFunctionResultContent = (resultContent: string, contentArea: HTMLDivElement): void => {
try {
const jsonResult = JSON.parse(resultContent);
// If it's JSON and has content array, render it properly
if (jsonResult && jsonResult.content && Array.isArray(jsonResult.content)) {
// Render each content item
jsonResult.content.forEach((item: any) => {
if (item.type === 'text') {
const textDiv = document.createElement('div');
textDiv.className = 'function-result-text';
textDiv.style.margin = '0 0 10px 0';
textDiv.style.whiteSpace = 'pre-wrap';
textDiv.style.wordBreak = 'break-word';
textDiv.textContent = item.text;
contentArea.appendChild(textDiv);
} else if (item.type === 'image' && item.url) {
const imgContainer = document.createElement('div');
imgContainer.className = 'function-result-image';
imgContainer.style.margin = '10px 0';
const img = document.createElement('img');
img.src = item.url;
img.alt = item.alt || 'Image';
img.style.maxWidth = '100%';
img.style.borderRadius = '4px';
imgContainer.appendChild(img);
contentArea.appendChild(imgContainer);
} else if (item.type === 'code' && item.code) {
const codeContainer = document.createElement('div');
codeContainer.className = 'function-result-code';
codeContainer.style.margin = '10px 0';
codeContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.05)';
codeContainer.style.borderRadius = '4px';
codeContainer.style.padding = '10px';
const pre = document.createElement('pre');
pre.style.margin = '0';
pre.style.whiteSpace = 'pre-wrap';
pre.style.wordBreak = 'break-word';
pre.style.fontFamily = 'monospace';
pre.textContent = item.code;
codeContainer.appendChild(pre);
contentArea.appendChild(codeContainer);
} else {
// For unknown types, just render as JSON
const unknownDiv = document.createElement('div');
unknownDiv.className = 'function-result-unknown';
unknownDiv.style.margin = '5px 0';
unknownDiv.style.fontFamily = 'monospace';
unknownDiv.style.fontSize = '12px';
unknownDiv.textContent = JSON.stringify(item, null, 2);
contentArea.appendChild(unknownDiv);
}
});
} else {
// If it's JSON but not in the expected format, format it nicely
const pre = document.createElement('pre');
pre.style.margin = '0';
pre.style.whiteSpace = 'pre-wrap';
pre.style.wordBreak = 'break-word';
pre.style.fontFamily = 'monospace';
pre.textContent = JSON.stringify(jsonResult, null, 2);
contentArea.appendChild(pre);
}
} catch (e) {
// If not JSON, just display as text with proper line breaks
contentArea.style.whiteSpace = 'pre-wrap';
contentArea.style.wordBreak = 'break-word';
contentArea.textContent = resultContent;
}
};
/**
* Creates a complete expandable block with header and content
*/
const createExpandableBlock = (config: ExpandableConfig, contentArea: HTMLDivElement): HTMLDivElement => {
const container = createThemedContainer('function-block ' + config.className, config.blockId);
const { header, expandButton } = createExpandableHeader(config);
const expandableContent = createExpandableContent();
expandableContent.appendChild(contentArea);
setupExpandCollapse(container, expandableContent, expandButton, config);
container.appendChild(header);
container.appendChild(expandableContent);
return container;
};
/**
* Main function to render a function result block
*
* @param block HTML element containing a function result
* @param isProcessingRef Reference to processing state
* @returns Boolean indicating whether rendering was successful
*/
export const renderFunctionResult = (block: HTMLElement, isProcessingRef: { current: boolean }): boolean => {
try {
// Skip if already processed
if (processedResultElements.has(block)) {
return false;
}
// Mark as processed to avoid duplicate processing
processedResultElements.add(block);
// Get the content of the block
// let content = block.textContent || '';
let content = block.textContent || '';
// Check if it contains MCP SuperAssistant system message tags
if (content.includes('<SYSTEM>') || content.includes('</SYSTEM>') || content.includes('<system>') || content.includes('</system>')) {
// Extract content between SYSTEM tags
const systemMatch = content;
if (systemMatch) {
const systemContent = systemMatch.trim();
renderSystemMessageBox(block, systemContent);
return true;
}
}
// Check if it's a function result
if (!content.includes('<function_result') && !content.includes('</function_result>')) {
return false;
}
// Generate a unique ID for this block
const blockId = `result-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
block.setAttribute('data-block-id', blockId);
// Parse the function result content
let resultContent = '';
try {
// Extract content between function_result tags
const resultMatch = content.match(/<function_result[^>]*>([\s\S]*?)<\/function_result>/);
if (resultMatch && resultMatch[1]) {
resultContent = resultMatch[1].trim();
}
// Extract call_id if available
const callIdMatch = content.match(/call_id="([^"]*)"/);
const callId = callIdMatch ? callIdMatch[1] : '';
// Create configuration for expandable block
const config: ExpandableConfig = {
blockId,
className: 'function-result-container',
headerText: 'Function Result',
expandTitle: 'Expand function result',
collapseTitle: 'Collapse function result',
callId,
};
// Create content area and render content
const contentArea = createThemedContentArea('param-value function-result-content');
renderFunctionResultContent(resultContent, contentArea);
// Create the complete expandable block
const resultContainer = createExpandableBlock(config, contentArea);
// Replace the original block with our rendered version
replaceBlockContent(block, resultContainer);
// Store the rendered block for future reference
renderedFunctionResults.set(blockId, resultContainer);
return true;
} catch (e) {
logger.error('Error parsing function result:', e);
return false;
}
} catch (e) {
logger.error('Error rendering function result:', e);
return false;
} finally {
// Reset processing state
isProcessingRef.current = false;
}
};
// Re-export renderer functionality
export * from './functionBlock';
export * from './functionResult';
export * from './components';
export * from './styles';
// Import proper types and dependencies
import { CONFIG } from '../core/config';
import type { FunctionInfo, ParamValueElement } from '../core/types';
import { Parameter } from '../core/types';
import { extractParameters } from '../parser/index';
import { stabilizeBlock, unstabilizeBlock, addExecuteButton, smoothlyUpdateBlockContent } from './components';
import { createOrUpdateParamElement } from './functionBlock';
import { safelySetContent } from '../utils/dom';
// Define a render options interface
interface RenderOptions {
current: boolean;
}
// Find the part where an existing function block is updated during streaming and add smooth updates
/**
* Update an existing function block with new content
*
* @param block The existing function block element
* @param functionContent The updated content to display
* @param functionInfo Information about the function call
* @param options Rendering options
*/
const updateExistingFunctionBlock = (
block: HTMLElement,
functionContent: string,
functionInfo: FunctionInfo,
options: RenderOptions,
): void => {
const blockId = block.getAttribute('data-block-id');
if (!blockId) return;
if (CONFIG.debug) console.debug(`Updating existing function block: ${blockId}`);
// Check if we're transitioning from loading to complete
const wasLoading = block.classList.contains('function-loading');
const isComplete = functionInfo.isComplete;
// Directly update state and content when transitioning or already complete
if (isComplete) {
// Ensure loading class is removed and complete class is added
if (block.classList.contains('function-loading')) {
block.classList.remove('function-loading');
}
if (!block.classList.contains('function-complete')) {
block.classList.add('function-complete');
}
// Update the function content area using safelySetContent
const contentArea = block.querySelector('.function-content');
if (contentArea) {
safelySetContent(contentArea as ParamValueElement, functionContent);
}
// Update parameters if needed
updateParameters(block, functionInfo, options);
// Add execute button if not already present
if (!block.querySelector('.execute-button')) {
// Find the original pre element to get the raw content
const originalPre = document.querySelector(`div[data-block-id="${blockId}"]`);
if (originalPre && originalPre.textContent?.trim()) {
addExecuteButton(block as HTMLDivElement, originalPre.textContent!.trim());
}
}
} else {
// Handle cases where block is still loading or becomes incomplete again
if (block.classList.contains('function-complete')) {
block.classList.remove('function-complete');
}
if (!block.classList.contains('function-loading')) {
block.classList.add('function-loading');
// Potentially remove execute button/toggle if added previously
const executeBtn = block.querySelector('.execute-button');
if (executeBtn) executeBtn.remove();
// Add spinner etc. if needed (assuming functionBlock.ts handles this primarily)
}
// Update the function content area using safelySetContent
const contentArea = block.querySelector('.function-content');
if (contentArea) {
safelySetContent(contentArea as ParamValueElement, functionContent);
}
// Update parameters
updateParameters(block, functionInfo, options);
}
};
/**
* Update parameters in a function block
*/
const updateParameters = (block: HTMLElement, functionInfo: FunctionInfo, options: RenderOptions): void => {
// Get extracted parameters
const parameters = extractParameters(
block.getAttribute('data-content') || '',
block.getAttribute('data-block-id') || null,
);
// Update parameter values
const paramContainer = block.querySelector('.function-parameters');
if (!paramContainer) return;
for (const param of parameters) {
// Find existing parameter row or create a new one
const paramRow = paramContainer.querySelector(`.param-row[data-param-name="${param.name}"]`) as HTMLElement;
if (!paramRow) {
// Use standard createOrUpdateParamElement for new parameters
createOrUpdateParamElement(
block as HTMLDivElement,
param.name,
param.value,
block.getAttribute('data-block-id') || '',
true,
param.isStreaming || false,
);
} else {
// Get the value container
const valueContainer = paramRow.querySelector('.param-value') as HTMLElement;
if (!valueContainer) continue;
// Use smoothlyUpdateBlockContent to minimize DOM changes for streaming parameters
if (param.isStreaming) {
// Stabilize DOM during streaming for smoother updates
stabilizeBlock(valueContainer);
// Apply content update with minimal DOM operations
smoothlyUpdateBlockContent(valueContainer, param.value, true);
} else {
// For completed parameters, use standard update
safelySetContent(valueContainer as ParamValueElement, param.value);
// Ensure block is unstabilized after completion
unstabilizeBlock(valueContainer);
}
// Update parameter state classes
if (param.isComplete) {
paramRow.classList.remove('param-streaming');
paramRow.classList.add('param-complete');
} else {
paramRow.classList.remove('param-complete'); // Ensure complete is removed
paramRow.classList.add('param-streaming');
}
}
}
};
import { isDarkTheme } from '../utils/themeDetector';
// Determine if dark theme should be used
const useDarkTheme = isDarkTheme();
export const styles = `
/* CSS Custom Properties for Performance */
.function-block {
/* Light theme variables */
--light-bg: #ffffff;
--light-text: #202124;
--light-text-secondary: #5f6368;
--light-text-tertiary: #3c4043;
--light-border: rgba(0,0,0,0.03);
--light-border-secondary: rgba(0,0,0,0.06);
--light-border-tertiary: rgba(0,0,0,0.12);
--light-shadow: 0 3px 12px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.05);
--light-surface: #f1f3f4;
--light-surface-secondary: #f5f7f9;
--light-surface-tertiary: #f8f9fa;
--light-primary: #1a73e8;
--light-primary-hover: #1967d2;
--light-primary-surface: #e8f0fe;
--light-success: #34a853;
--light-error: #ea4335;
--light-warning: #fbbc04;
/* Dark theme variables */
--dark-bg: #1e1e1e;
--dark-text: #e8eaed;
--dark-text-secondary: #9aa0a6;
--dark-text-tertiary: #dadce0;
--dark-border: rgba(255,255,255,0.03);
--dark-border-secondary: rgba(255,255,255,0.05);
--dark-border-tertiary: rgba(255,255,255,0.12);
--dark-shadow: 0 3px 12px rgba(0,0,0,0.25), 0 1px 4px rgba(0,0,0,0.15);
--dark-surface: #2d2d2d;
--dark-surface-secondary: #282828;
--dark-surface-tertiary: #1e1e1e;
--dark-primary: #8ab4f8;
--dark-primary-hover: #7ba9f0;
--dark-primary-surface: #174ea6;
--dark-success: #34a853;
--dark-error: #f28b82;
--dark-warning: #ffcb6b;
/* Common variables */
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
--font-system: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
--border-radius: 8px;
--border-radius-sm: 4px;
--border-radius-lg: 10px;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
--spacing-lg: 16px;
--spacing-xl: 18px;
--spacing-xxl: 20px;
--transition-fast: 0.15s ease;
--transition-normal: 0.2s ease;
--transition-slow: 0.25s ease-in-out;
}
/* Base styles with CSS variables and GPU acceleration */
.function-block {
margin: var(--spacing-xxl) 0;
padding: var(--spacing-xl);
border-radius: var(--border-radius-lg);
font-family: var(--font-system);
position: relative;
transition: all var(--transition-slow);
will-change: transform, opacity, box-shadow;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000px;
box-sizing: border-box;
overflow: hidden;
width: 100%;
min-width: 0;
}
/* Theme-specific styles using CSS variables */
.function-block.theme-light,
.function-block:not(.theme-dark) {
background: var(--light-bg);
color: var(--light-text);
box-shadow: var(--light-shadow);
border: 1px solid var(--light-border);
}
.function-block.theme-dark {
background: var(--dark-bg);
color: var(--dark-text);
box-shadow: var(--dark-shadow);
border: 1px solid var(--dark-border);
}
/* Optimized stabilized blocks */
.function-block-stabilized {
position: fixed !important;
z-index: 1000 !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.2) !important;
transform: translate3d(0,0,0); /* Hardware acceleration */
}
/* Optimized function name styles - Always visible header */
.function-name {
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--spacing-lg);
position: relative;
width: 100%;
max-width: 100%;
line-height: 1.4;
padding: 12px;
margin-bottom: 0;
transition: all var(--transition-normal);
cursor: pointer;
border-radius: var(--border-radius-md) var(--border-radius-md) 0 0;
will-change: transform, opacity, background-color;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
box-sizing: border-box;
overflow: hidden;
}
/* Left section of function header (function name) */
.function-name-left {
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex: 1;
min-width: 0; /* Allow text truncation */
max-width: 100%;
overflow: hidden;
will-change: transform;
transform: translate3d(0, 0, 0);
}
/* Right section of function header (expand button + call-id) */
.function-name-right {
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex-shrink: 0;
max-width: 40%;
overflow: hidden;
will-change: transform;
transform: translate3d(0, 0, 0);
}
/* Collapsed state - rounded corners all around */
.function-block:not(.expanded) .function-name {
border-radius: var(--border-radius-md);
margin-bottom: 0;
}
/* Expanded state - rounded top corners only */
.function-block.expanded .function-name {
border-radius: var(--border-radius-md) var(--border-radius-md) 0 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
/* Optimized expand button styles */
.expand-button {
background: none;
border: 1px solid rgba(0, 0, 0, 0.1);
cursor: pointer;
padding: 6px;
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-normal);
opacity: 0.8;
will-change: transform, opacity, box-shadow;
min-width: 28px;
height: 28px;
margin-left: var(--spacing-sm);
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
}
.expand-button:hover {
opacity: 1;
transform: scale(1.05) translate3d(0, 0, 0);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.expand-button:active {
transform: scale(0.95) translate3d(0, 0, 0);
}
.expand-button svg {
transition: transform var(--transition-normal);
will-change: transform;
}
/* Consolidated expand button theme styles */
.function-block.theme-light .expand-button,
.function-block:not(.theme-dark) .expand-button {
color: var(--light-text-secondary);
border-color: rgba(26, 115, 232, 0.2);
background-color: rgba(26, 115, 232, 0.05);
}
.function-block.theme-light .expand-button:hover,
.function-block:not(.theme-dark) .expand-button:hover {
color: var(--light-primary);
background-color: rgba(26, 115, 232, 0.1);
border-color: rgba(26, 115, 232, 0.4);
}
.function-block.theme-dark .expand-button {
color: var(--dark-text-secondary);
border-color: rgba(138, 180, 248, 0.2);
background-color: rgba(138, 180, 248, 0.05);
}
.function-block.theme-dark .expand-button:hover {
color: var(--dark-primary);
background-color: rgba(138, 180, 248, 0.1);
border-color: rgba(138, 180, 248, 0.4);
}
/* Optimized expandable content with smooth animations */
.expandable-content {
overflow: hidden;
width: 100%;
box-sizing: border-box;
transition: max-height 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94),
padding 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94),
border-color 0.3s ease-out;
will-change: max-height, opacity, padding;
border: 1px solid transparent;
border-top: none;
border-radius: 0 0 var(--border-radius-md) var(--border-radius-md);
background-color: rgba(0, 0, 0, 0.02);
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
max-height: 0;
opacity: 0;
padding: 0 12px;
}
/* Enhanced auto-expanded state for streaming content */
.function-block.auto-expanded .expandable-content {
animation: smoothExpand 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
@keyframes smoothExpand {
from {
max-height: 0;
opacity: 0;
padding: 0 12px;
}
to {
max-height: 2000px;
opacity: 1;
padding: 12px;
}
}
.function-block:not(.expanded) .expandable-content {
max-height: 0;
opacity: 0;
padding: 0 12px;
border-color: transparent;
}
.function-block.expanded .expandable-content {
max-height: 2000px; /* Large enough to accommodate content */
opacity: 1;
padding: 12px;
}
/* Theme styles for expandable content */
.function-block.theme-light .expandable-content,
.function-block:not(.theme-dark) .expandable-content {
background-color: rgba(26, 115, 232, 0.02);
border-color: rgba(26, 115, 232, 0.2);
}
.function-block.theme-dark .expandable-content {
background-color: rgba(138, 180, 248, 0.02);
border-color: rgba(138, 180, 248, 0.2);
}
/* Consolidated theme styles for function name */
.function-block.theme-light .function-name,
.function-block:not(.theme-dark) .function-name {
color: var(--light-primary);
background-color: rgba(26, 115, 232, 0.05);
border: 1px solid rgba(26, 115, 232, 0.2);
}
.function-block.theme-dark .function-name {
color: var(--dark-primary);
background-color: rgba(138, 180, 248, 0.05);
border: 1px solid rgba(138, 180, 248, 0.2);
}
/* Expanded state border styling */
.function-block.expanded.theme-light .function-name,
.function-block.expanded:not(.theme-dark) .function-name {
border-bottom-color: rgba(26, 115, 232, 0.3);
}
.function-block.expanded.theme-dark .function-name {
border-bottom-color: rgba(138, 180, 248, 0.3);
}
/* Hover effect for function name */
.function-name:hover {
opacity: 0.8;
transform: translateY(-1px) translate3d(0, 0, 0);
}
/* Function name text styling */
.function-name-text {
font-weight: inherit;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
will-change: transform;
transform: translate3d(0, 0, 0);
}
/* Call ID styling */
.call-id {
font-size: 0.85em;
opacity: 0.7;
font-weight: 400;
background-color: rgba(0, 0, 0, 0.05);
padding: 2px 6px;
border-radius: var(--border-radius-sm);
font-family: monospace;
flex-shrink: 0;
transition: opacity var(--transition-normal);
will-change: opacity;
transform: translate3d(0, 0, 0);
}
/* Consolidated call ID theme styles */
.function-block.theme-light .call-id,
.function-block:not(.theme-dark) .call-id {
background-color: rgba(26, 115, 232, 0.1);
color: var(--light-text-secondary);
}
.function-block.theme-dark .call-id {
background-color: rgba(138, 180, 248, 0.1);
color: var(--dark-text-secondary);
}
.function-block:hover .call-id {
opacity: 1;
transform: scale(1.02) translate3d(0, 0, 0);
}
/* Optimized parameter styles */
.param-name {
font-weight: 500;
margin-top: 14px;
margin-bottom: 6px;
padding-left: 2px;
font-size: 14px;
display: flex;
align-items: center;
contain: layout style;
will-change: opacity;
transform: translate3d(0, 0, 0);
}
/* Consolidated parameter name theme styles */
.function-block.theme-light .param-name,
.function-block:not(.theme-dark) .param-name {
color: var(--light-text);
}
.function-block.theme-dark .param-name {
color: var(--dark-text);
}
/* Optimized parameter value with hardware acceleration */
.param-value {
padding: var(--spacing-md) 14px;
border-radius: var(--border-radius);
font-family: var(--font-mono);
white-space: pre-wrap;
overflow-x: hidden;
overflow-y: auto;
word-break: break-word;
overflow-wrap: break-word;
font-size: 13px;
line-height: 1.5;
max-height: 300px;
scrollbar-width: thin;
position: relative;
pointer-events: auto !important;
transition: background-color var(--transition-normal), border-color var(--transition-normal);
border: 1px solid transparent;
contain: layout style;
transform: translate3d(0,0,0); /* Hardware acceleration for scrolling */
}
/* Specific styles for system message content */
.system-message-content {
white-space: pre-wrap !important;
word-break: break-word !important;
overflow-wrap: break-word !important;
font-family: var(--font-system) !important;
}
/* Specific styles for function result content */
.function-result-content {
white-space: pre-wrap !important;
word-break: break-word !important;
overflow-wrap: break-word !important;
}
/* Ensure all text content in function results preserves newlines */
.function-result-text {
white-space: pre-wrap !important;
word-break: break-word !important;
overflow-wrap: break-word !important;
}
/* Optimized scrollbar styles */
.param-value::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.param-value::-webkit-scrollbar-track {
background: transparent;
margin: var(--spacing-xs);
}
.param-value::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.15);
border-radius: 20px;
transition: background-color var(--transition-normal);
}
.param-value::-webkit-scrollbar-thumb:hover {
background-color: rgba(0, 0, 0, 0.25);
}
/* Dark theme scrollbar */
.function-block.theme-dark .param-value::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.15);
}
.function-block.theme-dark .param-value::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.25);
}
/* Consolidated parameter value theme styles */
.function-block.theme-light .param-value,
.function-block:not(.theme-dark) .param-value {
background-color: var(--light-surface-secondary);
border-color: var(--light-border-secondary);
color: var(--light-text);
}
.function-block.theme-dark .param-value {
background-color: var(--dark-surface-secondary);
border-color: var(--dark-border-secondary);
color: var(--dark-text);
}
/* Optimized large content styles */
.large-content {
position: relative;
contain: layout;
}
.large-content::after,
.content-truncated {
display: none;
}
/* Optimized streaming parameter styles with hardware acceleration */
.param-value[data-streaming="true"] {
padding: 0;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
max-height: 300px;
border-color: rgba(26, 115, 232, 0.3);
animation: subtle-pulse 2s infinite ease-in-out;
will-change: border-color, transform;
transform: translate3d(0,0,0);
scroll-behavior: smooth !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
/* Fade gradient overlays for smooth streaming effect */
.param-value[data-streaming="true"]::before,
.param-value[data-streaming="true"]::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 20px;
pointer-events: none;
z-index: 2;
transition: opacity 0.3s ease;
}
.param-value[data-streaming="true"]::before {
top: 0;
background: linear-gradient(to bottom,
rgba(255, 255, 255, 0.9) 0%,
rgba(255, 255, 255, 0.7) 50%,
transparent 100%);
}
.param-value[data-streaming="true"]::after {
bottom: 0;
background: linear-gradient(to top,
rgba(255, 255, 255, 0.9) 0%,
rgba(255, 255, 255, 0.7) 50%,
transparent 100%);
}
/* Dark theme fade gradients */
.function-block.theme-dark .param-value[data-streaming="true"]::before {
background: linear-gradient(to bottom,
rgba(32, 33, 36, 0.9) 0%,
rgba(32, 33, 36, 0.7) 50%,
transparent 100%);
}
.function-block.theme-dark .param-value[data-streaming="true"]::after {
background: linear-gradient(to top,
rgba(32, 33, 36, 0.9) 0%,
rgba(32, 33, 36, 0.7) 50%,
transparent 100%);
}
/* Enhanced streaming visual feedback */
.param-value[data-streaming="true"]::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg,
transparent 0%,
rgba(26, 115, 232, 0.6) 50%,
transparent 100%);
animation: streamingIndicator 2s infinite linear;
z-index: 1;
}
@keyframes streamingIndicator {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* Enhanced streaming content structure */
.param-value[data-streaming="true"] .content-wrapper {
flex: 1;
overflow: hidden;
position: relative;
min-height: 50px;
}
/* Optimized streaming pre element */
.param-value[data-streaming="true"] > pre,
.param-value[data-streaming="true"] .content-wrapper > pre {
margin: 0;
padding: var(--spacing-md) 14px;
overflow-x: hidden;
overflow-y: auto;
max-height: 300px;
flex: 1;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
background-color: inherit;
color: inherit !important;
border: none;
scroll-behavior: smooth;
contain: layout style;
transform: translate3d(0,0,0);
width: 100%;
min-height: inherit;
animation: textFlow 0.3s ease-out;
}
@keyframes textFlow {
from { opacity: 0.7; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
/* Enhanced scrollbar for streaming content */
.param-value[data-streaming="true"]::-webkit-scrollbar,
.param-value[data-streaming="true"] pre::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.param-value[data-streaming="true"]::-webkit-scrollbar-track,
.param-value[data-streaming="true"] pre::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 3px;
}
.param-value[data-streaming="true"]::-webkit-scrollbar-thumb,
.param-value[data-streaming="true"] pre::-webkit-scrollbar-thumb {
background: rgba(0, 212, 255, 0.5);
border-radius: 3px;
transition: background 0.2s ease;
}
.param-value[data-streaming="true"]::-webkit-scrollbar-thumb:hover,
.param-value[data-streaming="true"] pre::-webkit-scrollbar-thumb:hover {
background: rgba(0, 212, 255, 0.8);
}
/* Dark theme scrollbar for streaming */
.function-block.theme-dark .param-value[data-streaming="true"]::-webkit-scrollbar-track,
.function-block.theme-dark .param-value[data-streaming="true"] pre::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
.function-block.theme-dark .param-value[data-streaming="true"]::-webkit-scrollbar-thumb,
.function-block.theme-dark .param-value[data-streaming="true"] pre::-webkit-scrollbar-thumb {
background: rgba(138, 180, 248, 0.5);
}
.function-block.theme-dark .param-value[data-streaming="true"]::-webkit-scrollbar-thumb:hover,
.function-block.theme-dark .param-value[data-streaming="true"] pre::-webkit-scrollbar-thumb:hover {
background: rgba(138, 180, 248, 0.8);
}
/* Optimized streaming indicator */
.streaming-param-name {
position: relative;
display: flex;
align-items: center;
contain: layout style;
}
.streaming-param-name::before {
content: "";
margin-right: var(--spacing-sm);
width: var(--spacing-sm);
height: var(--spacing-sm);
border-radius: 50%;
background-color: var(--light-primary);
animation: pulse 1.5s infinite ease-in-out;
pointer-events: none;
flex-shrink: 0;
will-change: opacity;
}
.function-block.theme-dark .streaming-param-name::before {
background-color: var(--dark-primary);
}
/* Optimized stalled indicator styles */
.stalled-indicator {
margin-top: 10px;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius-sm);
font-size: 14px;
animation: fadeIn 0.3s ease-in-out;
contain: layout style;
}
.stalled-indicator[data-pre-existing="true"] {
background-color: rgba(180, 180, 180, 0.1);
border: 1px solid rgba(180, 180, 180, 0.3);
color: #555;
}
/* Consolidated stalled indicator theme styles */
.function-block.theme-light .stalled-indicator,
.function-block:not(.theme-dark) .stalled-indicator {
background-color: rgba(255, 200, 0, 0.1);
border: 1px solid rgba(255, 200, 0, 0.3);
color: #664d00;
}
.function-block.theme-dark .stalled-indicator {
background-color: rgba(255, 200, 0, 0.15);
border: 1px solid rgba(255, 200, 0, 0.3);
color: var(--dark-warning);
}
.stalled-message {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-family: var(--font-system);
}
.stalled-message svg {
flex-shrink: 0;
}
.stalled-retry-button {
margin-top: var(--spacing-sm);
padding: var(--spacing-xs) 10px;
background-color: rgba(255, 200, 0, 0.2);
border: 1px solid rgba(255, 200, 0, 0.4);
border-radius: var(--border-radius-sm);
cursor: pointer;
font-size: var(--spacing-md);
color: #664d00;
font-family: var(--font-system);
transition: background-color var(--transition-normal);
}
.stalled-retry-button:hover {
background-color: rgba(255, 200, 0, 0.3);
}
/* Virtual scrolling container optimizations */
.virtual-viewport {
contain: layout style paint;
will-change: scroll-position;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
-webkit-overflow-scrolling: touch;
}
.virtual-spacer {
contain: layout style;
will-change: height, transform;
}
.param-value[data-streaming="true"] .virtual-viewport {
scroll-behavior: auto !important;
}
.param-value[data-streaming="true"] .virtual-viewport pre {
will-change: transform, contents;
contain: layout style paint;
}
/* Enhanced function block states */
.function-block.function-loading {
background: linear-gradient(135deg,
rgba(26, 115, 232, 0.02) 0%,
rgba(26, 115, 232, 0.01) 100%);
border-left: 3px solid rgba(26, 115, 232, 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.function-block.function-complete {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.function-block.auto-expanded {
box-shadow: 0 4px 16px rgba(26, 115, 232, 0.15);
border-color: rgba(26, 115, 232, 0.2);
}
.function-block.theme-dark.auto-expanded {
box-shadow: 0 4px 16px rgba(138, 180, 248, 0.15);
border-color: rgba(138, 180, 248, 0.2);
}
/* Optimized keyframe animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
@keyframes subtle-pulse {
0%, 100% { border-color: rgba(26, 115, 232, 0.2); }
50% { border-color: rgba(26, 115, 232, 0.4); }
}
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* Optimized incomplete tag */
.incomplete-tag {
border-left: 3px dashed var(--light-primary) !important;
background-color: var(--light-primary-surface) !important;
}
.function-block.theme-dark .incomplete-tag {
border-left: 3px dashed var(--dark-primary) !important;
background-color: var(--dark-primary-surface) !important;
}
/* Optimized button container */
.function-buttons {
display: flex;
gap: var(--spacing-md);
margin-top: var(--spacing-xl);
justify-content: flex-start;
align-items: center;
contain: layout;
will-change: transform;
transform: translate3d(0, 0, 0);
}
/* Unified button base styles */
.raw-toggle,
.execute-button,
.insert-result-button,
.attach-file-button {
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--border-radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-normal);
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
display: flex;
align-items: center;
justify-content: center;
border: none;
will-change: transform, box-shadow;
contain: layout style;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
}
/* Optimized active states with hardware acceleration */
.execute-button:active,
.insert-result-button:active,
.attach-file-button:active,
.raw-toggle:active {
transform: translateY(1px) translate3d(0,0,0);
box-shadow: 0 0 1px rgba(0,0,0,0.12);
}
/* Consolidated raw toggle theme styles */
.function-block.theme-light .raw-toggle,
.function-block:not(.theme-dark) .raw-toggle {
background: var(--light-surface);
color: var(--light-text-secondary);
border: 1px solid var(--light-border-tertiary);
}
.function-block.theme-light .raw-toggle:hover,
.function-block:not(.theme-dark) .raw-toggle:hover {
background: #e8eaed;
color: var(--light-text);
}
.function-block.theme-dark .raw-toggle {
background: var(--dark-surface);
color: var(--dark-text-tertiary);
border: 1px solid var(--dark-border-tertiary);
}
.function-block.theme-dark .raw-toggle:hover {
background: #3c4043;
color: var(--dark-text);
}
/* Consolidated primary button styles - light theme */
.function-block.theme-light .execute-button,
.function-block:not(.theme-dark) .execute-button,
.function-block.theme-light .insert-result-button,
.function-block.theme-light .attach-file-button,
.function-block:not(.theme-dark) .insert-result-button,
.function-block:not(.theme-dark) .attach-file-button {
background: var(--light-primary);
color: white;
background-image: linear-gradient(to bottom, var(--light-primary), var(--light-primary-hover));
}
.function-block.theme-light .execute-button:hover,
.function-block:not(.theme-dark) .execute-button:hover,
.function-block.theme-light .insert-result-button:hover,
.function-block.theme-light .attach-file-button:hover,
.function-block:not(.theme-dark) .insert-result-button:hover,
.function-block:not(.theme-dark) .attach-file-button:hover {
background: var(--light-primary-hover);
background-image: linear-gradient(to bottom, var(--light-primary-hover), #1765cc);
}
/* Consolidated primary button styles - dark theme */
.function-block.theme-dark .execute-button,
.function-block.theme-dark .insert-result-button,
.function-block.theme-dark .attach-file-button {
background: var(--dark-primary);
color: var(--light-text);
background-image: linear-gradient(to bottom, var(--dark-primary), var(--dark-primary-hover));
}
.function-block.theme-dark .execute-button:hover,
.function-block.theme-dark .insert-result-button:hover,
.function-block.theme-dark .attach-file-button:hover {
background: var(--dark-primary-hover);
background-image: linear-gradient(to bottom, var(--dark-primary-hover), #6ca0e8);
}
/* Optimized function results panel */
.mcp-function-results-panel,
.xml-results-panel {
border-radius: 6px;
margin-top: 10px;
overflow: auto;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.5;
contain: layout style;
transform: translate3d(0,0,0);
will-change: transform, opacity;
transition: all var(--transition-normal);
}
.mcp-function-results-panel:hover,
.xml-results-panel:hover {
transform: scale(1.005) translate3d(0, 0, 0);
}
/* Consolidated results panel theme styles */
.function-block.theme-light .mcp-function-results-panel,
.function-block.theme-light .xml-results-panel,
.function-block:not(.theme-dark) .mcp-function-results-panel,
.function-block:not(.theme-dark) .xml-results-panel {
background-color: var(--light-surface-tertiary);
border: 1px solid #eaecef;
color: var(--light-text);
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
margin-top: var(--spacing-md);
}
.function-block.theme-dark .mcp-function-results-panel,
.function-block.theme-dark .xml-results-panel {
background-color: var(--dark-surface-tertiary);
border: 1px solid var(--dark-border);
color: var(--dark-text);
box-shadow: var(--dark-shadow);
margin-top: var(--spacing-md);
}
.function-results-loading {
padding: 10px;
color: var(--light-text-secondary);
font-style: italic;
}
.function-result-success pre {
margin: 0;
padding: 10px;
white-space: pre-wrap;
word-break: break-word;
}
/* Consolidated error theme styles */
.function-block.theme-light .function-result-error,
.function-block:not(.theme-dark) .function-result-error {
padding: 10px;
color: var(--light-error);
background-color: rgba(211, 47, 47, 0.05);
border-radius: var(--border-radius-sm);
}
.function-block.theme-dark .function-result-error {
color: var(--dark-error);
background-color: rgba(242, 139, 130, 0.1);
}
/* Consolidated language tag theme styles */
.function-block.theme-light .language-tag,
.function-block:not(.theme-dark) .language-tag {
background: var(--light-primary-surface);
color: var(--light-primary);
}
.function-block.theme-dark .language-tag {
background: var(--dark-primary-surface);
color: var(--dark-text);
}
.language-tag {
display: inline-block;
padding: 2px 6px;
border-radius: var(--border-radius-sm);
font-size: var(--spacing-md);
margin-bottom: var(--spacing-sm);
font-family: var(--font-system);
contain: layout style;
will-change: transform;
transform: translate3d(0, 0, 0);
transition: all var(--transition-normal);
}
.language-tag:hover {
transform: scale(1.02) translate3d(0, 0, 0);
}
/* Optimized XML pre element */
.xml-pre {
white-space: pre-wrap;
margin: 0;
padding: var(--spacing-md);
font-family: inherit;
font-size: 13px;
line-height: 1.5;
contain: layout style;
}
/* Optimized spinner with hardware acceleration for function name */
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(26, 115, 232, 0.3);
border-top: 2px solid var(--light-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
flex-shrink: 0;
will-change: transform;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
contain: layout style;
}
.function-block.theme-dark .spinner {
border: 2px solid rgba(138, 180, 248, 0.3);
border-top: 2px solid var(--dark-primary);
}
/* Optimized mobile layout with efficient media query */
@media (max-width: 768px) {
.function-block {
padding: 14px;
margin: 15px 0;
}
.function-name {
font-size: 15px;
padding-bottom: var(--spacing-sm);
margin-bottom: var(--spacing-md);
}
.param-name {
font-size: 13px;
margin-top: var(--spacing-md);
margin-bottom: var(--spacing-xs);
}
.param-value {
max-height: 200px;
padding: 10px var(--spacing-md);
font-size: 12.5px;
}
.call-id {
font-size: 0.8em;
padding: 2px 6px;
}
}
/* Optimized spinner with hardware acceleration */
.function-spinner {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 50%;
animation: spin 1s linear infinite;
flex-shrink: 0;
will-change: transform;
contain: layout style;
}
/* Consolidated spinner theme styles */
.function-block.theme-light .function-spinner,
.function-block:not(.theme-dark) .function-spinner {
border: 2px solid rgba(26,115,232,0.3);
border-top: 2px solid var(--light-primary);
}
.function-block.theme-dark .function-spinner {
border: 2px solid rgba(138, 180, 248, 0.3);
border-top: 2px solid var(--dark-primary);
}
/* Optimized keyframe with transform3d */
@keyframes spin {
0% { transform: rotate(0deg) translate3d(0,0,0); }
100% { transform: rotate(360deg) translate3d(0,0,0); }
}
.function-loading .function-name {
position: relative;
}
/* Optimized insert button container */
.insert-button-container {
margin-top: 10px;
margin-bottom: 10px;
display: flex;
justify-content: flex-end;
contain: layout;
}
.insert-result-button {
padding: var(--spacing-xs) var(--spacing-md);
}
/* Optimized button state styles */
.insert-result-button.insert-success,
.attach-file-button.attach-success {
background: var(--light-success) !important;
color: white !important;
}
.insert-result-button.insert-error,
.attach-file-button.attach-error {
background: var(--light-error) !important;
color: white !important;
}
.attach-file-button {
padding: var(--spacing-xs) var(--spacing-md);
margin-left: 6px;
}
/* Optimized content transitions with hardware acceleration */
.function-content-wrapper {
position: relative;
width: 100%;
transition: opacity var(--transition-normal), transform var(--transition-normal);
will-change: opacity, transform;
contain: layout;
}
.function-content-new {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
[data-smooth-updating] {
position: relative;
}
/* Optimized function history panel */
.function-history-panel {
margin-top: 10px;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--border-radius-sm);
font-size: 0.9em;
contain: layout style;
}
/* Consolidated history panel theme styles */
.function-block.theme-light .function-history-panel,
.function-block:not(.theme-dark) .function-history-panel {
background-color: var(--light-surface-tertiary);
border: 1px solid #dadce0;
color: var(--light-text);
}
.function-block.theme-dark .function-history-panel {
background-color: var(--dark-surface);
border: 1px solid var(--light-text-secondary);
color: var(--dark-text);
}
.function-history-header {
font-weight: bold;
margin-bottom: 5px;
}
.function-execution-info {
margin-bottom: var(--spacing-sm);
line-height: 1.4;
}
/* Base re-execute button styles */
.function-reexecute-button {
border: none;
padding: var(--spacing-sm) var(--spacing-lg);
border-radius: var(--border-radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-normal);
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
display: flex;
align-items: center;
justify-content: center;
will-change: transform, box-shadow;
contain: layout style;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
min-height: 32px;
min-width: 80px;
}
.function-reexecute-button:active {
transform: translateY(1px) translate3d(0,0,0);
box-shadow: 0 0 1px rgba(0,0,0,0.12);
}
/* Consolidated re-execute button theme styles */
.function-block.theme-light .function-reexecute-button,
.function-block:not(.theme-dark) .function-reexecute-button {
background: var(--light-primary);
color: white;
background-image: linear-gradient(to bottom, var(--light-primary), var(--light-primary-hover));
}
.function-block.theme-light .function-reexecute-button:hover,
.function-block:not(.theme-dark) .function-reexecute-button:hover {
background: var(--light-primary-hover);
background-image: linear-gradient(to bottom, var(--light-primary-hover), #1765cc);
}
.function-block.theme-dark .function-reexecute-button {
background: var(--dark-primary);
color: var(--light-text);
background-image: linear-gradient(to bottom, var(--dark-primary), var(--dark-primary-hover));
}
.function-block.theme-dark .function-reexecute-button:hover {
background: var(--dark-primary-hover);
background-image: linear-gradient(to bottom, var(--dark-primary-hover), #6ca0e8);
}
`;
import type { ParamValueElement } from '../core/types';
import { createLogger } from '@extension/shared/lib/logger';
/**
* Create a Trusted Type policy if available in the browser
*/
const logger = createLogger('DOMUtils');
let scriptSanitizerPolicy: any | null = null;
if (typeof window !== 'undefined' && (window as any).trustedTypes && (window as any).trustedTypes.createPolicy) {
try {
scriptSanitizerPolicy = (window as any).trustedTypes.createPolicy('scriptSanitizerPolicy', {
createHTML: (input: string) => input,
});
} catch (e) {
// Policy might already exist or creation failed
if ((window as any).trustedTypes && (window as any).trustedTypes.policies) {
scriptSanitizerPolicy = (window as any).trustedTypes.policies.get('scriptSanitizerPolicy') || null;
}
if (!scriptSanitizerPolicy && console) {
logger.warn('Could not create or retrieve Trusted Types policy "scriptSanitizerPolicy".', e);
}
}
}
/**
* Decode HTML entities in a string
*/
export const decodeHtml = (html: string): string => {
const txt = document.createElement('textarea');
// Use Trusted Types policy if available and successfully created
if (scriptSanitizerPolicy) {
// Assign TrustedHTML directly to innerHTML
txt.innerHTML = scriptSanitizerPolicy.createHTML(html);
} else if (typeof window !== 'undefined' && !(window as any).trustedTypes) {
// Fallback ONLY if Trusted Types are not supported/enforced
txt.innerHTML = html;
} else {
// If Trusted Types exist but policy creation failed, avoid innerHTML and log error
logger.error('Trusted Types are enforced, but the policy creation failed. Cannot set innerHTML for decoding.');
// Return the original string or a sanitized version, depending on requirements
return html;
}
return txt.value;
};
/**
* Format osascript commands for better readability
*/
export const formatOsascript = (cmd: string): string => {
return cmd.replace(/\s-e\s'/g, "\n -e '").replace(/osascript/, 'osascript\n ');
};
/**
* Safely set content of a DOM element
*/
export const safelySetContent = (
element: ParamValueElement,
content: string | null | undefined,
isHtml = false,
): void => {
try {
content = content || ''; // Ensure content is not null/undefined
// Check if the element is meant for streaming, regardless of the parameter name
if (element.hasAttribute('data-streaming')) {
let preElement = element.querySelector<HTMLPreElement>('pre');
if (!preElement) {
// Clear existing content and create the <pre> structure safely
while (element.firstChild) {
element.removeChild(element.firstChild);
}
preElement = document.createElement('pre');
// Apply necessary styles for the <pre> element
preElement.style.fontFamily = "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace";
preElement.style.fontSize = '13px';
preElement.style.lineHeight = '1.5';
preElement.style.whiteSpace = 'pre-wrap';
preElement.style.backgroundColor = 'inherit';
preElement.style.color = 'inherit';
preElement.style.border = 'none';
preElement.style.margin = '0';
preElement.style.padding = '10px';
preElement.style.overflowX = 'auto';
preElement.style.overflowY = 'auto';
preElement.style.flex = '1';
preElement.style.minHeight = '30px'; // Ensure a minimum height
element.appendChild(preElement);
// Adjust the container element's styles
element.style.display = 'flex';
element.style.flexDirection = 'column';
element.style.padding = '0'; // Remove padding from container, apply to pre
element.style.overflow = 'hidden'; // Hide overflow on container
}
// Set the content inside the <pre> element using textContent (CSP-safe)
preElement.textContent = content;
element.setAttribute('data-rendered-length', String(content.length));
// Force scroll to bottom for streaming effect
const forceScrollToBottom = () => {
if (preElement && element) {
// Scroll the container, not the pre element directly for better control
element.scrollTop = element.scrollHeight;
}
};
// Use timeouts to ensure scrolling happens after rendering updates
setTimeout(forceScrollToBottom, 0);
setTimeout(forceScrollToBottom, 50);
setTimeout(forceScrollToBottom, 100);
} else {
// Standard non-streaming content setting - always use textContent for CSP safety
// Clear existing content first
while (element.firstChild) {
element.removeChild(element.firstChild);
}
// Set content using CSP-safe methods
const textNode = document.createTextNode(content);
element.appendChild(textNode);
element.removeAttribute('data-rendered-length');
// Remove potentially added styles if it was previously streaming
element.style.display = '';
element.style.flexDirection = '';
element.style.padding = '';
element.style.overflow = '';
// Remove the <pre> element if it exists from a previous streaming state
const existingPre = element.querySelector('pre');
if (existingPre) {
existingPre.remove();
}
}
} catch (e) {
logger.error('Error setting content:', e);
// Fallback: Ensure content is displayed even if an error occurs
while (element.firstChild) {
element.removeChild(element.firstChild);
}
element.appendChild(document.createTextNode(content || ''));
element.removeAttribute('data-rendered-length');
// Basic scroll attempt on error during streaming
if (element.hasAttribute('data-streaming')) {
setTimeout(() => {
element.scrollTop = element.scrollHeight;
}, 0);
}
}
};
// Re-export all utility functions
import { createLogger } from '@extension/shared/lib/logger';
const logger = createLogger('RenderPrescriptUtils');
export * from './dom';
export * from './performance';
export * from './themeDetector';
// Add a global utility for theme control that can be accessed from the console
if (typeof window !== 'undefined') {
(window as any).themeControl = {
forceLight: () => {
const { forceThemeMode, clearCachedTheme } = require('./themeDetector');
forceThemeMode('light');
logger.debug('Forced light theme. Refresh the page to see changes.');
},
forceDark: () => {
const { forceThemeMode, clearCachedTheme } = require('./themeDetector');
forceThemeMode('dark');
logger.debug('Forced dark theme. Refresh the page to see changes.');
},
useSystem: () => {
const { forceThemeMode, clearCachedTheme } = require('./themeDetector');
forceThemeMode('system');
logger.debug('Using system theme preference. Refresh the page to see changes.');
},
reset: () => {
const { clearCachedTheme } = require('./themeDetector');
clearCachedTheme();
logger.debug('Theme detection reset. Refresh the page to see changes.');
},
detect: () => {
const { detectHostTheme, isDarkTheme } = require('./themeDetector');
const theme = detectHostTheme();
const isDark = isDarkTheme();
logger.debug(`Detected theme: ${theme}`);
logger.debug(`Using ${isDark ? 'dark' : 'light'} theme`);
return { theme, isDark };
},
};
logger.debug('[Theme Detector] Global theme control available via window.themeControl');
}
export function createLogger(name: string) {
return {
debug: (...args: any[]) => console.debug(`[${name}]`, ...args),
info: (...args: any[]) => console.info(`[${name}]`, ...args),
warn: (...args: any[]) => console.warn(`[${name}]`, ...args),
error: (...args: any[]) => console.error(`[${name}]`, ...args)
};
}
/**
* Debounce a function to limit how often it can be called
* @param func The function to debounce
* @param wait Wait time in milliseconds
* @returns A debounced version of the function
*/
export const debounce = <T extends (...args: any[]) => any>(
func: T,
wait: number,
): ((...args: Parameters<T>) => void) => {
let timeout: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>): void => {
const later = () => {
timeout = null;
func(...args);
};
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
};
/**
* Utility functions for detecting the host website's theme
*/
import { CONFIG } from '../core/config';
import { renderedFunctionBlocks } from '../renderer/functionBlock';
import { createLogger } from '@extension/shared/lib/logger';
/**
* Theme detection result
*/
const logger = createLogger('ThemeDetector');
export type ThemeMode = 'light' | 'dark' | 'system';
// Store the detected theme to avoid recalculating
let cachedTheme: ThemeMode | null = null;
// Store the current theme state for comparison
let currentThemeState = {
bodyClasses: '',
htmlClasses: '',
bodyDataTheme: '',
htmlDataTheme: '',
bodyBgColor: '',
isDark: false,
};
// Callback registry for theme change listeners
type ThemeChangeCallback = (isDark: boolean) => void;
const themeChangeListeners: ThemeChangeCallback[] = [];
// Theme observer instance
let themeObserver: MutationObserver | null = null;
// Theme change detection delay (to avoid excessive updates)
const THEME_CHANGE_DELAY = 100; // ms
/**
* Logs theme detection information if debug is enabled
* @param message The message to log
* @param data Optional data to include in the log
*/
function logThemeDetection(message: string, data?: any): void {
if (CONFIG.debug) {
logger.debug(`${message}`, data || '');
}
}
/**
* Enhanced theme detection with scoring mechanism
* @returns Object with theme confidence scores
*/
function detectThemeWithScoring(): { theme: ThemeMode; confidence: number; scores: { dark: number; light: number } } {
let darkScore = 0;
let lightScore = 0;
// Weight factors for different detection methods
const weights = {
classNames: 8,
dataAttributes: 8,
metaTags: 6,
backgroundColors: 5,
cssVariables: 4,
textColors: 3,
systemColors: 2,
urlPatterns: 1,
};
try {
const bodyEl = document.body;
const htmlEl = document.documentElement;
if (!bodyEl || !htmlEl) {
return { theme: 'system', confidence: 0, scores: { dark: 0, light: 0 } };
}
// 1. Enhanced class name detection
const bodyClasses = bodyEl.className.toLowerCase() || '';
const htmlClasses = htmlEl.className.toLowerCase() || '';
const allClasses = bodyClasses + ' ' + htmlClasses;
const darkClassPatterns = [
'dark-theme',
'theme-dark',
'dark-mode',
'dark',
'night-mode',
'nightmode',
'black-theme',
'theme-black',
'noir',
'midnight',
'shadow',
'carbon',
'slate',
'charcoal',
'obsidian',
'dim',
'dusky',
'darker',
'darkened',
'invert',
'inverted',
'contrast-dark',
'scheme-dark',
'color-scheme-dark',
'theme-dark-mode',
'darktheme',
'dark_theme',
'dark_mode',
];
const lightClassPatterns = [
'light-theme',
'theme-light',
'light-mode',
'light',
'day-mode',
'daymode',
'white-theme',
'theme-white',
'bright',
'default-theme',
'normal',
'classic',
'standard',
'vanilla',
'clean',
'minimal',
'bright-mode',
'contrast-light',
'scheme-light',
'color-scheme-light',
'theme-light-mode',
'lighttheme',
'light_theme',
'light_mode',
];
const darkClassMatches = darkClassPatterns.filter(pattern => new RegExp(`\\b${pattern}\\b`, 'i').test(allClasses));
const lightClassMatches = lightClassPatterns.filter(pattern =>
new RegExp(`\\b${pattern}\\b`, 'i').test(allClasses),
);
if (darkClassMatches.length > 0) {
darkScore += weights.classNames * darkClassMatches.length;
logThemeDetection('Dark class patterns found', darkClassMatches);
}
if (lightClassMatches.length > 0) {
lightScore += weights.classNames * lightClassMatches.length;
logThemeDetection('Light class patterns found', lightClassMatches);
}
// 2. Data attributes detection
const themeDataAttrs = [
bodyEl.getAttribute('data-theme'),
htmlEl.getAttribute('data-theme'),
bodyEl.getAttribute('data-color-scheme'),
htmlEl.getAttribute('data-color-scheme'),
bodyEl.getAttribute('data-color-mode'),
htmlEl.getAttribute('data-color-mode'),
bodyEl.getAttribute('data-bs-theme'), // Bootstrap theme
htmlEl.getAttribute('data-bs-theme'),
].filter(Boolean);
themeDataAttrs.forEach(attr => {
const attrValue = attr?.toLowerCase();
if (attrValue?.includes('dark')) darkScore += weights.dataAttributes;
if (attrValue?.includes('light')) lightScore += weights.dataAttributes;
});
// 3. Meta tags detection
const metaTags = document.querySelectorAll('meta[name*="theme"], meta[name*="color-scheme"]');
metaTags.forEach(meta => {
const content = meta.getAttribute('content')?.toLowerCase();
if (content?.includes('dark')) darkScore += weights.metaTags;
if (content?.includes('light')) lightScore += weights.metaTags;
});
// 4. Enhanced background color analysis
const elementsToAnalyze = [
bodyEl,
htmlEl,
...Array.from(document.querySelectorAll('main, [role="main"], .main-content, #main, #content, .content')).slice(
0,
3,
),
...Array.from(document.querySelectorAll('.app, #app, .wrapper, .container, .page, .layout')).slice(0, 3),
...Array.from(document.querySelectorAll('header, nav, .header, .navbar')).slice(0, 2),
];
const backgroundAnalysis = { darkCount: 0, lightCount: 0, totalAnalyzed: 0 };
for (const element of elementsToAnalyze) {
if (!element) continue;
try {
const style = window.getComputedStyle(element);
const bgColor = style.backgroundColor;
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
const brightness = getColorBrightness(bgColor);
if (brightness !== null) {
backgroundAnalysis.totalAnalyzed++;
if (brightness < 120) {
backgroundAnalysis.darkCount++;
} else if (brightness > 180) {
backgroundAnalysis.lightCount++;
}
}
}
} catch (error) {
// Continue with other elements
}
}
if (backgroundAnalysis.totalAnalyzed > 0) {
const darkRatio = backgroundAnalysis.darkCount / backgroundAnalysis.totalAnalyzed;
const lightRatio = backgroundAnalysis.lightCount / backgroundAnalysis.totalAnalyzed;
darkScore += weights.backgroundColors * darkRatio * 3;
lightScore += weights.backgroundColors * lightRatio * 3;
}
// 5. Enhanced CSS variables analysis
const cssVarsToCheck = [
'--background-color',
'--bg-color',
'--background',
'--bg',
'--color-bg',
'--color-background',
'--theme-bg',
'--primary-bg',
'--surface',
'--surface-color',
'--base-color',
'--canvas',
'--page-bg',
'--body-bg',
'--main-bg',
'--content-bg',
];
const cssVarAnalysis = { darkCount: 0, lightCount: 0 };
cssVarsToCheck.forEach(varName => {
const value = getComputedStyle(document.documentElement).getPropertyValue(varName);
if (value) {
const brightness = getColorBrightness(value.trim());
if (brightness !== null) {
if (brightness < 120) cssVarAnalysis.darkCount++;
else if (brightness > 180) cssVarAnalysis.lightCount++;
}
}
});
if (cssVarAnalysis.darkCount > 0) darkScore += weights.cssVariables * cssVarAnalysis.darkCount;
if (cssVarAnalysis.lightCount > 0) lightScore += weights.cssVariables * cssVarAnalysis.lightCount;
// 6. Text color analysis
const textElements = [
document.querySelector('h1, h2, h3'),
document.querySelector('p'),
document.querySelector('a'),
document.querySelector('.content, article, section'),
].filter(Boolean);
const textAnalysis = { lightTextCount: 0, darkTextCount: 0 };
textElements.forEach(element => {
try {
const color = window.getComputedStyle(element as HTMLElement).color;
const brightness = getColorBrightness(color);
if (brightness !== null) {
if (brightness > 180) textAnalysis.lightTextCount++;
else if (brightness < 100) textAnalysis.darkTextCount++;
}
} catch (error) {
// Continue with other elements
}
});
// Light text suggests dark background
if (textAnalysis.lightTextCount > textAnalysis.darkTextCount) {
darkScore += weights.textColors * textAnalysis.lightTextCount;
} else if (textAnalysis.darkTextCount > textAnalysis.lightTextCount) {
lightScore += weights.textColors * textAnalysis.darkTextCount;
}
// 7. System canvas colors (as fallback)
try {
const testDiv = document.createElement('div');
testDiv.style.cssText = 'position:absolute;top:-9999px;background-color:canvas;color:canvastext;';
document.body.appendChild(testDiv);
const canvasBg = window.getComputedStyle(testDiv).backgroundColor;
const brightness = getColorBrightness(canvasBg);
document.body.removeChild(testDiv);
if (brightness !== null) {
if (brightness < 128) darkScore += weights.systemColors;
else lightScore += weights.systemColors;
}
} catch (error) {
// Ignore errors
}
// 8. Website-specific patterns
const currentHost = window.location.hostname.toLowerCase();
const websitePatterns = {
dark: ['github.com', 'stackoverflow.com', 'reddit.com', 'discord.com', 'twitter.com'],
light: ['google.com', 'wikipedia.org', 'stackoverflow.com', 'reddit.com'],
};
// Check if current site commonly uses dark themes
if (websitePatterns.dark.some(site => currentHost.includes(site))) {
// Check for dark theme indicators in URL or page structure
const url = window.location.href.toLowerCase();
if (url.includes('dark') || document.querySelector('[data-theme*="dark"]')) {
darkScore += weights.urlPatterns;
}
}
} catch (error) {
logThemeDetection('Error in theme scoring', error);
}
// Calculate final theme and confidence
const totalScore = darkScore + lightScore;
const confidence = Math.min(totalScore / 20, 1); // Normalize to 0-1
let theme: ThemeMode;
if (totalScore === 0 || Math.abs(darkScore - lightScore) < 2) {
theme = 'system';
} else {
theme = darkScore > lightScore ? 'dark' : 'light';
}
logThemeDetection('Theme detection scoring complete', {
darkScore,
lightScore,
totalScore,
confidence,
finalTheme: theme,
});
return { theme, confidence, scores: { dark: darkScore, light: lightScore } };
}
/**
* Utility function to calculate color brightness from various color formats
* @param colorValue CSS color value (rgb, rgba, hex, hsl, etc.)
* @returns Brightness value (0-255) or null if parsing fails
*/
function getColorBrightness(colorValue: string): number | null {
if (!colorValue) return null;
const value = colorValue.trim();
// RGB/RGBA format
const rgbMatch = value.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (rgbMatch) {
const [_, r, g, b] = rgbMatch.map(Number);
return (r * 299 + g * 587 + b * 114) / 1000;
}
// Hex format
const hexMatch = value.match(/#([0-9a-f]{3,6})/i);
if (hexMatch) {
const hex = hexMatch[1];
let r, g, b;
if (hex.length === 3) {
r = parseInt(hex[0] + hex[0], 16);
g = parseInt(hex[1] + hex[1], 16);
b = parseInt(hex[2] + hex[2], 16);
} else {
r = parseInt(hex.substring(0, 2), 16);
g = parseInt(hex.substring(2, 4), 16);
b = parseInt(hex.substring(4, 6), 16);
}
if (!isNaN(r) && !isNaN(g) && !isNaN(b)) {
return (r * 299 + g * 587 + b * 114) / 1000;
}
}
// HSL format
const hslMatch = value.match(/hsla?\(\d+,\s*\d+%?,\s*(\d+)%?/i);
if (hslMatch) {
const lightness = parseInt(hslMatch[1], 10);
return (lightness / 100) * 255; // Convert percentage to 0-255 scale
}
return null;
}
/**
* Website-specific theme detection patterns
*/
function detectWebsiteSpecificTheme(): ThemeMode | null {
const hostname = window.location.hostname.toLowerCase();
const pathname = window.location.pathname.toLowerCase();
const search = window.location.search.toLowerCase();
// GitHub
if (hostname.includes('github.com')) {
// Check for dark theme indicators
if (
document.querySelector('[data-color-mode="dark"]') ||
document.body.getAttribute('data-color-mode') === 'dark' ||
document.documentElement.getAttribute('data-color-mode') === 'dark' ||
document.querySelector('[data-color-scheme="dark"]') ||
document.body.getAttribute('data-color-scheme') === 'dark' ||
document.documentElement.getAttribute('data-color-scheme') === 'dark'
) {
return 'dark';
}
if (
document.querySelector('[data-color-mode="light"]') ||
document.body.getAttribute('data-color-mode') === 'light' ||
document.documentElement.getAttribute('data-color-mode') === 'light' ||
document.querySelector('[data-color-scheme="light"]') ||
document.body.getAttribute('data-color-scheme') === 'light' ||
document.documentElement.getAttribute('data-color-scheme') === 'light'
) {
return 'light';
}
}
// Reddit
if (hostname.includes('reddit.com')) {
if (document.querySelector('[data-theme="dark"]') || document.documentElement.className.includes('theme-dark')) {
return 'dark';
}
}
// Discord
if (hostname.includes('discord.com')) {
if (document.querySelector('[class*="theme-dark"]') || document.body.className.includes('theme-dark')) {
return 'dark';
}
}
// Twitter/X
if (hostname.includes('twitter.com') || hostname.includes('x.com')) {
if (document.querySelector('[data-theme="dark"]') || document.documentElement.style.colorScheme === 'dark') {
return 'dark';
}
}
// YouTube
if (hostname.includes('youtube.com')) {
if (document.querySelector('[dark]') || document.documentElement.getAttribute('dark') !== null) {
return 'dark';
}
}
// Stack Overflow
if (hostname.includes('stackoverflow.com')) {
if (document.querySelector('.theme-dark') || document.body.className.includes('theme-dark')) {
return 'dark';
}
}
return null;
}
/**
* Main theme detection function with multiple strategies
* @returns The detected theme mode
*/
export function detectTheme(): ThemeMode {
if (cachedTheme) {
logThemeDetection('Returning cached theme', cachedTheme);
return cachedTheme;
}
logThemeDetection('Starting theme detection');
try {
const bodyEl = document.body;
const htmlEl = document.documentElement;
if (!bodyEl || !htmlEl) {
logThemeDetection('Body or HTML element not found, defaulting to system');
cachedTheme = 'system';
return cachedTheme;
}
// Strategy 1: Check website-specific patterns first (highest priority)
const websiteTheme = detectWebsiteSpecificTheme();
if (websiteTheme) {
logThemeDetection('Theme detected from website-specific patterns', websiteTheme);
cachedTheme = websiteTheme;
return cachedTheme;
}
// Strategy 2: Use scoring mechanism for comprehensive analysis
const scoringResult = detectThemeWithScoring();
if (scoringResult.confidence > 0.3) {
// Only use if we have reasonable confidence
logThemeDetection('Theme detected from scoring mechanism', scoringResult);
cachedTheme = scoringResult.theme;
return cachedTheme;
}
// Strategy 3: Legacy detection methods as fallback
// Check for prefers-color-scheme in CSS media queries
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
logThemeDetection('Dark theme detected from prefers-color-scheme');
cachedTheme = 'dark';
return cachedTheme;
}
// Check data attributes with expanded patterns
const themeDataAttrs = [
bodyEl.getAttribute('data-theme'),
htmlEl.getAttribute('data-theme'),
bodyEl.getAttribute('data-color-mode'),
htmlEl.getAttribute('data-color-mode'),
bodyEl.getAttribute('data-color-scheme'),
htmlEl.getAttribute('data-color-scheme'),
bodyEl.getAttribute('data-bs-theme'), // Bootstrap theme
htmlEl.getAttribute('data-bs-theme'),
bodyEl.getAttribute('theme'),
htmlEl.getAttribute('theme'),
];
logThemeDetection('Checking data attributes', themeDataAttrs);
for (const attr of themeDataAttrs) {
if (attr) {
const attrValue = attr.toLowerCase();
if (attrValue.includes('dark') || attrValue === 'dark') {
logThemeDetection('Dark theme detected from data attributes', attr);
cachedTheme = 'dark';
return cachedTheme;
}
if (attrValue.includes('light') || attrValue === 'light') {
logThemeDetection('Light theme detected from data attributes', attr);
cachedTheme = 'light';
return cachedTheme;
}
}
}
// Check for meta tags
const metaTags = document.querySelectorAll(
'meta[name*="theme"], meta[name*="color-scheme"], meta[name*="theme-color"]',
);
for (const meta of metaTags) {
const content = meta.getAttribute('content')?.toLowerCase();
if (content?.includes('dark')) {
logThemeDetection('Dark theme detected from meta tags');
cachedTheme = 'dark';
return cachedTheme;
}
if (content?.includes('light')) {
logThemeDetection('Light theme detected from meta tags');
cachedTheme = 'light';
return cachedTheme;
}
}
// Enhanced class name detection with more patterns
const bodyClasses = bodyEl.className.toLowerCase() || '';
const htmlClasses = htmlEl.className.toLowerCase() || '';
const allClasses = `${bodyClasses} ${htmlClasses}`;
logThemeDetection('Checking class names', { bodyClasses, htmlClasses });
// Extended dark theme patterns
const darkClassPatterns = [
'dark-theme',
'theme-dark',
'dark-mode',
'dark',
'night-mode',
'nightmode',
'black-theme',
'theme-black',
'noir',
'midnight',
'shadow',
'carbon',
'slate',
'charcoal',
'obsidian',
'dim',
'dusky',
'darker',
'darkened',
'invert',
'inverted',
'contrast-dark',
'scheme-dark',
'color-scheme-dark',
'theme-dark-mode',
'darktheme',
'dark_theme',
'dark_mode',
'mode-dark',
'is-dark',
'has-dark-theme',
];
const lightClassPatterns = [
'light-theme',
'theme-light',
'light-mode',
'light',
'day-mode',
'daymode',
'white-theme',
'theme-white',
'bright',
'default-theme',
'normal',
'classic',
'standard',
'vanilla',
'clean',
'minimal',
'bright-mode',
'contrast-light',
'scheme-light',
'color-scheme-light',
'theme-light-mode',
'lighttheme',
'light_theme',
'light_mode',
'mode-light',
'is-light',
'has-light-theme',
];
// Use word boundaries for more precise matching
const hasDarkClass = darkClassPatterns.some(pattern => new RegExp(`\\b${pattern}\\b`, 'i').test(allClasses));
const hasLightClass = lightClassPatterns.some(pattern => new RegExp(`\\b${pattern}\\b`, 'i').test(allClasses));
if (hasDarkClass) {
logThemeDetection('Dark theme detected from class names');
cachedTheme = 'dark';
return cachedTheme;
}
if (hasLightClass) {
logThemeDetection('Light theme detected from class names');
cachedTheme = 'light';
return cachedTheme;
}
// Enhanced background color analysis with multiple elements
const elementsToCheck = [
{ element: bodyEl, name: 'body' },
{ element: htmlEl, name: 'html' },
// Check main content areas
...Array.from(document.querySelectorAll('main, [role="main"], .main-content, #main, #content, .content'))
.slice(0, 3)
.map((el, i) => ({ element: el as HTMLElement, name: `main-${i}` })),
// Check common wrapper elements
...Array.from(document.querySelectorAll('.app, #app, .wrapper, .container, .page, .layout'))
.slice(0, 2)
.map((el, i) => ({ element: el as HTMLElement, name: `wrapper-${i}` })),
// Check header/navigation
...Array.from(document.querySelectorAll('header, nav, .header, .navbar'))
.slice(0, 2)
.map((el, i) => ({ element: el as HTMLElement, name: `nav-${i}` })),
];
const backgroundAnalysisResults: Array<{ name: string; brightness: number; isDark: boolean }> = [];
for (const { element, name } of elementsToCheck) {
try {
if (!element) continue;
const computedStyle = window.getComputedStyle(element);
const bgColor = computedStyle.backgroundColor;
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
const brightness = getColorBrightness(bgColor);
if (brightness !== null) {
const isDark = brightness < 120; // More strict threshold
backgroundAnalysisResults.push({ name, brightness, isDark });
logThemeDetection(`Background analysis for ${name}`, { bgColor, brightness, isDark });
}
}
} catch (error) {
logThemeDetection(`Error analyzing background color for ${name}`, error);
}
}
// Analyze results - if majority of elements suggest dark theme
if (backgroundAnalysisResults.length > 0) {
const darkCount = backgroundAnalysisResults.filter(result => result.isDark).length;
const lightCount = backgroundAnalysisResults.length - darkCount;
logThemeDetection('Background analysis summary', {
total: backgroundAnalysisResults.length,
dark: darkCount,
light: lightCount,
results: backgroundAnalysisResults,
});
if (darkCount > lightCount) {
logThemeDetection('Dark theme detected from background color analysis');
cachedTheme = 'dark';
return cachedTheme;
} else if (lightCount > darkCount) {
logThemeDetection('Light theme detected from background color analysis');
cachedTheme = 'light';
return cachedTheme;
}
}
// Enhanced CSS variables analysis
const cssVarsToCheck = [
'--background-color',
'--bg-color',
'--background',
'--bg',
'--color-bg',
'--color-background',
'--theme-bg',
'--primary-bg',
'--surface',
'--surface-color',
'--base-color',
'--canvas',
'--page-bg',
'--body-bg',
'--main-bg',
'--content-bg',
'--theme-background',
'--app-background',
'--global-background',
];
logThemeDetection('Checking CSS variables', cssVarsToCheck);
for (const varName of cssVarsToCheck) {
const value = getComputedStyle(document.documentElement).getPropertyValue(varName);
if (value) {
const colorValue = value.trim();
const brightness = getColorBrightness(colorValue);
if (brightness !== null) {
logThemeDetection(`CSS variable ${varName} brightness`, { colorValue, brightness });
if (brightness < 120) {
logThemeDetection('Dark theme detected from CSS variables');
cachedTheme = 'dark';
return cachedTheme;
} else if (brightness > 180) {
logThemeDetection('Light theme detected from CSS variables');
cachedTheme = 'light';
return cachedTheme;
}
}
}
}
// Enhanced text color contrast analysis
const textElements = [
document.querySelector('h1, h2, h3'),
document.querySelector('p'),
document.querySelector('a'),
document.querySelector('.content, article, section'),
document.querySelector('span'),
document.querySelector('div'),
].filter(Boolean) as HTMLElement[];
let lightTextCount = 0;
let darkTextCount = 0;
for (const element of textElements.slice(0, 5)) {
try {
const computedStyle = window.getComputedStyle(element);
const textColor = computedStyle.color;
if (textColor && textColor !== 'rgba(0, 0, 0, 0)') {
const brightness = getColorBrightness(textColor);
if (brightness !== null) {
if (brightness > 180) {
lightTextCount++; // Light text suggests dark background
} else if (brightness < 100) {
darkTextCount++; // Dark text suggests light background
}
logThemeDetection(`Text color analysis for ${element.tagName}`, {
textColor,
brightness,
classification: brightness > 180 ? 'light-text' : brightness < 100 ? 'dark-text' : 'neutral',
});
}
}
} catch (error) {
logThemeDetection(`Error analyzing text color for ${element.tagName}`, error);
}
}
logThemeDetection('Text color analysis summary', { lightTextCount, darkTextCount });
// If we have significantly more light text, it's probably a dark theme
if (lightTextCount > darkTextCount && lightTextCount >= 2) {
logThemeDetection('Dark theme detected from text color analysis');
cachedTheme = 'dark';
return cachedTheme;
} else if (darkTextCount > lightTextCount && darkTextCount >= 2) {
logThemeDetection('Light theme detected from text color analysis');
cachedTheme = 'light';
return cachedTheme;
}
// Final fallback: system canvas colors
try {
const testDiv = document.createElement('div');
testDiv.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
width: 1px;
height: 1px;
background-color: canvas;
color: canvastext;
`;
document.body.appendChild(testDiv);
const testStyle = window.getComputedStyle(testDiv);
const bgColor = testStyle.backgroundColor;
document.body.removeChild(testDiv);
if (bgColor) {
const brightness = getColorBrightness(bgColor);
if (brightness !== null) {
logThemeDetection('System canvas color analysis', { bgColor, brightness });
if (brightness < 128) {
logThemeDetection('Dark theme detected from system canvas colors');
cachedTheme = 'dark';
return cachedTheme;
} else {
logThemeDetection('Light theme detected from system canvas colors');
cachedTheme = 'light';
return cachedTheme;
}
}
}
} catch (error) {
logThemeDetection('Error in system color analysis', error);
}
} catch (error) {
logThemeDetection('Error in theme detection', error);
}
// Default to system if we can't determine
logThemeDetection('Could not determine theme, defaulting to system');
cachedTheme = 'system';
return cachedTheme;
}
/**
* Get the current theme state for change detection
*/
function getCurrentThemeState() {
const bodyEl = document.body;
const htmlEl = document.documentElement;
return {
bodyClasses: bodyEl?.className || '',
htmlClasses: htmlEl?.className || '',
bodyDataTheme: bodyEl?.getAttribute('data-theme') || '',
htmlDataTheme: htmlEl?.getAttribute('data-theme') || '',
bodyBgColor: bodyEl ? window.getComputedStyle(bodyEl).backgroundColor : '',
isDark: detectTheme() === 'dark',
};
}
/**
* Start monitoring for theme changes
*/
export function startThemeMonitoring(): void {
if (themeObserver) {
return; // Already monitoring
}
let debounceTimeout: number;
const checkThemeChange = () => {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => {
const newThemeState = getCurrentThemeState();
// Check if theme has actually changed
if (
newThemeState.isDark !== currentThemeState.isDark ||
newThemeState.bodyClasses !== currentThemeState.bodyClasses ||
newThemeState.htmlClasses !== currentThemeState.htmlClasses ||
newThemeState.bodyDataTheme !== currentThemeState.bodyDataTheme ||
newThemeState.htmlDataTheme !== currentThemeState.htmlDataTheme ||
newThemeState.bodyBgColor !== currentThemeState.bodyBgColor
) {
logThemeDetection('Theme change detected', {
old: currentThemeState,
new: newThemeState,
});
// Clear cached theme to force re-detection
cachedTheme = null;
// Update current state
currentThemeState = newThemeState;
// Notify listeners
themeChangeListeners.forEach(callback => {
try {
callback(newThemeState.isDark);
} catch (error) {
logThemeDetection('Error in theme change callback', error);
}
});
// Update all function blocks automatically
updateAllFunctionBlockThemes();
}
}, THEME_CHANGE_DELAY);
};
// Set up mutation observer for DOM changes
themeObserver = new MutationObserver(mutations => {
let shouldCheck = false;
for (const mutation of mutations) {
// Check for class changes
if (
mutation.type === 'attributes' &&
(mutation.attributeName === 'class' || mutation.attributeName?.startsWith('data-'))
) {
shouldCheck = true;
break;
}
// Check for style changes
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
shouldCheck = true;
break;
}
}
if (shouldCheck) {
checkThemeChange();
}
});
// Start observing
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme', 'data-color-mode', 'data-color-scheme', 'data-bs-theme', 'style'],
subtree: false,
});
themeObserver.observe(document.body, {
attributes: true,
attributeFilter: ['class', 'data-theme', 'data-color-mode', 'data-color-scheme', 'data-bs-theme', 'style'],
subtree: false,
});
// Listen for CSS media query changes
if (window.matchMedia) {
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const lightModeMediaQuery = window.matchMedia('(prefers-color-scheme: light)');
const handleMediaQueryChange = (e: MediaQueryListEvent) => {
logThemeDetection('Media query change detected', { matches: e.matches, media: e.media });
checkThemeChange();
};
darkModeMediaQuery.addEventListener('change', handleMediaQueryChange);
lightModeMediaQuery.addEventListener('change', handleMediaQueryChange);
// Store references for cleanup
(window as any)._themeMediaQueryListeners = {
dark: { query: darkModeMediaQuery, handler: handleMediaQueryChange },
light: { query: lightModeMediaQuery, handler: handleMediaQueryChange },
};
}
// Listen for storage events (for websites that sync theme via localStorage/sessionStorage)
const handleStorageChange = (e: StorageEvent) => {
if (
e.key &&
(e.key.includes('theme') || e.key.includes('dark') || e.key.includes('light') || e.key.includes('color'))
) {
logThemeDetection('Storage change detected for theme-related key', { key: e.key, newValue: e.newValue });
checkThemeChange();
}
};
window.addEventListener('storage', handleStorageChange);
(window as any)._themeStorageListener = handleStorageChange;
// Listen for custom theme change events that websites might dispatch
const customThemeEvents = [
'themechange',
'theme-change',
'colorschemechange',
'color-scheme-change',
'darkmodechange',
'dark-mode-change',
'lightmodechange',
'light-mode-change',
];
const handleCustomThemeEvent = (e: Event) => {
logThemeDetection('Custom theme event detected', { type: e.type, detail: (e as CustomEvent).detail });
checkThemeChange();
};
customThemeEvents.forEach(eventType => {
document.addEventListener(eventType, handleCustomThemeEvent);
window.addEventListener(eventType, handleCustomThemeEvent);
});
(window as any)._themeCustomEventListeners = {
handler: handleCustomThemeEvent,
events: customThemeEvents,
};
// Watch for CSS variable changes using ResizeObserver trick
if (window.ResizeObserver) {
const themeVariableWatcher = document.createElement('div');
themeVariableWatcher.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
width: 1px;
height: 1px;
background: var(--background-color, var(--bg-color, var(--background, transparent)));
color: var(--text-color, var(--color, inherit));
pointer-events: none;
`;
document.body.appendChild(themeVariableWatcher);
const lastComputedStyle = window.getComputedStyle(themeVariableWatcher);
let lastBgColor = lastComputedStyle.backgroundColor;
let lastTextColor = lastComputedStyle.color;
const variableObserver = new ResizeObserver(() => {
const currentStyle = window.getComputedStyle(themeVariableWatcher);
const currentBgColor = currentStyle.backgroundColor;
const currentTextColor = currentStyle.color;
if (currentBgColor !== lastBgColor || currentTextColor !== lastTextColor) {
logThemeDetection('CSS variable change detected', {
bgColor: { old: lastBgColor, new: currentBgColor },
textColor: { old: lastTextColor, new: currentTextColor },
});
lastBgColor = currentBgColor;
lastTextColor = currentTextColor;
checkThemeChange();
}
});
// Trigger observation by changing a property
const triggerObservation = () => {
themeVariableWatcher.style.width = themeVariableWatcher.style.width === '1px' ? '2px' : '1px';
};
setInterval(triggerObservation, 1000); // Check every second
variableObserver.observe(themeVariableWatcher);
(window as any)._themeVariableWatcher = {
element: themeVariableWatcher,
observer: variableObserver,
};
}
// Initialize current state
currentThemeState = getCurrentThemeState();
logThemeDetection('Enhanced theme monitoring started with multiple detection methods');
}
/**
* Stop monitoring for theme changes
*/
export function stopThemeMonitoring(): void {
if (themeObserver) {
themeObserver.disconnect();
themeObserver = null;
}
// Clean up media query listeners
const mediaQueryListeners = (window as any)._themeMediaQueryListeners;
if (mediaQueryListeners) {
mediaQueryListeners.dark?.query?.removeEventListener('change', mediaQueryListeners.dark.handler);
mediaQueryListeners.light?.query?.removeEventListener('change', mediaQueryListeners.light.handler);
delete (window as any)._themeMediaQueryListeners;
}
// Clean up storage listener
const storageListener = (window as any)._themeStorageListener;
if (storageListener) {
window.removeEventListener('storage', storageListener);
delete (window as any)._themeStorageListener;
}
// Clean up custom event listeners
const customEventListeners = (window as any)._themeCustomEventListeners;
if (customEventListeners) {
customEventListeners.events.forEach((eventType: string) => {
document.removeEventListener(eventType, customEventListeners.handler);
window.removeEventListener(eventType, customEventListeners.handler);
});
delete (window as any)._themeCustomEventListeners;
}
// Clean up CSS variable watcher
const variableWatcher = (window as any)._themeVariableWatcher;
if (variableWatcher) {
variableWatcher.observer?.disconnect();
if (variableWatcher.element && variableWatcher.element.parentNode) {
variableWatcher.element.parentNode.removeChild(variableWatcher.element);
}
delete (window as any)._themeVariableWatcher;
}
logThemeDetection('Enhanced theme monitoring stopped and cleaned up');
}
/**
* Add a callback for theme changes
*/
export function addThemeChangeListener(callback: ThemeChangeCallback): void {
themeChangeListeners.push(callback);
}
/**
* Remove a callback for theme changes
*/
export function removeThemeChangeListener(callback: ThemeChangeCallback): void {
const index = themeChangeListeners.indexOf(callback);
if (index > -1) {
themeChangeListeners.splice(index, 1);
}
}
/**
* Force theme re-detection
*/
export function forceThemeRedetection(): ThemeMode {
cachedTheme = null;
return detectTheme();
}
/**
* Get the current detected theme with confidence score
*/
export function getThemeWithConfidence(): { theme: ThemeMode; confidence: number } {
const scoringResult = detectThemeWithScoring();
return { theme: scoringResult.theme, confidence: scoringResult.confidence };
}
/**
* Check if the current theme is dark
* @returns true if the current theme is dark, false otherwise
*/
export function isDarkTheme(): boolean {
const theme = detectTheme();
return theme === 'dark';
}
/**
* Apply theme class to an element based on detected theme
* @param element The element to apply theme class to
*/
export function applyThemeClass(element: HTMLElement): void {
const theme = detectTheme();
// Remove existing theme classes
element.classList.remove('theme-light', 'theme-dark', 'theme-system');
// Apply appropriate theme class
element.classList.add(`theme-${theme}`);
// Set data attribute for CSS targeting
element.setAttribute('data-theme', theme);
logThemeDetection(`Applied theme class: theme-${theme}`, { element: element.className });
}
/**
* Update theme for all rendered function blocks
*/
export function updateAllFunctionBlockThemes(): void {
if (renderedFunctionBlocks && renderedFunctionBlocks.size > 0) {
renderedFunctionBlocks.forEach((block, key) => {
applyThemeClass(block);
});
logThemeDetection(`Updated theme for ${renderedFunctionBlocks.size} function blocks`);
}
}
/**
* Initialize automatic theme detection and monitoring
* This function should be called when the page loads to start monitoring
*/
export function initializeThemeDetection(): void {
// Start theme monitoring
startThemeMonitoring();
// Force initial theme detection
forceThemeRedetection();
logThemeDetection('Theme detection initialized');
}
// Auto-initialize when DOM is ready
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeThemeDetection);
} else {
// DOM is already ready
initializeThemeDetection();
}
}
/**
* MCP Client Service for Anything Copilot
* Connects to MCP SuperAssistant Proxy via SSE
*/
export interface McpTool {
name: string
description: string
inputSchema?: Record<string, any>
}
export interface McpExecutionResult {
id: string
toolName: string
args: Record<string, any>
status: "pending" | "success" | "error"
result?: any
error?: string
timestamp: number
duration?: number
}
export type McpConnectionStatus = "disconnected" | "connecting" | "connected" | "error"
type StatusCallback = (status: McpConnectionStatus) => void
type ToolsCallback = (tools: McpTool[]) => void
type ExecutionCallback = (result: McpExecutionResult) => void
export class McpService {
private serverUrl: string
private abortController: AbortController | null = null
private sessionId: string | null = null
private status: McpConnectionStatus = "disconnected"
private tools: McpTool[] = []
private executions: McpExecutionResult[] = []
private statusListeners: StatusCallback[] = []
private toolsListeners: ToolsCallback[] = []
private executionListeners: ExecutionCallback[] = []
private pendingRequests = new Map<
string | number,
{ resolve: (value: any) => void; reject: (error: any) => void }
>()
private requestId = 0
// n8n credentials loaded from storage
private n8nApiUrl: string = ""
private n8nApiKey: string = ""
private mcpAuthToken: string = ""
private static readonly STORAGE_KEY = "mcp_n8n_config"
constructor(serverUrl = "http://localhost:3006") {
this.serverUrl = serverUrl
}
/** Load config from chrome.storage.local (keys saved by McpSettings.vue) */
async loadConfig(): Promise<void> {
try {
const data = await chrome.storage.local.get(McpService.STORAGE_KEY)
const cfg = data[McpService.STORAGE_KEY]
if (cfg) {
if (cfg.proxyUrl) this.serverUrl = cfg.proxyUrl
if (cfg.n8nApiUrl) this.n8nApiUrl = cfg.n8nApiUrl
if (cfg.n8nApiKey) this.n8nApiKey = cfg.n8nApiKey
if (cfg.mcpAuthToken !== undefined) this.mcpAuthToken = cfg.mcpAuthToken
console.log("[McpService] Loaded config from storage:", {
proxyUrl: this.serverUrl,
n8nApiUrl: this.n8nApiUrl,
hasKey: !!this.n8nApiKey,
hasAuthToken: !!this.mcpAuthToken,
})
} else {
console.warn("[McpService] No saved config found — configure in sidebar settings")
}
} catch (e) {
console.warn("[McpService] Could not load config:", e)
}
}
getN8nCredentials() {
return { n8nApiUrl: this.n8nApiUrl, n8nApiKey: this.n8nApiKey }
}
// --- Event Subscription ---
onStatusChange(callback: StatusCallback) {
this.statusListeners.push(callback)
return () => {
this.statusListeners = this.statusListeners.filter((cb) => cb !== callback)
}
}
onToolsUpdate(callback: ToolsCallback) {
this.toolsListeners.push(callback)
return () => {
this.toolsListeners = this.toolsListeners.filter((cb) => cb !== callback)
}
}
onExecution(callback: ExecutionCallback) {
this.executionListeners.push(callback)
return () => {
this.executionListeners = this.executionListeners.filter((cb) => cb !== callback)
}
}
private setStatus(status: McpConnectionStatus) {
this.status = status
this.statusListeners.forEach((cb) => cb(status))
}
getStatus(): McpConnectionStatus {
return this.status
}
getTools(): McpTool[] {
return this.tools
}
getExecutions(): McpExecutionResult[] {
return this.executions
}
// --- Connection ---
async connect(): Promise<void> {
if (this.status === "connected" || this.status === "connecting") {
return
}
this.setStatus("connecting")
try {
// First check health
const healthOk = await this.checkHealth()
if (!healthOk) {
this.setStatus("error")
return
}
// Connect via custom fetch SSE
this.abortController = new AbortController()
const headers: Record<string, string> = {
"x-n8n-url": this.n8nApiUrl,
"x-n8n-key": this.n8nApiKey,
}
if (this.mcpAuthToken) {
headers["Authorization"] = `Bearer ${this.mcpAuthToken}`
}
fetch(`${this.serverUrl}/sse`, {
method: "GET",
headers,
signal: this.abortController.signal,
}).then(async (response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
console.log("[McpService] SSE connection opened")
this.setStatus("connected")
const reader = response.body?.getReader()
if (!reader) throw new Error("No reader")
const decoder = new TextDecoder()
let buffer = ""
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const messages = buffer.split(/\r?\n\r?\n/)
buffer = messages.pop() || ""
for (const msg of messages) {
let event = ""
let data = ""
for (const line of msg.split(/\r?\n/)) {
if (line.startsWith("event: ")) event = line.substring(7)
else if (line.startsWith("event:")) event = line.substring(6)
else if (line.startsWith("data: ")) data += line.substring(6)
else if (line.startsWith("data:")) data += line.substring(5)
}
if (event === "endpoint" && data) {
this.sessionId = data
console.log("[McpService] Got session endpoint:", data)
this.fetchTools()
} else if (data) {
try {
this.handleMessage(JSON.parse(data))
} catch (e) {}
}
}
}
} catch (e: any) {
if (e.name !== "AbortError") {
console.error("[McpService] SSE stream error:", e)
this.setStatus("error")
}
}
}).catch((error) => {
console.error("[McpService] SSE fetch failed:", error)
if (this.status !== "error") {
this.setStatus("error")
}
})
} catch (error) {
console.error("[McpService] Connection preamble failed:", error)
this.setStatus("error")
}
}
async disconnect(): Promise<void> {
if (this.abortController) {
this.abortController.abort()
this.abortController = null
}
this.sessionId = null
this.setStatus("disconnected")
}
async checkHealth(): Promise<boolean> {
try {
const headers: Record<string, string> = {}
if (this.mcpAuthToken) {
headers["Authorization"] = `Bearer ${this.mcpAuthToken}`
}
const response = await fetch(`${this.serverUrl}/health`, {
method: "GET",
headers,
signal: AbortSignal.timeout(5000),
})
return response.ok
} catch {
return false
}
}
// --- JSON-RPC Messaging ---
private async sendRequest(method: string, params?: any): Promise<any> {
const id = ++this.requestId
const message = {
jsonrpc: "2.0",
id,
method,
params: params || {},
}
// Determine the endpoint to send to
let endpoint = `${this.serverUrl}/message`
if (this.sessionId) {
// If sessionId is a full URL path, use it
if (this.sessionId.startsWith("/") || this.sessionId.startsWith("http")) {
endpoint = this.sessionId.startsWith("http")
? this.sessionId
: `${this.serverUrl}${this.sessionId}`
}
}
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject })
// Set timeout
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id)
reject(new Error("Request timeout"))
}
}, 30000)
const headers: Record<string, string> = { "Content-Type": "application/json" }
if (this.mcpAuthToken) {
headers["Authorization"] = `Bearer ${this.mcpAuthToken}`
}
fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(message),
}).catch((error) => {
this.pendingRequests.delete(id)
reject(error)
})
})
}
private handleMessage(data: any) {
if (data.id && this.pendingRequests.has(data.id)) {
const { resolve, reject } = this.pendingRequests.get(data.id)!
this.pendingRequests.delete(data.id)
if (data.error) {
reject(data.error)
} else {
resolve(data.result)
}
}
}
// --- Tool Operations ---
async fetchTools(): Promise<McpTool[]> {
try {
const result = await this.sendRequest("tools/list")
if (result && result.tools) {
this.tools = result.tools.map((t: any) => ({
name: t.name,
description: t.description || "",
inputSchema: t.inputSchema,
}))
this.toolsListeners.forEach((cb) => cb(this.tools))
}
return this.tools
} catch (error) {
console.error("[McpService] Failed to fetch tools:", error)
// If JSON-RPC doesn't work, try REST API
try {
const headers: Record<string, string> = {}
if (this.mcpAuthToken) {
headers["Authorization"] = `Bearer ${this.mcpAuthToken}`
}
const response = await fetch(`${this.serverUrl}/tools`, {
headers,
signal: AbortSignal.timeout(10000),
})
if (response.ok) {
const data = await response.json()
const toolsList = data.tools || data || []
this.tools = toolsList.map((t: any) => ({
name: t.name,
description: t.description || "",
inputSchema: t.inputSchema || t.input_schema || {},
}))
this.toolsListeners.forEach((cb) => cb(this.tools))
}
} catch (e) {
console.error("[McpService] REST fallback also failed:", e)
}
return this.tools
}
}
async callTool(toolName: string, args: Record<string, any> = {}): Promise<McpExecutionResult> {
const execution: McpExecutionResult = {
id: `exec_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
toolName,
args,
status: "pending",
timestamp: Date.now(),
}
this.executions.unshift(execution)
this.executionListeners.forEach((cb) => cb(execution))
const startTime = Date.now()
try {
const result = await this.sendRequest("tools/call", {
name: toolName,
arguments: args,
})
execution.status = "success"
execution.result = result
execution.duration = Date.now() - startTime
this.executionListeners.forEach((cb) => cb(execution))
return execution
} catch (error: any) {
execution.status = "error"
execution.error = error?.message || String(error)
execution.duration = Date.now() - startTime
// Try REST fallback
try {
const headers: Record<string, string> = { "Content-Type": "application/json" }
if (this.mcpAuthToken) {
headers["Authorization"] = `Bearer ${this.mcpAuthToken}`
}
const response = await fetch(`${this.serverUrl}/call-tool`, {
method: "POST",
headers,
body: JSON.stringify({ name: toolName, arguments: args }),
signal: AbortSignal.timeout(30000),
})
if (response.ok) {
const data = await response.json()
execution.status = "success"
execution.result = data
execution.error = undefined
execution.duration = Date.now() - startTime
}
} catch (e) {
// Keep original error
}
this.executionListeners.forEach((cb) => cb(execution))
return execution
}
}
clearExecutions(): void {
this.executions = []
}
}
// Singleton instance
let _instance: McpService | null = null
export function getMcpService(url?: string): McpService {
if (!_instance) {
_instance = new McpService(url)
}
return _instance
}
......@@ -14,6 +14,10 @@ export enum MessageType {
showChatDocs = "show-chat-docs",
openInSidebar = "open-in-sidebar",
registerContentSidebar = "register-content-sidebar",
mcpExecuteRequest = "mcp-execute-request",
mcpExecuteResponse = "mcp-execute-response",
mcpGetToolsRequest = "mcp-get-tools-request",
mcpGetToolsResponse = "mcp-get-tools-response",
}
export enum ServiceFunc {
......@@ -31,6 +35,8 @@ export enum ServiceFunc {
toggleContentSidebar = "toggle-content-sidebar",
waitSidebar = "wait-sidebar",
openInSidebar = "open-in-sidebar",
mcpExecuteTool = "mcp-execute-tool",
mcpGetTools = "mcp-get-tools",
}
export type ParseDocOptions = {
......
@echo off
echo ========================================
echo n8n MCP SuperAssistant Proxy Starter
echo ========================================
echo.
echo Starting MCP SuperAssistant Proxy...
echo Connect URL: http://localhost:3006/sse
echo.
echo Press Ctrl+C to stop.
echo.
npx -y @srbhptl39/mcp-superassistant-proxy@latest --config ./mcp-config.json --outputTransport sse
......@@ -13,7 +13,8 @@
"baseUrl": ".",
"resolveJsonModule": true,
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@extension/shared/lib/logger": ["./src/content/render_prescript/utils/logger.ts"]
},
"types": ["@types/chrome"]
}
......
......@@ -51,6 +51,7 @@ export default defineConfig({
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
"@extension/shared/lib/logger": fileURLToPath(new URL("./src/content/render_prescript/utils/logger", import.meta.url)),
"vue-i18n": resolve(
__dirname,
"node_modules",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment