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>
This diff is collapsed.
This diff is collapsed.
......@@ -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>
<div class="my-12">
<!-- 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" 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>;
}
This diff is collapsed.
/**
* 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';
}
};
// 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,
};
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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';
This diff is collapsed.
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;
};
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
// 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)
};
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
@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
This diff is collapsed.
This diff is collapsed.
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