Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47889287e4 | ||
|
|
3be78f5763 | ||
|
|
935bbcac5f | ||
|
|
90c7267e22 | ||
|
|
75c9c87f52 |
@@ -54,3 +54,4 @@ packages/*/*.tgz
|
||||
# Claude chats
|
||||
.claude/
|
||||
claude_chats/
|
||||
*.zip
|
||||
|
||||
@@ -77,4 +77,35 @@ test.describe('Popup', () => {
|
||||
const openPanel = page.locator('#open-panel');
|
||||
await expect(openPanel).toBeAttached();
|
||||
});
|
||||
|
||||
test('on-page overlay toggle button exists', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
const openOverlay = page.locator('#open-page-overlay');
|
||||
await expect(openOverlay).toBeAttached();
|
||||
await expect(openOverlay).toContainText('Overlay');
|
||||
});
|
||||
|
||||
test('toggles on-page overlay on a live webpage', async ({ context, extensionId }) => {
|
||||
const targetPage = await context.newPage();
|
||||
await targetPage.goto('https://example.com', { waitUntil: 'domcontentloaded' });
|
||||
await expect(targetPage.locator('body')).toBeVisible();
|
||||
|
||||
// Content script mounts hidden overlay root.
|
||||
await expect(targetPage.locator('#hanzo-page-overlay-root')).toHaveCount(1);
|
||||
|
||||
const popupPage = await context.newPage();
|
||||
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
const toggleOverlayBtn = popupPage.locator('#open-page-overlay');
|
||||
await expect(toggleOverlayBtn).toBeVisible();
|
||||
await toggleOverlayBtn.click();
|
||||
|
||||
await targetPage.bringToFront();
|
||||
const overlayRoot = targetPage.locator('#hanzo-page-overlay-root');
|
||||
await expect(overlayRoot).toHaveAttribute('data-enabled', 'true');
|
||||
await expect(overlayRoot).toHaveAttribute('data-open', 'true');
|
||||
await expect(targetPage.locator('#hanzo-page-overlay-root .hanzo-page-panel')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,6 +95,9 @@ test.describe('Sidebar', () => {
|
||||
|
||||
// Chat UI elements should exist
|
||||
await expect(page.locator('#model-select')).toBeAttached();
|
||||
await expect(page.locator('#rag-enabled')).toBeAttached();
|
||||
await expect(page.locator('#tab-context-enabled')).toBeAttached();
|
||||
await expect(page.locator('#rag-status')).toBeAttached();
|
||||
await expect(page.locator('#chat-input')).toBeAttached();
|
||||
await expect(page.locator('#send-btn')).toBeAttached();
|
||||
});
|
||||
@@ -121,6 +124,7 @@ test.describe('Sidebar', () => {
|
||||
|
||||
await expect(page.locator('#mcp-status')).toBeAttached();
|
||||
await expect(page.locator('#mcp-tools')).toBeAttached();
|
||||
await expect(page.locator('#mcp-tool-list')).toBeAttached();
|
||||
await expect(page.locator('#agent-count')).toBeAttached();
|
||||
await expect(page.locator('#tab-fs')).toBeAttached();
|
||||
await expect(page.locator('#gpu-status')).toBeAttached();
|
||||
@@ -137,6 +141,12 @@ test.describe('Sidebar', () => {
|
||||
await expect(page.locator('#mcp-port-setting')).toBeAttached();
|
||||
await expect(page.locator('#cdp-port-setting')).toBeAttached();
|
||||
await expect(page.locator('#zap-ports-setting')).toBeAttached();
|
||||
await expect(page.locator('#rag-use-zap')).toBeAttached();
|
||||
await expect(page.locator('#rag-include-tab-context')).toBeAttached();
|
||||
await expect(page.locator('#rag-top-k')).toBeAttached();
|
||||
await expect(page.locator('#rag-kb')).toBeAttached();
|
||||
await expect(page.locator('#rag-endpoint')).toBeAttached();
|
||||
await expect(page.locator('#rag-api-key')).toBeAttached();
|
||||
await expect(page.locator('#save-settings')).toBeAttached();
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.2",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
"build": "echo 'Browser extension build completed'",
|
||||
"build": "node src/build.js",
|
||||
"watch": "node src/build.js --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
@@ -33,4 +33,4 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,9 +53,11 @@ const IAM_BASE = 'https://hanzo.id';
|
||||
const CLIENT_ID = 'app-hanzo';
|
||||
const SCOPES = 'openid profile email';
|
||||
|
||||
// Redirect URI: use the extension's own callback page
|
||||
// Redirect URI: use a web callback that Casdoor has registered.
|
||||
// The tab-based auth flow catches the redirect URL before the page loads,
|
||||
// extracts the code, and closes the tab — works across Chrome, Firefox, Safari.
|
||||
function getRedirectUri(): string {
|
||||
return chrome.runtime.getURL('callback.html');
|
||||
return 'https://hanzo.ai/callback';
|
||||
}
|
||||
|
||||
// Storage keys (matching @hanzo/iam SDK convention)
|
||||
|
||||
@@ -33,6 +33,14 @@ interface ControlSession {
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
interface RagSnippet {
|
||||
content: string;
|
||||
title?: string;
|
||||
source?: string;
|
||||
score?: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const controlSession: ControlSession = {
|
||||
active: false,
|
||||
tabId: null,
|
||||
@@ -416,11 +424,106 @@ function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async function getRagConfig(): Promise<{
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
knowledgeBase: string;
|
||||
topK: number;
|
||||
includeTabContext: boolean;
|
||||
}> {
|
||||
const result = await browser.storage.local.get([
|
||||
'hanzo_rag_endpoint',
|
||||
'hanzo_rag_api_key',
|
||||
'hanzo_rag_kb',
|
||||
'hanzo_rag_top_k',
|
||||
'hanzo_rag_include_tab_context',
|
||||
]);
|
||||
|
||||
const topKParsed = Number.parseInt(String(result.hanzo_rag_top_k ?? 5), 10);
|
||||
return {
|
||||
endpoint: String(result.hanzo_rag_endpoint || '').trim(),
|
||||
apiKey: String(result.hanzo_rag_api_key || '').trim(),
|
||||
knowledgeBase: String(result.hanzo_rag_kb || '').trim(),
|
||||
topK: Number.isFinite(topKParsed) ? Math.max(1, Math.min(topKParsed, 20)) : 5,
|
||||
includeTabContext: result.hanzo_rag_include_tab_context !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRagSnippets(raw: any): RagSnippet[] {
|
||||
if (!raw) return [];
|
||||
|
||||
const candidates: any[] = [];
|
||||
if (Array.isArray(raw)) candidates.push(...raw);
|
||||
if (Array.isArray(raw?.snippets)) candidates.push(...raw.snippets);
|
||||
if (Array.isArray(raw?.documents)) candidates.push(...raw.documents);
|
||||
if (Array.isArray(raw?.items)) candidates.push(...raw.items);
|
||||
if (Array.isArray(raw?.results)) candidates.push(...raw.results);
|
||||
|
||||
if (!candidates.length && typeof raw?.content === 'string') {
|
||||
candidates.push({ content: raw.content, source: raw.source || 'rag-endpoint' });
|
||||
}
|
||||
|
||||
return candidates
|
||||
.map((item) => {
|
||||
const content = String(item?.content ?? item?.text ?? item?.snippet ?? item?.body ?? '').trim();
|
||||
if (!content) return null;
|
||||
return {
|
||||
content,
|
||||
title: item?.title ? String(item.title) : undefined,
|
||||
source: item?.source ? String(item.source) : undefined,
|
||||
score: typeof item?.score === 'number' ? item.score : undefined,
|
||||
url: item?.url ? String(item.url) : undefined,
|
||||
} as RagSnippet;
|
||||
})
|
||||
.filter((item): item is RagSnippet => !!item)
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
async function queryRagEndpoint(params: {
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
query: string;
|
||||
topK: number;
|
||||
knowledgeBase: string;
|
||||
includeTabContext: boolean;
|
||||
pageContext?: { url?: string; title?: string };
|
||||
}): Promise<RagSnippet[]> {
|
||||
if (!params.endpoint) return [];
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (params.apiKey) headers.Authorization = `Bearer ${params.apiKey}`;
|
||||
|
||||
const response = await fetch(params.endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
query: params.query,
|
||||
top_k: params.topK,
|
||||
knowledge_base: params.knowledgeBase || undefined,
|
||||
page_context: params.includeTabContext ? params.pageContext : undefined,
|
||||
source: 'hanzo-browser-extension-firefox',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`RAG endpoint error ${response.status}: ${text || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const raw = await response.json();
|
||||
return normalizeRagSnippets(raw).map((snippet) => ({
|
||||
...snippet,
|
||||
source: snippet.source || 'rag-endpoint',
|
||||
}));
|
||||
}
|
||||
|
||||
async function firefoxLogin(): Promise<any> {
|
||||
const codeVerifier = generateRandomString(64);
|
||||
const codeChallenge = base64UrlEncode(await sha256(codeVerifier));
|
||||
const state = generateRandomString(32);
|
||||
const redirectUri = browser.runtime.getURL('callback.html');
|
||||
const redirectUri = 'https://hanzo.ai/callback';
|
||||
|
||||
const authorizeUrl = new URL(`${IAM_BASE}/login/oauth/authorize`);
|
||||
authorizeUrl.searchParams.set('client_id', CLIENT_ID);
|
||||
@@ -533,6 +636,75 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
|
||||
break;
|
||||
}
|
||||
|
||||
case 'checkWebGPU': {
|
||||
try {
|
||||
if (!navigator.gpu) {
|
||||
sendResponse({ available: false });
|
||||
break;
|
||||
}
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
sendResponse({
|
||||
available: !!adapter,
|
||||
adapter: (adapter as any)?.name || 'Unknown GPU',
|
||||
models: [],
|
||||
});
|
||||
} catch {
|
||||
sendResponse({ available: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'listAgents':
|
||||
sendResponse({ success: true, agents: [] });
|
||||
break;
|
||||
|
||||
case 'stopAgent':
|
||||
sendResponse({ success: false, error: 'Agent workers are not available in Firefox background mode yet' });
|
||||
break;
|
||||
|
||||
case 'launchAIWorker':
|
||||
sendResponse({ success: false, error: 'launchAIWorker is not implemented in Firefox background mode yet' });
|
||||
break;
|
||||
|
||||
case 'rag.query': {
|
||||
try {
|
||||
const config = await getRagConfig();
|
||||
const topKInput = Number.parseInt(String(request.topK ?? config.topK), 10);
|
||||
const params = {
|
||||
endpoint: String(request.endpoint ?? config.endpoint ?? '').trim(),
|
||||
apiKey: String(request.apiKey ?? config.apiKey ?? '').trim(),
|
||||
query: String(request.query || '').trim(),
|
||||
topK: Number.isFinite(topKInput) ? Math.max(1, Math.min(topKInput, 20)) : 5,
|
||||
knowledgeBase: String(request.knowledgeBase ?? config.knowledgeBase ?? '').trim(),
|
||||
includeTabContext: request.includeTabContext !== undefined
|
||||
? !!request.includeTabContext
|
||||
: config.includeTabContext,
|
||||
pageContext: request.pageContext && typeof request.pageContext === 'object'
|
||||
? {
|
||||
url: request.pageContext.url ? String(request.pageContext.url) : undefined,
|
||||
title: request.pageContext.title ? String(request.pageContext.title) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (!params.query) {
|
||||
sendResponse({ success: false, error: 'Query is required' });
|
||||
break;
|
||||
}
|
||||
|
||||
if (!params.endpoint) {
|
||||
sendResponse({ success: true, source: 'none', snippets: [] });
|
||||
break;
|
||||
}
|
||||
|
||||
const snippets = await queryRagEndpoint(params);
|
||||
sendResponse({ success: true, source: snippets.length ? 'rag-endpoint' : 'none', snippets });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// --- AI Control Overlay (forwarded to content script) ---
|
||||
case 'ai.control.start':
|
||||
case 'ai.control.cursor':
|
||||
@@ -570,7 +742,7 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
|
||||
break;
|
||||
|
||||
default:
|
||||
// Not a handled message — ignore
|
||||
sendResponse({ success: false, error: `Unknown action: ${request.action}` });
|
||||
break;
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { BrowserControl } from './browser-control';
|
||||
import { WebGPUAI } from './webgpu-ai';
|
||||
import { getCDPBridge, CDPBridge } from './cdp-bridge';
|
||||
import * as auth from './auth';
|
||||
import { listModels } from './chat-client';
|
||||
import { listModels, chatCompletion, ChatMessage } from './chat-client';
|
||||
|
||||
// Initialize browser control
|
||||
const browserControl = new BrowserControl();
|
||||
@@ -30,6 +30,29 @@ interface ControlSession {
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
interface RagSnippet {
|
||||
content: string;
|
||||
title?: string;
|
||||
source?: string;
|
||||
score?: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface RagQueryParams {
|
||||
query: string;
|
||||
topK: number;
|
||||
knowledgeBase: string;
|
||||
includeTabContext: boolean;
|
||||
useZapMemory: boolean;
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
mcpId?: string;
|
||||
pageContext?: {
|
||||
url?: string;
|
||||
title?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const zapState: ZapState = {
|
||||
connected: false,
|
||||
mcps: [],
|
||||
@@ -49,6 +72,7 @@ const DEFAULT_MCP_PORT = 3001;
|
||||
const DEFAULT_CDP_PORT = 9223;
|
||||
const ZAP_RECONNECT_DELAY = 3000;
|
||||
const ZAP_DISCOVERY_TIMEOUT = 2000;
|
||||
const DEFAULT_RAG_TOP_K = 5;
|
||||
|
||||
/** Load port configuration from storage */
|
||||
async function getPortConfig(): Promise<{ zapPorts: number[]; mcpPort: number; cdpPort: number }> {
|
||||
@@ -63,6 +87,36 @@ async function getPortConfig(): Promise<{ zapPorts: number[]; mcpPort: number; c
|
||||
});
|
||||
}
|
||||
|
||||
async function getRagConfig(): Promise<{
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
knowledgeBase: string;
|
||||
topK: number;
|
||||
includeTabContext: boolean;
|
||||
useZapMemory: boolean;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get([
|
||||
'hanzo_rag_endpoint',
|
||||
'hanzo_rag_api_key',
|
||||
'hanzo_rag_kb',
|
||||
'hanzo_rag_top_k',
|
||||
'hanzo_rag_include_tab_context',
|
||||
'hanzo_rag_use_zap',
|
||||
], (result) => {
|
||||
const parsedTopK = Number.parseInt(String(result.hanzo_rag_top_k ?? DEFAULT_RAG_TOP_K), 10);
|
||||
resolve({
|
||||
endpoint: String(result.hanzo_rag_endpoint || '').trim(),
|
||||
apiKey: String(result.hanzo_rag_api_key || '').trim(),
|
||||
knowledgeBase: String(result.hanzo_rag_kb || '').trim(),
|
||||
topK: Number.isFinite(parsedTopK) ? Math.max(1, Math.min(parsedTopK, 20)) : DEFAULT_RAG_TOP_K,
|
||||
includeTabContext: result.hanzo_rag_include_tab_context !== false,
|
||||
useZapMemory: result.hanzo_rag_use_zap !== false,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Active ZAP WebSocket connections keyed by MCP id */
|
||||
const zapConnections = new Map<string, WebSocket>();
|
||||
|
||||
@@ -279,6 +333,126 @@ async function zapCallTool(name: string, args: Record<string, unknown> = {}, tar
|
||||
throw new Error(`Tool not found on any ZAP-connected MCP: ${name}`);
|
||||
}
|
||||
|
||||
function hasZapTool(name: string): boolean {
|
||||
return zapState.mcps.some((mcp) => mcp.tools.includes(name));
|
||||
}
|
||||
|
||||
function normalizeRagSnippets(raw: any): RagSnippet[] {
|
||||
if (!raw) return [];
|
||||
|
||||
const candidates: any[] = [];
|
||||
if (Array.isArray(raw)) candidates.push(...raw);
|
||||
if (Array.isArray(raw?.snippets)) candidates.push(...raw.snippets);
|
||||
if (Array.isArray(raw?.documents)) candidates.push(...raw.documents);
|
||||
if (Array.isArray(raw?.items)) candidates.push(...raw.items);
|
||||
if (Array.isArray(raw?.results)) candidates.push(...raw.results);
|
||||
if (Array.isArray(raw?.memories)) candidates.push(...raw.memories);
|
||||
if (Array.isArray(raw?.matches)) candidates.push(...raw.matches);
|
||||
|
||||
if (!candidates.length && typeof raw?.content === 'string') {
|
||||
candidates.push({ content: raw.content, source: raw.source || 'memory' });
|
||||
}
|
||||
|
||||
const snippets = candidates
|
||||
.map((item) => {
|
||||
const content = String(
|
||||
item?.content ??
|
||||
item?.text ??
|
||||
item?.snippet ??
|
||||
item?.body ??
|
||||
item?.value ??
|
||||
'',
|
||||
).trim();
|
||||
if (!content) return null;
|
||||
|
||||
return {
|
||||
content,
|
||||
title: item?.title ? String(item.title) : undefined,
|
||||
source: item?.source ? String(item.source) : undefined,
|
||||
score: typeof item?.score === 'number' ? item.score : undefined,
|
||||
url: item?.url ? String(item.url) : undefined,
|
||||
} as RagSnippet;
|
||||
})
|
||||
.filter((item): item is RagSnippet => !!item);
|
||||
|
||||
return snippets.slice(0, 20);
|
||||
}
|
||||
|
||||
async function queryRagFromZap(params: RagQueryParams): Promise<RagSnippet[]> {
|
||||
if (!params.useZapMemory || !zapState.connected || !hasZapTool('memory')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const payloads: Record<string, unknown>[] = [
|
||||
{
|
||||
action: 'query',
|
||||
query: params.query,
|
||||
top_k: params.topK,
|
||||
limit: params.topK,
|
||||
kb: params.knowledgeBase || undefined,
|
||||
page_context: params.pageContext,
|
||||
},
|
||||
{
|
||||
query: params.query,
|
||||
topK: params.topK,
|
||||
limit: params.topK,
|
||||
knowledge_base: params.knowledgeBase || undefined,
|
||||
context: params.pageContext,
|
||||
},
|
||||
];
|
||||
|
||||
for (const payload of payloads) {
|
||||
try {
|
||||
const raw = await zapCallTool('memory', payload, params.mcpId);
|
||||
const snippets = normalizeRagSnippets(raw);
|
||||
if (snippets.length) {
|
||||
return snippets.map((snippet) => ({
|
||||
...snippet,
|
||||
source: snippet.source || 'zap-memory',
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Try alternative payload shape before giving up.
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function queryRagFromEndpoint(params: RagQueryParams): Promise<RagSnippet[]> {
|
||||
if (!params.endpoint) return [];
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (params.apiKey) {
|
||||
headers.Authorization = `Bearer ${params.apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(params.endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
query: params.query,
|
||||
top_k: params.topK,
|
||||
knowledge_base: params.knowledgeBase || undefined,
|
||||
page_context: params.includeTabContext ? params.pageContext : undefined,
|
||||
source: 'hanzo-browser-extension',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`RAG endpoint error ${response.status}: ${text || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const raw = await response.json();
|
||||
return normalizeRagSnippets(raw).map((snippet) => ({
|
||||
...snippet,
|
||||
source: snippet.source || 'rag-endpoint',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and connect to ZAP servers on startup
|
||||
*/
|
||||
@@ -316,6 +490,18 @@ function sendControlMessageToTab(tabId: number, message: Record<string, unknown>
|
||||
});
|
||||
}
|
||||
|
||||
function sendMessageToTab(tabId: number, message: Record<string, unknown>): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.sendMessage(tabId, message, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message || 'Failed to contact page content script'));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveTabId(): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
@@ -431,10 +617,43 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
}
|
||||
break;
|
||||
|
||||
case 'checkWebGPU': {
|
||||
try {
|
||||
if (!navigator.gpu) {
|
||||
sendResponse({ available: false });
|
||||
break;
|
||||
}
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
sendResponse({
|
||||
available: !!adapter,
|
||||
adapter: (adapter as any)?.name || 'Unknown GPU',
|
||||
models: webgpuAI.getStatus().models,
|
||||
});
|
||||
} catch {
|
||||
sendResponse({ available: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'listAgents':
|
||||
sendResponse({ success: true, agents: browserControl.getAgents() });
|
||||
break;
|
||||
|
||||
case 'stopAgent':
|
||||
sendResponse({ success: browserControl.stopAgent(request.agentId) });
|
||||
break;
|
||||
|
||||
case 'launchAIWorker':
|
||||
if (sender.tab?.id) {
|
||||
await browserControl.launchAIWorker(sender.tab.id, request.model);
|
||||
sendResponse({ success: true });
|
||||
try {
|
||||
const targetTabId = request.tabId || sender.tab?.id;
|
||||
if (!targetTabId) {
|
||||
sendResponse({ success: false, error: 'No target tab found' });
|
||||
break;
|
||||
}
|
||||
const agentId = await browserControl.launchAIWorker(targetTabId, request.model || 'browser-control');
|
||||
sendResponse({ success: true, agentId });
|
||||
} catch (error: any) {
|
||||
sendResponse({ success: false, error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -598,6 +817,28 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
|
||||
// --- In-page overlay (content script) ---
|
||||
case 'page.overlay.toggle':
|
||||
case 'page.overlay.show':
|
||||
case 'page.overlay.hide':
|
||||
case 'page.overlay.status': {
|
||||
try {
|
||||
const tabId = typeof request.tabId === 'number'
|
||||
? request.tabId
|
||||
: (sender.tab?.id ?? await getActiveTabId());
|
||||
if (tabId === null) {
|
||||
sendResponse({ success: false, error: 'No target tab found' });
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await sendMessageToTab(tabId, { action: request.action });
|
||||
sendResponse({ success: true, tabId, ...(response || {}) });
|
||||
} catch (error: any) {
|
||||
sendResponse({ success: false, error: error.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Auth (Hanzo IAM OAuth2 + PKCE) ---
|
||||
case 'auth.login':
|
||||
try {
|
||||
@@ -635,6 +876,54 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
}
|
||||
break;
|
||||
|
||||
case 'rag.query':
|
||||
try {
|
||||
const config = await getRagConfig();
|
||||
const topKInput = Number.parseInt(String(request.topK ?? config.topK), 10);
|
||||
const params: RagQueryParams = {
|
||||
query: String(request.query || '').trim(),
|
||||
topK: Number.isFinite(topKInput) ? Math.max(1, Math.min(topKInput, 20)) : DEFAULT_RAG_TOP_K,
|
||||
knowledgeBase: String(request.knowledgeBase ?? config.knowledgeBase ?? '').trim(),
|
||||
includeTabContext: request.includeTabContext !== undefined
|
||||
? !!request.includeTabContext
|
||||
: config.includeTabContext,
|
||||
useZapMemory: request.useZapMemory !== undefined
|
||||
? !!request.useZapMemory
|
||||
: config.useZapMemory,
|
||||
endpoint: String(request.endpoint ?? config.endpoint ?? '').trim(),
|
||||
apiKey: String(request.apiKey ?? config.apiKey ?? '').trim(),
|
||||
mcpId: request.mcpId ? String(request.mcpId) : undefined,
|
||||
pageContext: request.pageContext && typeof request.pageContext === 'object'
|
||||
? {
|
||||
url: request.pageContext.url ? String(request.pageContext.url) : undefined,
|
||||
title: request.pageContext.title ? String(request.pageContext.title) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (!params.query) {
|
||||
sendResponse({ success: false, error: 'Query is required' });
|
||||
break;
|
||||
}
|
||||
|
||||
let snippets = await queryRagFromZap(params);
|
||||
let source = snippets.length ? 'zap-memory' : 'none';
|
||||
|
||||
if (!snippets.length && params.endpoint) {
|
||||
snippets = await queryRagFromEndpoint(params);
|
||||
source = snippets.length ? 'rag-endpoint' : source;
|
||||
}
|
||||
|
||||
sendResponse({
|
||||
success: true,
|
||||
source,
|
||||
snippets: snippets.slice(0, params.topK),
|
||||
});
|
||||
} catch (error: any) {
|
||||
sendResponse({ success: false, error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
// --- Cloud Chat ---
|
||||
case 'chat.listModels':
|
||||
try {
|
||||
@@ -650,6 +939,112 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
}
|
||||
break;
|
||||
|
||||
case 'chat.complete':
|
||||
try {
|
||||
const token = await auth.getValidAccessToken();
|
||||
if (!token) {
|
||||
sendResponse({ success: false, error: 'Not authenticated' });
|
||||
break;
|
||||
}
|
||||
|
||||
const model = String(request.model || 'gpt-4o');
|
||||
const messagesInput = Array.isArray(request.messages) ? request.messages : [];
|
||||
const messages: ChatMessage[] = messagesInput
|
||||
.filter((msg: any) => msg && typeof msg.content === 'string' && typeof msg.role === 'string')
|
||||
.map((msg: any) => ({
|
||||
role: (msg.role === 'system' || msg.role === 'assistant') ? msg.role : 'user',
|
||||
content: String(msg.content),
|
||||
}))
|
||||
.slice(-24);
|
||||
|
||||
if (!messages.length) {
|
||||
sendResponse({ success: false, error: 'messages are required' });
|
||||
break;
|
||||
}
|
||||
|
||||
const content = await chatCompletion(token, {
|
||||
model,
|
||||
messages,
|
||||
temperature: typeof request.temperature === 'number' ? request.temperature : undefined,
|
||||
max_tokens: typeof request.max_tokens === 'number' ? request.max_tokens : undefined,
|
||||
});
|
||||
|
||||
sendResponse({ success: true, content });
|
||||
} catch (error: any) {
|
||||
sendResponse({ success: false, error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
// --- Bridge status ---
|
||||
case 'bridge.status': {
|
||||
const bridgeConnected = cdpBridge.isBridgeConnected();
|
||||
sendResponse({
|
||||
success: true,
|
||||
connected: bridgeConnected,
|
||||
browsers: bridgeConnected ? [detectBrowser()] : [],
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Config persistence ---
|
||||
case 'config.save': {
|
||||
const { key, value } = request;
|
||||
// Forward to CDP bridge server so it can save to ~/.hanzo/extension/config.json
|
||||
cdpBridge.sendConfig(key, value);
|
||||
|
||||
// Also sync to IAM if authenticated (best-effort)
|
||||
try {
|
||||
const token = await auth.getValidAccessToken();
|
||||
if (token) {
|
||||
fetch('https://hanzo.id/api/update-user', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: { [key]: value },
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Not authenticated
|
||||
}
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Takeover messages (forwarded from CDP bridge to content script) ---
|
||||
case 'hanzo.takeover.start': {
|
||||
const takeoverTabId = await resolveControlTabId(request.tabId);
|
||||
if (takeoverTabId === null) {
|
||||
sendResponse({ success: false, error: 'No target tab found' });
|
||||
break;
|
||||
}
|
||||
await startControlSession(takeoverTabId, request.task);
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'hanzo.takeover.end': {
|
||||
await stopControlSession();
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'hanzo.takeover.cursor': {
|
||||
const cursorTabId = await resolveControlTabId(request.tabId);
|
||||
if (cursorTabId !== null) {
|
||||
sendControlMessageToTab(cursorTabId, {
|
||||
action: 'ai.control.cursor',
|
||||
x: request.x,
|
||||
y: request.y,
|
||||
});
|
||||
}
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Content script element selection (routed from content script) ---
|
||||
case 'elementSelected':
|
||||
// Forward to ZAP-connected MCPs (primary)
|
||||
@@ -670,6 +1065,7 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
}
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,27 @@ const { execSync } = require('child_process');
|
||||
async function build() {
|
||||
console.log('Building browser extension...');
|
||||
|
||||
const hanzoUiRoot = path.resolve(__dirname, '../../../../ui');
|
||||
const hanzoUiPrimitives = path.join(hanzoUiRoot, 'pkg/ui/dist/primitives/index-common.js');
|
||||
const localReact = path.join(hanzoUiRoot, 'node_modules/react');
|
||||
const localReactDom = path.join(hanzoUiRoot, 'node_modules/react-dom');
|
||||
const canUseSharedUi =
|
||||
fs.existsSync(hanzoUiPrimitives) &&
|
||||
fs.existsSync(localReact) &&
|
||||
fs.existsSync(localReactDom);
|
||||
|
||||
if (!canUseSharedUi) {
|
||||
throw new Error(
|
||||
[
|
||||
'Shared @hanzo/ui dependencies were not found.',
|
||||
`Expected: ${hanzoUiPrimitives}`,
|
||||
`Expected: ${localReact}`,
|
||||
`Expected: ${localReactDom}`,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
console.log('Using shared @hanzo/ui primitives from sibling repo:', hanzoUiPrimitives);
|
||||
|
||||
// Ensure dist directories exist
|
||||
fs.mkdirSync('dist/browser-extension', { recursive: true });
|
||||
fs.mkdirSync('dist/browser-extension/chrome', { recursive: true });
|
||||
@@ -56,6 +77,32 @@ async function build() {
|
||||
format: 'esm'
|
||||
});
|
||||
|
||||
// Build sidebar + popup UI scripts (TypeScript-first, with JS fallback)
|
||||
const sidebarAliases = {
|
||||
'@hanzo/ui/primitives-common': hanzoUiPrimitives,
|
||||
react: localReact,
|
||||
'react-dom': localReactDom,
|
||||
};
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [fs.existsSync('src/sidebar.ts') ? 'src/sidebar.ts' : 'src/sidebar.js'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/sidebar.js',
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'firefox91', 'safari14'],
|
||||
sourcemap: 'inline',
|
||||
alias: sidebarAliases,
|
||||
});
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [fs.existsSync('src/popup.ts') ? 'src/popup.ts' : 'src/popup.js'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/popup.js',
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'firefox91', 'safari14'],
|
||||
sourcemap: 'inline',
|
||||
});
|
||||
|
||||
// Build browser control module
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/browser-control.ts'],
|
||||
@@ -160,14 +207,18 @@ async function build() {
|
||||
);
|
||||
}
|
||||
|
||||
// Copy popup + sidebar HTML/CSS/JS
|
||||
for (const f of ['popup.html', 'popup.css', 'popup.js', 'sidebar.html', 'sidebar.css', 'sidebar.js', 'callback.html', 'ai-worker.js']) {
|
||||
// Copy popup + sidebar HTML/CSS
|
||||
for (const f of ['popup.html', 'popup.css', 'sidebar.html', 'sidebar.css', 'callback.html', 'ai-worker.js']) {
|
||||
const src = path.join('src', f);
|
||||
if (fs.existsSync(src)) {
|
||||
fs.copyFileSync(src, path.join(dir, f));
|
||||
}
|
||||
}
|
||||
|
||||
// Copy compiled popup/sidebar scripts
|
||||
fs.copyFileSync('dist/browser-extension/popup.js', path.join(dir, 'popup.js'));
|
||||
fs.copyFileSync('dist/browser-extension/sidebar.js', path.join(dir, 'sidebar.js'));
|
||||
|
||||
// Copy icons into each browser directory
|
||||
for (const icon of ['icon16.png', 'icon48.png', 'icon128.png']) {
|
||||
const iconPath = path.join('images', icon);
|
||||
@@ -263,4 +314,4 @@ server.on('elementSelected', (data) => {
|
||||
console.log('\nTo publish to npm: cd dist/browser-extension && npm publish');
|
||||
}
|
||||
|
||||
build().catch(console.error);
|
||||
build().catch(console.error);
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as path from 'path';
|
||||
interface CDPClient {
|
||||
ws: WebSocket;
|
||||
capabilities: string[];
|
||||
browser: string;
|
||||
tabId?: number;
|
||||
}
|
||||
|
||||
@@ -87,9 +88,15 @@ class CDPBridgeServer {
|
||||
if (message.type === 'register') {
|
||||
this.clients.set(ws, {
|
||||
ws,
|
||||
capabilities: message.capabilities || []
|
||||
capabilities: message.capabilities || [],
|
||||
browser: message.browser || 'unknown',
|
||||
});
|
||||
console.log('[hanzo.browser] Registered:', message.capabilities?.join(', '));
|
||||
console.log(`[hanzo.browser] Registered ${message.browser || 'unknown'}:`, message.capabilities?.join(', '));
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'config') {
|
||||
this.saveConfig(message.key, message.value);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -111,6 +118,37 @@ class CDPBridgeServer {
|
||||
}
|
||||
}
|
||||
|
||||
/** Save a config value to ~/.hanzo/extension/config.json */
|
||||
private saveConfig(key: string, value: unknown): void {
|
||||
const configDir = path.join(
|
||||
process.env.HOME || process.env.USERPROFILE || '.',
|
||||
'.hanzo',
|
||||
'extension',
|
||||
);
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
config[key] = value;
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
||||
console.log(`[hanzo.browser] Config saved: ${key} = ${JSON.stringify(value)}`);
|
||||
} catch (error: any) {
|
||||
console.error(`[hanzo.browser] Failed to save config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get connected browser names */
|
||||
getConnectedBrowsers(): string[] {
|
||||
return Array.from(this.clients.values())
|
||||
.map((c) => (c as any).browser || 'unknown')
|
||||
.filter((b) => b !== 'unknown');
|
||||
}
|
||||
|
||||
private async sendRaw(method: string, params?: any): Promise<any> {
|
||||
const client = Array.from(this.clients.values())[0];
|
||||
if (!client) {
|
||||
@@ -332,6 +370,7 @@ async function startJSONRPCServer(bridgeServer: CDPBridgeServer) {
|
||||
res.end(JSON.stringify({
|
||||
service: 'hanzo.browser',
|
||||
connected: bridgeServer.isConnected(),
|
||||
browsers: bridgeServer.getConnectedBrowsers(),
|
||||
actions: [
|
||||
'navigate', 'navigate_back', 'reload', 'url', 'title', 'content',
|
||||
'screenshot', 'snapshot',
|
||||
@@ -341,6 +380,7 @@ async function startJSONRPCServer(bridgeServer: CDPBridgeServer) {
|
||||
'wait', 'wait_for_load',
|
||||
'tabs', 'new_tab', 'close_tab', 'select_tab',
|
||||
'console', 'network_requests',
|
||||
'takeover.start', 'takeover.end', 'takeover.cursor',
|
||||
'status'
|
||||
]
|
||||
}));
|
||||
|
||||
@@ -276,11 +276,19 @@ export class CDPBridge {
|
||||
|
||||
this.wsServer.onopen = () => {
|
||||
console.log('[CDP] Connected to bridge server');
|
||||
// Register as CDP provider
|
||||
// Register as CDP provider with browser identification
|
||||
const browser = typeof navigator !== 'undefined'
|
||||
? (navigator.userAgent.includes('Firefox') ? 'firefox'
|
||||
: navigator.userAgent.includes('Edg/') ? 'edge'
|
||||
: navigator.userAgent.includes('Chrome') ? 'chrome'
|
||||
: navigator.userAgent.includes('Safari') ? 'safari'
|
||||
: 'unknown')
|
||||
: 'unknown';
|
||||
this.wsServer?.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
capabilities: ['navigate', 'screenshot', 'click', 'type', 'evaluate']
|
||||
browser,
|
||||
capabilities: ['navigate', 'screenshot', 'click', 'type', 'evaluate', 'takeover']
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -394,15 +402,24 @@ export class CDPBridge {
|
||||
|
||||
// Control overlay management
|
||||
case 'hanzo.control.start':
|
||||
this.notifyControlOverlay(tabId, 'ai.control.start', { task: params.task || 'AI is controlling this page' });
|
||||
case 'hanzo.takeover.start':
|
||||
this.notifyControlOverlay(tabId, 'ai.control.start', { task: params.task || 'Hanzo AI is controlling this tab' });
|
||||
result = { success: true };
|
||||
break;
|
||||
|
||||
case 'hanzo.control.stop':
|
||||
case 'hanzo.takeover.end':
|
||||
this.notifyControlOverlay(tabId, 'ai.control.stop', {});
|
||||
result = { success: true };
|
||||
break;
|
||||
|
||||
case 'hanzo.takeover.cursor':
|
||||
if (params?.x !== undefined && params?.y !== undefined) {
|
||||
this.notifyControlOverlay(tabId, 'ai.control.cursor', { x: params.x, y: params.y });
|
||||
}
|
||||
result = { success: true };
|
||||
break;
|
||||
|
||||
default:
|
||||
// Pass through to Chrome debugger
|
||||
result = await this.send(tabId, method, params);
|
||||
@@ -454,6 +471,21 @@ export class CDPBridge {
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
/** Check whether the bridge WebSocket is connected. */
|
||||
isBridgeConnected(): boolean {
|
||||
return this.wsServer !== null && this.wsServer.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/** Send a config key/value to the bridge server for persistence. */
|
||||
sendConfig(key: string, value: unknown): void {
|
||||
if (!this.isBridgeConnected()) return;
|
||||
this.wsServer!.send(JSON.stringify({
|
||||
type: 'config',
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
private broadcastEvent(tabId: number, method: string, params: any): void {
|
||||
const event = JSON.stringify({
|
||||
type: 'event',
|
||||
@@ -461,7 +493,7 @@ export class CDPBridge {
|
||||
method,
|
||||
params
|
||||
});
|
||||
|
||||
|
||||
if (this.wsServer?.readyState === WebSocket.OPEN) {
|
||||
this.wsServer.send(event);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { flushSync } from 'react-dom';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Textarea,
|
||||
} from '@hanzo/ui/primitives-common';
|
||||
|
||||
function LoginPrompt() {
|
||||
return (
|
||||
<Card className="chat-modern-login-card">
|
||||
<CardHeader>
|
||||
<CardTitle>Chat with Zen AI models</CardTitle>
|
||||
<CardDescription>Models enabled in Hanzo Cloud are loaded automatically.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button id="auth-btn" className="chat-modern-auth-btn" type="button">
|
||||
Sign in
|
||||
</Button>
|
||||
<p className="auth-note">
|
||||
Browser tools work without sign-in.{' '}
|
||||
<a href="https://docs.hanzo.ai" target="_blank" rel="noreferrer">
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatComposer() {
|
||||
return (
|
||||
<div className="chat-modern-composer">
|
||||
<div className="model-selector">
|
||||
<select id="model-select">
|
||||
<option value="claude-sonnet-4-20250514">Claude Sonnet 4</option>
|
||||
<option value="claude-opus-4-20250514">Claude Opus 4</option>
|
||||
<option value="gpt-4o">GPT-4o</option>
|
||||
<option value="zen-coder-flash">Zen Coder Flash</option>
|
||||
<option value="zen-max">Zen Max</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="chat-flags">
|
||||
<label className="flag-toggle">
|
||||
<input type="checkbox" id="rag-enabled" defaultChecked />
|
||||
<span>RAG</span>
|
||||
</label>
|
||||
<label className="flag-toggle">
|
||||
<input type="checkbox" id="tab-context-enabled" defaultChecked />
|
||||
<span>Tab Context</span>
|
||||
</label>
|
||||
<span id="rag-status" className="rag-status">
|
||||
Ready
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="input-row">
|
||||
<Textarea id="chat-input" className="chat-modern-input" placeholder="Ask anything..." rows={1} />
|
||||
<Button id="send-btn" className="send-btn chat-modern-send" title="Send" type="button">
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M4 10l12-6-6 12-2-6-4-2z" strokeWidth="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function mountModernChatWidget() {
|
||||
const loginTarget = document.getElementById('chat-login-prompt');
|
||||
if (loginTarget) {
|
||||
loginTarget.innerHTML = '';
|
||||
const loginRoot = createRoot(loginTarget);
|
||||
flushSync(() => {
|
||||
loginRoot.render(<LoginPrompt />);
|
||||
});
|
||||
}
|
||||
|
||||
const composerTarget = document.getElementById('chat-composer');
|
||||
if (composerTarget) {
|
||||
composerTarget.innerHTML = '';
|
||||
const composerRoot = createRoot(composerTarget);
|
||||
flushSync(() => {
|
||||
composerRoot.render(<ChatComposer />);
|
||||
});
|
||||
}
|
||||
}
|
||||
+1143
-474
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.2",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
@@ -17,7 +17,7 @@
|
||||
"<all_urls>"
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' ws://localhost:* http://localhost:* https://api.hanzo.ai https://iam.hanzo.ai https://hanzo.id;"
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' ws://localhost:* wss://* http://* https://*;"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.2",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
@@ -19,7 +19,7 @@
|
||||
"<all_urls>"
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' ws://localhost:* http://localhost:* https://api.hanzo.ai https://iam.hanzo.ai https://hanzo.id;"
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' ws://localhost:* wss://* http://* https://*;"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
|
||||
@@ -372,6 +372,44 @@ kbd {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Backend Picker */
|
||||
.backend-picker {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.backend-picker h3 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.backend-select-wrapper select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
}
|
||||
|
||||
.backend-select-wrapper select:focus {
|
||||
outline: none;
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.backend-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
</svg>
|
||||
Open Chat Panel
|
||||
</button>
|
||||
<button id="open-page-overlay" class="btn btn-secondary" style="margin-bottom: 16px;">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 4h16v12H8l-4 4V4z"/>
|
||||
</svg>
|
||||
Toggle On-Page Overlay
|
||||
</button>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="features">
|
||||
@@ -104,6 +110,26 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Browser Backend -->
|
||||
<div class="setting-group backend-picker">
|
||||
<h3>Browser Backend</h3>
|
||||
<div class="backend-select-wrapper">
|
||||
<select id="browser-backend">
|
||||
<option value="auto">Auto (default)</option>
|
||||
<option value="this">This Browser</option>
|
||||
<option value="firefox">Firefox</option>
|
||||
<option value="chrome">Chrome</option>
|
||||
<option value="playwright">Playwright Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="backend-status">
|
||||
<span class="status-dot" id="bridge-status"></span>
|
||||
<span id="bridge-detail" class="status-detail">Checking bridge...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Connection Status -->
|
||||
<div class="status">
|
||||
<div class="status-item">
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
// Hanzo AI — Popup Script
|
||||
// Uses background message passing for auth (not direct storage access).
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const loginSection = document.getElementById('login-section');
|
||||
const mainSection = document.getElementById('main-section');
|
||||
const settingsSection = document.getElementById('settings-section');
|
||||
|
||||
// Auth
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
|
||||
// Open panel
|
||||
const openPanel = document.getElementById('open-panel');
|
||||
const openPageOverlay = document.getElementById('open-page-overlay');
|
||||
|
||||
// Status dots
|
||||
const zapStatus = document.getElementById('zap-status');
|
||||
const zapDetail = document.getElementById('zap-detail');
|
||||
const mcpStatus = document.getElementById('mcp-status');
|
||||
const mcpPort = document.getElementById('mcp-port');
|
||||
const cdpStatus = document.getElementById('cdp-status');
|
||||
const cdpDetail = document.getElementById('cdp-detail');
|
||||
const gpuStatus = document.getElementById('gpu-status');
|
||||
const gpuDetail = document.getElementById('gpu-detail');
|
||||
|
||||
// Settings
|
||||
const openSettings = document.getElementById('open-settings');
|
||||
const backBtn = document.getElementById('back-btn');
|
||||
const saveSettings = document.getElementById('save-settings');
|
||||
const testConnection = document.getElementById('test-connection');
|
||||
|
||||
// --- Auth via background ---
|
||||
function checkAuth() {
|
||||
// Always show main section and status (tools work without login)
|
||||
mainSection.classList.remove('hidden');
|
||||
refreshStatus();
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'auth.status' }, (response) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (response?.success && response.authenticated) {
|
||||
loginSection.classList.add('hidden');
|
||||
if (response.user) {
|
||||
const avatar = document.getElementById('user-avatar');
|
||||
const name = document.getElementById('user-name');
|
||||
const email = document.getElementById('user-email');
|
||||
avatar.src = response.user.picture || response.user.avatar || 'icon48.png';
|
||||
name.textContent = response.user.name || response.user.displayName || 'Hanzo User';
|
||||
email.textContent = response.user.email || '';
|
||||
}
|
||||
} else {
|
||||
loginSection.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loginBtn?.addEventListener('click', () => {
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Signing in...';
|
||||
chrome.runtime.sendMessage({ action: 'auth.login' }, (response) => {
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.innerHTML = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3"/></svg> Sign in with Hanzo';
|
||||
if (response?.success) {
|
||||
checkAuth();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
logoutBtn?.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ action: 'auth.logout' }, () => {
|
||||
mainSection.classList.add('hidden');
|
||||
loginSection.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Open side panel ---
|
||||
openPanel?.addEventListener('click', () => {
|
||||
if (chrome.sidePanel) {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (tabs[0]?.id) {
|
||||
chrome.sidePanel.open({ tabId: tabs[0].id });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Firefox: sidebar_action is automatic
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
|
||||
function isInjectableTab(tab: chrome.tabs.Tab | undefined): boolean {
|
||||
if (!tab || !tab.id) return false;
|
||||
const url = tab.url || '';
|
||||
if (!url) return false;
|
||||
if (url.startsWith('chrome://')) return false;
|
||||
if (url.startsWith('chrome-extension://')) return false;
|
||||
if (url.startsWith('devtools://')) return false;
|
||||
if (url.startsWith('edge://')) return false;
|
||||
if (url.startsWith('about:')) return false;
|
||||
if (url.startsWith('view-source:')) return false;
|
||||
if (url.startsWith('moz-extension://')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
openPageOverlay?.addEventListener('click', () => {
|
||||
openPageOverlay.textContent = 'Opening...';
|
||||
openPageOverlay.setAttribute('disabled', 'true');
|
||||
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const activeTab = tabs[0];
|
||||
let targetTab = isInjectableTab(activeTab) ? activeTab : null;
|
||||
|
||||
if (!targetTab) {
|
||||
chrome.tabs.query({ currentWindow: true }, (allTabs) => {
|
||||
targetTab = allTabs.find((tab) => isInjectableTab(tab)) || null;
|
||||
if (!targetTab?.id) {
|
||||
openPageOverlay.textContent = 'No valid page tab';
|
||||
setTimeout(() => {
|
||||
openPageOverlay.textContent = 'Toggle On-Page Overlay';
|
||||
openPageOverlay.removeAttribute('disabled');
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'page.overlay.toggle', tabId: targetTab.id }, (response) => {
|
||||
if (chrome.runtime.lastError || !response?.success) {
|
||||
openPageOverlay.textContent = 'Unavailable on this page';
|
||||
} else {
|
||||
const opened = !!response?.open;
|
||||
openPageOverlay.textContent = opened ? 'Overlay Opened' : 'Overlay Hidden';
|
||||
}
|
||||
setTimeout(() => {
|
||||
openPageOverlay.textContent = 'Toggle On-Page Overlay';
|
||||
openPageOverlay.removeAttribute('disabled');
|
||||
}, 900);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetTab?.id) {
|
||||
openPageOverlay.textContent = 'No Active Tab';
|
||||
setTimeout(() => {
|
||||
openPageOverlay.textContent = 'Toggle On-Page Overlay';
|
||||
openPageOverlay.removeAttribute('disabled');
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'page.overlay.toggle', tabId: targetTab.id }, (response) => {
|
||||
if (chrome.runtime.lastError || !response?.success) {
|
||||
openPageOverlay.textContent = 'Unavailable on this page';
|
||||
} else {
|
||||
const opened = !!response?.open;
|
||||
openPageOverlay.textContent = opened ? 'Overlay Opened' : 'Overlay Hidden';
|
||||
}
|
||||
setTimeout(() => {
|
||||
openPageOverlay.textContent = 'Toggle On-Page Overlay';
|
||||
openPageOverlay.removeAttribute('disabled');
|
||||
}, 900);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Status ---
|
||||
function refreshStatus() {
|
||||
// ZAP status
|
||||
chrome.runtime.sendMessage({ action: 'zap.status' }, (response) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (response?.success) {
|
||||
const zap = response.zap;
|
||||
if (zap.connected) {
|
||||
zapStatus.classList.add('connected');
|
||||
zapStatus.classList.remove('disconnected');
|
||||
const mcpCount = zap.mcps?.length || 0;
|
||||
const toolCount = zap.mcps?.reduce((sum, m) => sum + (m.tools?.length || 0), 0) || 0;
|
||||
zapDetail.textContent = `${mcpCount} MCP(s), ${toolCount} tools`;
|
||||
} else {
|
||||
zapStatus.classList.remove('connected');
|
||||
zapStatus.classList.add('disconnected');
|
||||
zapDetail.textContent = 'Not connected';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GPU status
|
||||
chrome.runtime.sendMessage({ action: 'runLocalAI', prompt: '' }, (response) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
gpuStatus.classList.add(response?.success ? 'connected' : 'disconnected');
|
||||
gpuStatus.classList.remove(response?.success ? 'disconnected' : 'connected');
|
||||
gpuDetail.textContent = response?.success ? 'Available' : 'Not available';
|
||||
});
|
||||
|
||||
// MCP/CDP — set as active for now (checked via ZAP)
|
||||
mcpStatus.classList.add('connected');
|
||||
mcpPort.textContent = 'Fallback';
|
||||
cdpStatus.classList.add('connected');
|
||||
cdpDetail.textContent = 'Active';
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
openSettings?.addEventListener('click', () => {
|
||||
mainSection.classList.add('hidden');
|
||||
settingsSection.classList.remove('hidden');
|
||||
loadSettings();
|
||||
});
|
||||
|
||||
backBtn?.addEventListener('click', () => {
|
||||
settingsSection.classList.add('hidden');
|
||||
mainSection.classList.remove('hidden');
|
||||
});
|
||||
|
||||
function loadSettings() {
|
||||
chrome.storage.local.get(['mcpPort', 'cdpPort', 'zapPorts'], (result) => {
|
||||
const mcpPortInput = document.getElementById('mcp-port-setting');
|
||||
const cdpPortInput = document.getElementById('cdp-port-setting');
|
||||
const zapPortsInput = document.getElementById('zap-ports-setting');
|
||||
|
||||
if (mcpPortInput) mcpPortInput.value = result.mcpPort || 3001;
|
||||
if (cdpPortInput) cdpPortInput.value = result.cdpPort || 9223;
|
||||
if (zapPortsInput) zapPortsInput.value = (result.zapPorts || [9999, 9998, 9997, 9996, 9995]).join(',');
|
||||
});
|
||||
}
|
||||
|
||||
saveSettings?.addEventListener('click', () => {
|
||||
const mcpPortVal = parseInt(document.getElementById('mcp-port-setting')?.value) || 3001;
|
||||
const cdpPortVal = parseInt(document.getElementById('cdp-port-setting')?.value) || 9223;
|
||||
const zapPortsVal = (document.getElementById('zap-ports-setting')?.value || '')
|
||||
.split(',').map(p => parseInt(p.trim())).filter(p => p > 0 && p < 65536);
|
||||
|
||||
chrome.storage.local.set({
|
||||
mcpPort: mcpPortVal,
|
||||
cdpPort: cdpPortVal,
|
||||
zapPorts: zapPortsVal.length ? zapPortsVal : [9999, 9998, 9997, 9996, 9995],
|
||||
}, () => {
|
||||
saveSettings.textContent = 'Saved!';
|
||||
setTimeout(() => { saveSettings.textContent = 'Save Settings'; }, 1500);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Test Connection ---
|
||||
testConnection?.addEventListener('click', () => {
|
||||
testConnection.textContent = 'Testing...';
|
||||
testConnection.disabled = true;
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'zap.discover' }, () => {
|
||||
refreshStatus();
|
||||
testConnection.textContent = 'Test Connection';
|
||||
testConnection.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Feature toggles
|
||||
['source-maps', 'webgpu-ai', 'browser-control', 'tab-filesystem'].forEach(id => {
|
||||
const checkbox = document.getElementById(id);
|
||||
if (checkbox) {
|
||||
chrome.storage.local.get([id], (result) => {
|
||||
checkbox.checked = result[id] !== false; // default on
|
||||
});
|
||||
checkbox.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ [id]: checkbox.checked });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Browser Backend Picker ---
|
||||
const backendSelect = document.getElementById('browser-backend') as HTMLSelectElement | null;
|
||||
const bridgeStatus = document.getElementById('bridge-status');
|
||||
const bridgeDetail = document.getElementById('bridge-detail');
|
||||
|
||||
function loadBackendPreference() {
|
||||
chrome.storage.sync.get(['browserBackend'], (result) => {
|
||||
if (backendSelect && result.browserBackend) {
|
||||
backendSelect.value = result.browserBackend;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshBridgeStatus() {
|
||||
chrome.runtime.sendMessage({ action: 'bridge.status' }, (response) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (response?.success && response.connected) {
|
||||
bridgeStatus?.classList.add('connected');
|
||||
bridgeStatus?.classList.remove('disconnected');
|
||||
const browsers = response.browsers || [];
|
||||
bridgeDetail.textContent = browsers.length
|
||||
? `Connected: ${browsers.join(', ')}`
|
||||
: 'Connected';
|
||||
} else {
|
||||
bridgeStatus?.classList.remove('connected');
|
||||
bridgeStatus?.classList.add('disconnected');
|
||||
bridgeDetail.textContent = 'Not connected';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
backendSelect?.addEventListener('change', () => {
|
||||
const value = backendSelect.value;
|
||||
|
||||
// Save to chrome.storage.sync (cross-device)
|
||||
chrome.storage.sync.set({ browserBackend: value });
|
||||
|
||||
// Forward to background to persist via CDP bridge + sync to IAM
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'config.save',
|
||||
key: 'browserBackend',
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
loadBackendPreference();
|
||||
refreshBridgeStatus();
|
||||
|
||||
// Init
|
||||
checkAuth();
|
||||
});
|
||||
@@ -178,6 +178,39 @@ body {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* React login card (shared @hanzo/ui primitives) */
|
||||
.chat-modern-login-card {
|
||||
width: min(320px, 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
radial-gradient(120% 180% at 0% 0%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 55%),
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-tertiary) 90%, black), var(--bg-secondary));
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 16px 30px color-mix(in srgb, black 28%, transparent);
|
||||
}
|
||||
|
||||
.chat-modern-login-card .chat-modern-auth-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chat-modern-login-card .auth-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-modern-login-card .auth-note a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.chat-modern-login-card .auth-note a:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ==================== Tab Bar ==================== */
|
||||
|
||||
.tab-bar {
|
||||
@@ -344,6 +377,12 @@ body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-modern-composer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.model-selector {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -359,6 +398,34 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-flags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.flag-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.flag-toggle input[type="checkbox"] {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.rag-status {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -385,6 +452,10 @@ body {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chat-modern-input {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -596,6 +667,27 @@ body {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tool-list-wrap {
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tool-list-header {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tool-list {
|
||||
max-height: 90px;
|
||||
overflow-y: auto;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-tertiary);
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* ==================== User Profile ==================== */
|
||||
|
||||
.user-profile {
|
||||
@@ -700,6 +792,7 @@ body {
|
||||
}
|
||||
|
||||
.setting-item input[type="text"],
|
||||
.setting-item input[type="password"],
|
||||
.setting-item select {
|
||||
width: 140px;
|
||||
padding: 6px 10px;
|
||||
@@ -710,6 +803,17 @@ body {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.setting-item-column {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.setting-item-column input[type="text"],
|
||||
.setting-item-column input[type="password"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-item input[type="number"] {
|
||||
width: 70px;
|
||||
padding: 6px 10px;
|
||||
|
||||
@@ -68,6 +68,17 @@
|
||||
<option value="zen-max">Zen Max</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="chat-flags">
|
||||
<label class="flag-toggle">
|
||||
<input type="checkbox" id="rag-enabled" checked>
|
||||
<span>RAG</span>
|
||||
</label>
|
||||
<label class="flag-toggle">
|
||||
<input type="checkbox" id="tab-context-enabled" checked>
|
||||
<span>Tab Context</span>
|
||||
</label>
|
||||
<span id="rag-status" class="rag-status">Ready</span>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<textarea id="chat-input" placeholder="Ask anything..." rows="1"></textarea>
|
||||
<button id="send-btn" class="send-btn" title="Send">
|
||||
@@ -97,6 +108,10 @@
|
||||
<span>Tools Available</span>
|
||||
<span id="mcp-tools" class="status-value">0</span>
|
||||
</div>
|
||||
<div class="tool-list-wrap">
|
||||
<div class="tool-list-header">Discovered Tools</div>
|
||||
<div id="mcp-tool-list" class="tool-list">No tools discovered yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -219,6 +234,35 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- RAG Settings -->
|
||||
<div class="panel">
|
||||
<h3>RAG Integration</h3>
|
||||
<label class="setting-item">
|
||||
<span>Use ZAP Memory</span>
|
||||
<input type="checkbox" id="rag-use-zap" checked>
|
||||
</label>
|
||||
<label class="setting-item">
|
||||
<span>Include Tab Context</span>
|
||||
<input type="checkbox" id="rag-include-tab-context" checked>
|
||||
</label>
|
||||
<label class="setting-item">
|
||||
<span>Top K</span>
|
||||
<input type="number" id="rag-top-k" value="5" min="1" max="20">
|
||||
</label>
|
||||
<label class="setting-item setting-item-column">
|
||||
<span>Knowledge Base</span>
|
||||
<input type="text" id="rag-kb" placeholder="e.g. engineering-docs">
|
||||
</label>
|
||||
<label class="setting-item setting-item-column">
|
||||
<span>RAG Endpoint</span>
|
||||
<input type="text" id="rag-endpoint" placeholder="https://your-rag.company.com/query">
|
||||
</label>
|
||||
<label class="setting-item setting-item-column">
|
||||
<span>RAG API Key</span>
|
||||
<input type="password" id="rag-api-key" placeholder="Optional bearer token">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button id="save-settings" class="primary-btn">Save Settings</button>
|
||||
|
||||
<!-- Links -->
|
||||
|
||||
@@ -7,6 +7,8 @@ class HanzoSidebar {
|
||||
this.messages = [];
|
||||
this.streaming = false;
|
||||
this.streamController = null;
|
||||
this.authenticated = false;
|
||||
this.ragContext = null;
|
||||
|
||||
this.initializeUI();
|
||||
this.setupEventListeners();
|
||||
@@ -28,11 +30,15 @@ class HanzoSidebar {
|
||||
chatInput: document.getElementById('chat-input'),
|
||||
sendBtn: document.getElementById('send-btn'),
|
||||
modelSelect: document.getElementById('model-select'),
|
||||
ragEnabled: document.getElementById('rag-enabled'),
|
||||
tabContextEnabled: document.getElementById('tab-context-enabled'),
|
||||
ragStatus: document.getElementById('rag-status'),
|
||||
|
||||
// Tools
|
||||
tabTools: document.getElementById('tab-tools'),
|
||||
mcpStatus: document.getElementById('mcp-status'),
|
||||
mcpTools: document.getElementById('mcp-tools'),
|
||||
mcpToolList: document.getElementById('mcp-tool-list'),
|
||||
agentCount: document.getElementById('agent-count'),
|
||||
agentList: document.getElementById('agent-list'),
|
||||
tabFs: document.getElementById('tab-fs'),
|
||||
@@ -49,6 +55,12 @@ class HanzoSidebar {
|
||||
logoutBtn: document.getElementById('logout-btn'),
|
||||
saveSettings: document.getElementById('save-settings'),
|
||||
defaultModel: document.getElementById('default-model'),
|
||||
ragUseZap: document.getElementById('rag-use-zap'),
|
||||
ragIncludeTabContext: document.getElementById('rag-include-tab-context'),
|
||||
ragTopK: document.getElementById('rag-top-k'),
|
||||
ragKb: document.getElementById('rag-kb'),
|
||||
ragEndpoint: document.getElementById('rag-endpoint'),
|
||||
ragApiKey: document.getElementById('rag-api-key'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,6 +82,13 @@ class HanzoSidebar {
|
||||
}
|
||||
});
|
||||
this.el.chatInput.addEventListener('input', () => this.autoResize());
|
||||
this.el.ragEnabled?.addEventListener('change', () => {
|
||||
this.setRagStatus(this.el.ragEnabled.checked ? 'RAG enabled' : 'RAG disabled');
|
||||
});
|
||||
this.el.tabContextEnabled?.addEventListener('change', () => {
|
||||
if (!this.el.ragEnabled?.checked) return;
|
||||
this.setRagStatus(this.el.tabContextEnabled.checked ? 'Tab context on' : 'Tab context off');
|
||||
});
|
||||
|
||||
// Tools
|
||||
this.el.refreshTabs.addEventListener('click', () => this.refreshTabFilesystem());
|
||||
@@ -172,6 +191,7 @@ class HanzoSidebar {
|
||||
}
|
||||
|
||||
hideChatLoginPrompt() {
|
||||
this.authenticated = true;
|
||||
const chatLogin = document.getElementById('chat-login-prompt');
|
||||
if (chatLogin) chatLogin.classList.add('hidden');
|
||||
const chatComposer = document.getElementById('chat-composer');
|
||||
@@ -223,6 +243,10 @@ class HanzoSidebar {
|
||||
async sendMessage() {
|
||||
const text = this.el.chatInput.value.trim();
|
||||
if (!text || this.streaming) return;
|
||||
if (!this.authenticated) {
|
||||
this.showError('Please sign in to use chat');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get token
|
||||
const tokenResp = await chrome.runtime.sendMessage({ action: 'auth.getToken' });
|
||||
@@ -231,8 +255,32 @@ class HanzoSidebar {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
this.messages.push({ role: 'user', content: text });
|
||||
const userMessage = { role: 'user', content: text };
|
||||
const model = this.el.modelSelect.value;
|
||||
let requestMessages = [...this.messages, userMessage];
|
||||
|
||||
this.ragContext = null;
|
||||
if (this.el.ragEnabled?.checked) {
|
||||
this.setRagStatus('Retrieving context...');
|
||||
const ragResp = await this.requestRagContext(text);
|
||||
if (ragResp?.success && Array.isArray(ragResp.snippets) && ragResp.snippets.length) {
|
||||
this.ragContext = ragResp;
|
||||
requestMessages = [
|
||||
{ role: 'system', content: this.buildRagSystemPrompt(ragResp) },
|
||||
...requestMessages,
|
||||
];
|
||||
this.setRagStatus(`RAG: ${ragResp.snippets.length} snippet(s) from ${ragResp.source || 'context'}`);
|
||||
} else if (ragResp?.error) {
|
||||
this.setRagStatus(`RAG unavailable (${ragResp.error})`);
|
||||
} else {
|
||||
this.setRagStatus('No context found');
|
||||
}
|
||||
} else {
|
||||
this.setRagStatus('RAG disabled');
|
||||
}
|
||||
|
||||
// Add user message to transcript
|
||||
this.messages.push(userMessage);
|
||||
this.appendMessage('user', text);
|
||||
|
||||
// Clear input
|
||||
@@ -250,7 +298,6 @@ class HanzoSidebar {
|
||||
|
||||
try {
|
||||
// Stream via direct fetch (sidebar has access via CSP)
|
||||
const model = this.el.modelSelect.value;
|
||||
const response = await fetch('https://api.hanzo.ai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -259,7 +306,7 @@ class HanzoSidebar {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: this.messages,
|
||||
messages: requestMessages,
|
||||
stream: true,
|
||||
}),
|
||||
signal: (this.streamController = new AbortController()).signal,
|
||||
@@ -302,13 +349,18 @@ class HanzoSidebar {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.ragContext?.snippets?.length) {
|
||||
fullContent += this.buildRagCitationBlock(this.ragContext);
|
||||
assistantEl.innerHTML = this.renderMarkdown(fullContent);
|
||||
}
|
||||
|
||||
this.messages.push({ role: 'assistant', content: fullContent });
|
||||
this.saveConversation();
|
||||
} catch (error) {
|
||||
typingEl.remove();
|
||||
if (error.name !== 'AbortError') {
|
||||
if (error?.name !== 'AbortError') {
|
||||
assistantEl.remove();
|
||||
this.appendError(error.message);
|
||||
this.appendError(error?.message || 'Chat failed');
|
||||
}
|
||||
} finally {
|
||||
this.streaming = false;
|
||||
@@ -317,6 +369,77 @@ class HanzoSidebar {
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveTabContext() {
|
||||
try {
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!activeTab) return null;
|
||||
return {
|
||||
url: activeTab.url || '',
|
||||
title: activeTab.title || '',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async requestRagContext(query) {
|
||||
try {
|
||||
const pageContext = this.el.tabContextEnabled?.checked ? await this.getActiveTabContext() : null;
|
||||
const topK = Math.max(1, Math.min(parseInt(this.el.ragTopK?.value || '5', 10) || 5, 20));
|
||||
|
||||
return await chrome.runtime.sendMessage({
|
||||
action: 'rag.query',
|
||||
query,
|
||||
topK,
|
||||
includeTabContext: !!this.el.tabContextEnabled?.checked,
|
||||
useZapMemory: !!this.el.ragUseZap?.checked,
|
||||
knowledgeBase: this.el.ragKb?.value || '',
|
||||
endpoint: this.el.ragEndpoint?.value || '',
|
||||
apiKey: this.el.ragApiKey?.value || '',
|
||||
pageContext: pageContext || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
return { success: false, error: error?.message || 'Failed to query RAG' };
|
||||
}
|
||||
}
|
||||
|
||||
buildRagSystemPrompt(ragResponse) {
|
||||
const snippets = (ragResponse?.snippets || []).slice(0, 8);
|
||||
const lines = snippets.map((snippet, index) => {
|
||||
const title = snippet.title || snippet.source || `Snippet ${index + 1}`;
|
||||
const score = typeof snippet.score === 'number' ? ` (score ${snippet.score.toFixed(3)})` : '';
|
||||
const sourceLine = snippet.url ? `${title}${score} — ${snippet.url}` : `${title}${score}`;
|
||||
return `[${index + 1}] ${sourceLine}\n${snippet.content}`;
|
||||
});
|
||||
|
||||
return [
|
||||
'You are assisting inside Hanzo Browser Extension.',
|
||||
'Use the retrieved context below when relevant, and state uncertainty if context is insufficient.',
|
||||
'Retrieved Context:',
|
||||
...lines,
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
buildRagCitationBlock(ragResponse) {
|
||||
const snippets = (ragResponse?.snippets || []).slice(0, 5);
|
||||
if (!snippets.length) return '';
|
||||
|
||||
const rows = snippets.map((snippet, index) => {
|
||||
const title = snippet.title || snippet.source || `Snippet ${index + 1}`;
|
||||
if (snippet.url) {
|
||||
return `- ${title}: ${snippet.url}`;
|
||||
}
|
||||
return `- ${title}`;
|
||||
});
|
||||
|
||||
return `\n\n---\nContext Sources:\n${rows.join('\n')}`;
|
||||
}
|
||||
|
||||
setRagStatus(text) {
|
||||
if (!this.el.ragStatus) return;
|
||||
this.el.ragStatus.textContent = text || 'Ready';
|
||||
}
|
||||
|
||||
appendMessage(role, content) {
|
||||
// Remove welcome message
|
||||
const welcome = this.el.chatMessages.querySelector('.chat-welcome');
|
||||
@@ -422,11 +545,19 @@ class HanzoSidebar {
|
||||
const zap = zapResponse.zap;
|
||||
const toolCount = zap.mcps?.reduce((sum, m) => sum + (m.tools?.length || 0), 0) || 0;
|
||||
this.updateMCPStatus(zap.connected, toolCount, zap.mcps?.length || 0);
|
||||
if (zap.connected) {
|
||||
const toolsResp = await chrome.runtime.sendMessage({ action: 'zap.listTools' });
|
||||
this.updateMCPToolList(toolsResp?.success ? toolsResp.tools : []);
|
||||
} else {
|
||||
this.updateMCPToolList([]);
|
||||
}
|
||||
} else {
|
||||
this.updateMCPStatus(false);
|
||||
this.updateMCPToolList([]);
|
||||
}
|
||||
} catch {
|
||||
this.updateMCPStatus(false);
|
||||
this.updateMCPToolList([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +572,19 @@ class HanzoSidebar {
|
||||
this.el.mcpTools.textContent = toolCount;
|
||||
}
|
||||
|
||||
updateMCPToolList(tools) {
|
||||
if (!this.el.mcpToolList) return;
|
||||
if (!tools || !tools.length) {
|
||||
this.el.mcpToolList.textContent = 'No tools discovered yet.';
|
||||
return;
|
||||
}
|
||||
|
||||
const names = Array.from(new Set(tools.map((tool) => tool.name))).sort();
|
||||
const preview = names.slice(0, 20);
|
||||
const suffix = names.length > preview.length ? `\n... +${names.length - preview.length} more` : '';
|
||||
this.el.mcpToolList.textContent = `${preview.join('\n')}${suffix}`;
|
||||
}
|
||||
|
||||
async refreshTabFilesystem() {
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
@@ -599,6 +743,7 @@ class HanzoSidebar {
|
||||
}
|
||||
|
||||
const agentType = typeSelect.value;
|
||||
const agentInstructions = instructions.value?.trim() || '';
|
||||
overlay.remove();
|
||||
|
||||
try {
|
||||
@@ -606,6 +751,7 @@ class HanzoSidebar {
|
||||
action: 'launchAIWorker',
|
||||
tabId,
|
||||
model: agentType,
|
||||
instructions: agentInstructions,
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
@@ -632,7 +778,20 @@ class HanzoSidebar {
|
||||
// ===========================================================================
|
||||
|
||||
loadSettings() {
|
||||
chrome.storage.local.get(['mcpPort', 'cdpPort', 'zapPorts', 'hanzo_default_model'], (result) => {
|
||||
chrome.storage.local.get([
|
||||
'mcpPort',
|
||||
'cdpPort',
|
||||
'zapPorts',
|
||||
'hanzo_default_model',
|
||||
'hanzo_rag_endpoint',
|
||||
'hanzo_rag_api_key',
|
||||
'hanzo_rag_kb',
|
||||
'hanzo_rag_top_k',
|
||||
'hanzo_rag_include_tab_context',
|
||||
'hanzo_rag_use_zap',
|
||||
'hanzo_chat_rag_enabled',
|
||||
'hanzo_chat_tab_context_enabled',
|
||||
], (result) => {
|
||||
const mcpPort = document.getElementById('mcp-port-setting');
|
||||
const cdpPort = document.getElementById('cdp-port-setting');
|
||||
const zapPorts = document.getElementById('zap-ports-setting');
|
||||
@@ -643,6 +802,16 @@ class HanzoSidebar {
|
||||
if (result.hanzo_default_model && this.el.defaultModel) {
|
||||
this.el.defaultModel.value = result.hanzo_default_model;
|
||||
}
|
||||
|
||||
if (this.el.ragEndpoint) this.el.ragEndpoint.value = result.hanzo_rag_endpoint || '';
|
||||
if (this.el.ragApiKey) this.el.ragApiKey.value = result.hanzo_rag_api_key || '';
|
||||
if (this.el.ragKb) this.el.ragKb.value = result.hanzo_rag_kb || '';
|
||||
if (this.el.ragTopK) this.el.ragTopK.value = String(result.hanzo_rag_top_k || 5);
|
||||
if (this.el.ragIncludeTabContext) this.el.ragIncludeTabContext.checked = result.hanzo_rag_include_tab_context !== false;
|
||||
if (this.el.ragUseZap) this.el.ragUseZap.checked = result.hanzo_rag_use_zap !== false;
|
||||
if (this.el.ragEnabled) this.el.ragEnabled.checked = result.hanzo_chat_rag_enabled !== false;
|
||||
if (this.el.tabContextEnabled) this.el.tabContextEnabled.checked = result.hanzo_chat_tab_context_enabled !== false;
|
||||
this.setRagStatus(this.el.ragEnabled?.checked ? 'Ready' : 'RAG disabled');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -660,6 +829,14 @@ class HanzoSidebar {
|
||||
'safe-mode': document.getElementById('safe-mode')?.checked,
|
||||
'enable-webgpu': document.getElementById('enable-webgpu')?.checked,
|
||||
maxAgents: parseInt(document.getElementById('max-agents')?.value) || 3,
|
||||
hanzo_rag_endpoint: this.el.ragEndpoint?.value?.trim() || '',
|
||||
hanzo_rag_api_key: this.el.ragApiKey?.value?.trim() || '',
|
||||
hanzo_rag_kb: this.el.ragKb?.value?.trim() || '',
|
||||
hanzo_rag_top_k: Math.max(1, Math.min(parseInt(this.el.ragTopK?.value || '5', 10) || 5, 20)),
|
||||
hanzo_rag_include_tab_context: !!this.el.ragIncludeTabContext?.checked,
|
||||
hanzo_rag_use_zap: !!this.el.ragUseZap?.checked,
|
||||
hanzo_chat_rag_enabled: !!this.el.ragEnabled?.checked,
|
||||
hanzo_chat_tab_context_enabled: !!this.el.tabContextEnabled?.checked,
|
||||
});
|
||||
|
||||
this.showNotification('Settings saved');
|
||||
|
||||
@@ -0,0 +1,887 @@
|
||||
import { mountModernChatWidget } from './chat-widget';
|
||||
|
||||
// Hanzo AI Browser Extension Sidebar — Chat + Tools + Settings
|
||||
|
||||
class HanzoSidebar {
|
||||
constructor() {
|
||||
this.agents = new Map();
|
||||
this.currentTab = 'chat';
|
||||
this.messages = [];
|
||||
this.streaming = false;
|
||||
this.streamController = null;
|
||||
this.authenticated = false;
|
||||
this.ragContext = null;
|
||||
|
||||
mountModernChatWidget();
|
||||
this.initializeUI();
|
||||
this.setupEventListeners();
|
||||
this.checkAuth();
|
||||
}
|
||||
|
||||
initializeUI() {
|
||||
this.el = {
|
||||
// Auth
|
||||
authSection: document.getElementById('auth-section'),
|
||||
authBtn: document.getElementById('auth-btn'),
|
||||
tabBar: document.getElementById('tab-bar'),
|
||||
userBadge: document.getElementById('user-badge'),
|
||||
headerAvatar: document.getElementById('header-avatar'),
|
||||
|
||||
// Chat
|
||||
tabChat: document.getElementById('tab-chat'),
|
||||
chatMessages: document.getElementById('chat-messages'),
|
||||
chatInput: document.getElementById('chat-input'),
|
||||
sendBtn: document.getElementById('send-btn'),
|
||||
modelSelect: document.getElementById('model-select'),
|
||||
ragEnabled: document.getElementById('rag-enabled'),
|
||||
tabContextEnabled: document.getElementById('tab-context-enabled'),
|
||||
ragStatus: document.getElementById('rag-status'),
|
||||
|
||||
// Tools
|
||||
tabTools: document.getElementById('tab-tools'),
|
||||
mcpStatus: document.getElementById('mcp-status'),
|
||||
mcpTools: document.getElementById('mcp-tools'),
|
||||
mcpToolList: document.getElementById('mcp-tool-list'),
|
||||
agentCount: document.getElementById('agent-count'),
|
||||
agentList: document.getElementById('agent-list'),
|
||||
tabFs: document.getElementById('tab-fs'),
|
||||
refreshTabs: document.getElementById('refresh-tabs'),
|
||||
gpuStatus: document.getElementById('gpu-status'),
|
||||
gpuModel: document.getElementById('gpu-model'),
|
||||
launchAgent: document.getElementById('launch-agent'),
|
||||
|
||||
// Settings
|
||||
tabSettings: document.getElementById('tab-settings'),
|
||||
userAvatar: document.getElementById('user-avatar'),
|
||||
userName: document.getElementById('user-name'),
|
||||
userEmail: document.getElementById('user-email'),
|
||||
logoutBtn: document.getElementById('logout-btn'),
|
||||
saveSettings: document.getElementById('save-settings'),
|
||||
defaultModel: document.getElementById('default-model'),
|
||||
ragUseZap: document.getElementById('rag-use-zap'),
|
||||
ragIncludeTabContext: document.getElementById('rag-include-tab-context'),
|
||||
ragTopK: document.getElementById('rag-top-k'),
|
||||
ragKb: document.getElementById('rag-kb'),
|
||||
ragEndpoint: document.getElementById('rag-endpoint'),
|
||||
ragApiKey: document.getElementById('rag-api-key'),
|
||||
};
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Auth
|
||||
this.el.authBtn.addEventListener('click', () => this.login());
|
||||
|
||||
// Tabs
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => this.switchTab(tab.dataset.tab));
|
||||
});
|
||||
|
||||
// Chat
|
||||
this.el.sendBtn.addEventListener('click', () => this.sendMessage());
|
||||
this.el.chatInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
});
|
||||
this.el.chatInput.addEventListener('input', () => this.autoResize());
|
||||
this.el.ragEnabled?.addEventListener('change', () => {
|
||||
this.setRagStatus(this.el.ragEnabled.checked ? 'RAG enabled' : 'RAG disabled');
|
||||
});
|
||||
this.el.tabContextEnabled?.addEventListener('change', () => {
|
||||
if (!this.el.ragEnabled?.checked) return;
|
||||
this.setRagStatus(this.el.tabContextEnabled.checked ? 'Tab context on' : 'Tab context off');
|
||||
});
|
||||
|
||||
// Tools
|
||||
this.el.refreshTabs.addEventListener('click', () => this.refreshTabFilesystem());
|
||||
this.el.launchAgent.addEventListener('click', () => this.showAgentLauncher());
|
||||
|
||||
// Settings
|
||||
this.el.logoutBtn.addEventListener('click', () => this.logout());
|
||||
this.el.saveSettings.addEventListener('click', () => this.saveSettings());
|
||||
|
||||
// Background messages
|
||||
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||||
this.handleMessage(request);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Auth
|
||||
// ===========================================================================
|
||||
|
||||
async checkAuth() {
|
||||
// Always show tabs and initialize tools — auth only gates chat
|
||||
this.el.tabBar.classList.remove('hidden');
|
||||
this.el.authSection.classList.add('hidden');
|
||||
this.switchTab('tools'); // Default to tools (always works)
|
||||
|
||||
// Initialize features that work without auth
|
||||
this.connectToMCP();
|
||||
this.refreshTabFilesystem();
|
||||
this.checkWebGPU();
|
||||
this.loadSettings();
|
||||
this.startMonitoring();
|
||||
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({ action: 'auth.status' });
|
||||
if (response?.success && response.authenticated) {
|
||||
this.setUser(response.user);
|
||||
this.switchTab('chat');
|
||||
} else {
|
||||
this.showChatLoginPrompt();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
this.showChatLoginPrompt();
|
||||
}
|
||||
}
|
||||
|
||||
async login() {
|
||||
this.el.authBtn.disabled = true;
|
||||
this.el.authBtn.textContent = 'Signing in...';
|
||||
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({ action: 'auth.login' });
|
||||
if (response?.success) {
|
||||
this.setUser(response.user);
|
||||
this.hideChatLoginPrompt();
|
||||
} else {
|
||||
this.showError(response?.error || 'Sign in failed');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showError('Sign in failed');
|
||||
} finally {
|
||||
this.el.authBtn.disabled = false;
|
||||
this.el.authBtn.textContent = 'Sign in with Hanzo';
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await chrome.runtime.sendMessage({ action: 'auth.logout' });
|
||||
this.el.userBadge.classList.add('hidden');
|
||||
this.el.userName.textContent = '';
|
||||
this.el.userEmail.textContent = '';
|
||||
this.showChatLoginPrompt();
|
||||
this.switchTab('tools');
|
||||
}
|
||||
|
||||
setUser(user) {
|
||||
this.authenticated = true;
|
||||
if (user) {
|
||||
const avatar = user.picture || user.avatar || '';
|
||||
if (avatar) {
|
||||
this.el.headerAvatar.src = avatar;
|
||||
this.el.userBadge.classList.remove('hidden');
|
||||
this.el.userAvatar.src = avatar;
|
||||
}
|
||||
this.el.userName.textContent = user.name || user.displayName || 'User';
|
||||
this.el.userEmail.textContent = user.email || '';
|
||||
}
|
||||
this.loadModels();
|
||||
this.loadConversation();
|
||||
}
|
||||
|
||||
showChatLoginPrompt() {
|
||||
this.authenticated = false;
|
||||
// Show login prompt in chat area instead of blocking entire sidebar
|
||||
const chatLogin = document.getElementById('chat-login-prompt');
|
||||
if (chatLogin) chatLogin.classList.remove('hidden');
|
||||
const chatComposer = document.getElementById('chat-composer');
|
||||
if (chatComposer) chatComposer.classList.add('hidden');
|
||||
}
|
||||
|
||||
hideChatLoginPrompt() {
|
||||
this.authenticated = true;
|
||||
const chatLogin = document.getElementById('chat-login-prompt');
|
||||
if (chatLogin) chatLogin.classList.add('hidden');
|
||||
const chatComposer = document.getElementById('chat-composer');
|
||||
if (chatComposer) chatComposer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tab Navigation
|
||||
// ===========================================================================
|
||||
|
||||
switchTab(name) {
|
||||
this.currentTab = name;
|
||||
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.tab').forEach(t => {
|
||||
t.classList.toggle('active', t.dataset.tab === name);
|
||||
});
|
||||
|
||||
// Show/hide content
|
||||
document.querySelectorAll('.tab-content').forEach(t => t.classList.add('hidden'));
|
||||
const target = document.getElementById(`tab-${name}`);
|
||||
if (target) target.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Chat
|
||||
// ===========================================================================
|
||||
|
||||
async loadModels() {
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({ action: 'chat.listModels' });
|
||||
if (response?.success && response.models?.length) {
|
||||
const select = this.el.modelSelect;
|
||||
select.innerHTML = '';
|
||||
for (const model of response.models) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = model.id;
|
||||
opt.textContent = model.name || model.id;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
// Also populate settings default model
|
||||
this.el.defaultModel.innerHTML = select.innerHTML;
|
||||
}
|
||||
} catch {
|
||||
// Keep default options
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
const text = this.el.chatInput.value.trim();
|
||||
if (!text || this.streaming) return;
|
||||
if (!this.authenticated) {
|
||||
this.showError('Please sign in to use chat');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get token
|
||||
const tokenResp = await chrome.runtime.sendMessage({ action: 'auth.getToken' });
|
||||
if (!tokenResp?.success || !tokenResp.token) {
|
||||
this.showError('Please sign in again');
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage = { role: 'user', content: text };
|
||||
const model = this.el.modelSelect.value;
|
||||
let requestMessages = [...this.messages, userMessage];
|
||||
|
||||
this.ragContext = null;
|
||||
if (this.el.ragEnabled?.checked) {
|
||||
this.setRagStatus('Retrieving context...');
|
||||
const ragResp = await this.requestRagContext(text);
|
||||
if (ragResp?.success && Array.isArray(ragResp.snippets) && ragResp.snippets.length) {
|
||||
this.ragContext = ragResp;
|
||||
requestMessages = [
|
||||
{ role: 'system', content: this.buildRagSystemPrompt(ragResp) },
|
||||
...requestMessages,
|
||||
];
|
||||
this.setRagStatus(`RAG: ${ragResp.snippets.length} snippet(s) from ${ragResp.source || 'context'}`);
|
||||
} else if (ragResp?.error) {
|
||||
this.setRagStatus(`RAG unavailable (${ragResp.error})`);
|
||||
} else {
|
||||
this.setRagStatus('No context found');
|
||||
}
|
||||
} else {
|
||||
this.setRagStatus('RAG disabled');
|
||||
}
|
||||
|
||||
// Add user message to transcript
|
||||
this.messages.push(userMessage);
|
||||
this.appendMessage('user', text);
|
||||
|
||||
// Clear input
|
||||
this.el.chatInput.value = '';
|
||||
this.autoResize();
|
||||
|
||||
// Show typing
|
||||
this.streaming = true;
|
||||
this.el.sendBtn.disabled = true;
|
||||
const typingEl = this.showTyping();
|
||||
|
||||
// Create assistant message element
|
||||
const assistantEl = this.appendMessage('assistant', '');
|
||||
let fullContent = '';
|
||||
|
||||
try {
|
||||
// Stream via direct fetch (sidebar has access via CSP)
|
||||
const response = await fetch('https://api.hanzo.ai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${tokenResp.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: requestMessages,
|
||||
stream: true,
|
||||
}),
|
||||
signal: (this.streamController = new AbortController()).signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(`API error ${response.status}: ${errText}`);
|
||||
}
|
||||
|
||||
typingEl.remove();
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith(':')) continue;
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
const data = trimmed.slice(6);
|
||||
if (data === '[DONE]') break;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const delta = parsed.choices?.[0]?.delta?.content;
|
||||
if (delta) {
|
||||
fullContent += delta;
|
||||
assistantEl.innerHTML = this.renderMarkdown(fullContent);
|
||||
this.scrollToBottom();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.ragContext?.snippets?.length) {
|
||||
fullContent += this.buildRagCitationBlock(this.ragContext);
|
||||
assistantEl.innerHTML = this.renderMarkdown(fullContent);
|
||||
}
|
||||
|
||||
this.messages.push({ role: 'assistant', content: fullContent });
|
||||
this.saveConversation();
|
||||
} catch (error) {
|
||||
typingEl.remove();
|
||||
if (error?.name !== 'AbortError') {
|
||||
assistantEl.remove();
|
||||
this.appendError(error?.message || 'Chat failed');
|
||||
}
|
||||
} finally {
|
||||
this.streaming = false;
|
||||
this.streamController = null;
|
||||
this.el.sendBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveTabContext() {
|
||||
try {
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!activeTab) return null;
|
||||
return {
|
||||
url: activeTab.url || '',
|
||||
title: activeTab.title || '',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async requestRagContext(query) {
|
||||
try {
|
||||
const pageContext = this.el.tabContextEnabled?.checked ? await this.getActiveTabContext() : null;
|
||||
const topK = Math.max(1, Math.min(parseInt(this.el.ragTopK?.value || '5', 10) || 5, 20));
|
||||
|
||||
return await chrome.runtime.sendMessage({
|
||||
action: 'rag.query',
|
||||
query,
|
||||
topK,
|
||||
includeTabContext: !!this.el.tabContextEnabled?.checked,
|
||||
useZapMemory: !!this.el.ragUseZap?.checked,
|
||||
knowledgeBase: this.el.ragKb?.value || '',
|
||||
endpoint: this.el.ragEndpoint?.value || '',
|
||||
apiKey: this.el.ragApiKey?.value || '',
|
||||
pageContext: pageContext || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
return { success: false, error: error?.message || 'Failed to query RAG' };
|
||||
}
|
||||
}
|
||||
|
||||
buildRagSystemPrompt(ragResponse) {
|
||||
const snippets = (ragResponse?.snippets || []).slice(0, 8);
|
||||
const lines = snippets.map((snippet, index) => {
|
||||
const title = snippet.title || snippet.source || `Snippet ${index + 1}`;
|
||||
const score = typeof snippet.score === 'number' ? ` (score ${snippet.score.toFixed(3)})` : '';
|
||||
const sourceLine = snippet.url ? `${title}${score} — ${snippet.url}` : `${title}${score}`;
|
||||
return `[${index + 1}] ${sourceLine}\n${snippet.content}`;
|
||||
});
|
||||
|
||||
return [
|
||||
'You are assisting inside Hanzo Browser Extension.',
|
||||
'Use the retrieved context below when relevant, and state uncertainty if context is insufficient.',
|
||||
'Retrieved Context:',
|
||||
...lines,
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
buildRagCitationBlock(ragResponse) {
|
||||
const snippets = (ragResponse?.snippets || []).slice(0, 5);
|
||||
if (!snippets.length) return '';
|
||||
|
||||
const rows = snippets.map((snippet, index) => {
|
||||
const title = snippet.title || snippet.source || `Snippet ${index + 1}`;
|
||||
if (snippet.url) {
|
||||
return `- ${title}: ${snippet.url}`;
|
||||
}
|
||||
return `- ${title}`;
|
||||
});
|
||||
|
||||
return `\n\n---\nContext Sources:\n${rows.join('\n')}`;
|
||||
}
|
||||
|
||||
setRagStatus(text) {
|
||||
if (!this.el.ragStatus) return;
|
||||
this.el.ragStatus.textContent = text || 'Ready';
|
||||
}
|
||||
|
||||
appendMessage(role, content) {
|
||||
// Remove welcome message
|
||||
const welcome = this.el.chatMessages.querySelector('.chat-welcome');
|
||||
if (welcome) welcome.remove();
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = `msg msg-${role}`;
|
||||
div.innerHTML = role === 'assistant' ? this.renderMarkdown(content) : this.escapeHtml(content);
|
||||
this.el.chatMessages.appendChild(div);
|
||||
this.scrollToBottom();
|
||||
return div;
|
||||
}
|
||||
|
||||
appendError(message) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg-error';
|
||||
div.textContent = message;
|
||||
this.el.chatMessages.appendChild(div);
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
showTyping() {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'typing-indicator';
|
||||
div.innerHTML = '<span></span><span></span><span></span>';
|
||||
this.el.chatMessages.appendChild(div);
|
||||
this.scrollToBottom();
|
||||
return div;
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
this.el.chatMessages.scrollTop = this.el.chatMessages.scrollHeight;
|
||||
}
|
||||
|
||||
autoResize() {
|
||||
const el = this.el.chatInput;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
||||
}
|
||||
|
||||
// Minimal markdown: code blocks, inline code, bold, italic, line breaks
|
||||
renderMarkdown(text) {
|
||||
if (!text) return '';
|
||||
let html = this.escapeHtml(text);
|
||||
|
||||
// Code blocks: ```lang\n...\n```
|
||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
|
||||
return `<pre><code>${code.trim()}</code></pre>`;
|
||||
});
|
||||
|
||||
// Inline code: `...`
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
// Bold: **...**
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic: *...*
|
||||
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
|
||||
// Line breaks
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async loadConversation() {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(['hanzo_chat_messages'], (result) => {
|
||||
if (result.hanzo_chat_messages?.length) {
|
||||
this.messages = result.hanzo_chat_messages;
|
||||
// Render stored messages
|
||||
const welcome = this.el.chatMessages.querySelector('.chat-welcome');
|
||||
if (welcome) welcome.remove();
|
||||
for (const msg of this.messages) {
|
||||
this.appendMessage(msg.role, msg.content);
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
saveConversation() {
|
||||
// Keep last 50 messages
|
||||
const toStore = this.messages.slice(-50);
|
||||
chrome.storage.local.set({ hanzo_chat_messages: toStore });
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tools Tab
|
||||
// ===========================================================================
|
||||
|
||||
async connectToMCP() {
|
||||
try {
|
||||
const zapResponse = await chrome.runtime.sendMessage({ action: 'zap.status' });
|
||||
if (zapResponse?.success) {
|
||||
const zap = zapResponse.zap;
|
||||
const toolCount = zap.mcps?.reduce((sum, m) => sum + (m.tools?.length || 0), 0) || 0;
|
||||
this.updateMCPStatus(zap.connected, toolCount, zap.mcps?.length || 0);
|
||||
if (zap.connected) {
|
||||
const toolsResp = await chrome.runtime.sendMessage({ action: 'zap.listTools' });
|
||||
this.updateMCPToolList(toolsResp?.success ? toolsResp.tools : []);
|
||||
} else {
|
||||
this.updateMCPToolList([]);
|
||||
}
|
||||
} else {
|
||||
this.updateMCPStatus(false);
|
||||
this.updateMCPToolList([]);
|
||||
}
|
||||
} catch {
|
||||
this.updateMCPStatus(false);
|
||||
this.updateMCPToolList([]);
|
||||
}
|
||||
}
|
||||
|
||||
updateMCPStatus(connected, toolCount = 0, mcpCount = 0) {
|
||||
this.el.mcpStatus.innerHTML = connected ? `
|
||||
<span class="status-indicator connected"></span>
|
||||
${mcpCount > 0 ? `ZAP: ${mcpCount} MCP(s)` : 'Connected'}
|
||||
` : `
|
||||
<span class="status-indicator disconnected"></span>
|
||||
Disconnected
|
||||
`;
|
||||
this.el.mcpTools.textContent = toolCount;
|
||||
}
|
||||
|
||||
updateMCPToolList(tools) {
|
||||
if (!this.el.mcpToolList) return;
|
||||
if (!tools || !tools.length) {
|
||||
this.el.mcpToolList.textContent = 'No tools discovered yet.';
|
||||
return;
|
||||
}
|
||||
|
||||
const names = Array.from(new Set(tools.map((tool) => tool.name))).sort();
|
||||
const preview = names.slice(0, 20);
|
||||
const suffix = names.length > preview.length ? `\n... +${names.length - preview.length} more` : '';
|
||||
this.el.mcpToolList.textContent = `${preview.join('\n')}${suffix}`;
|
||||
}
|
||||
|
||||
async refreshTabFilesystem() {
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
this.el.tabFs.innerHTML = tabs.map((tab, i) => `
|
||||
<div class="tab-item ${tab.active ? 'active' : ''}" data-tab-id="${tab.id}">
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2 2v12h12V2H2zm1 1h10v10H3V3z"/>
|
||||
</svg>
|
||||
<span title="${this.escapeHtml(tab.url || '')}">/tabs/${i}/${this.sanitizePath(tab.title || 'untitled')}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
this.el.tabFs.querySelectorAll('.tab-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
chrome.tabs.update(parseInt(item.dataset.tabId), { active: true });
|
||||
});
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
sanitizePath(title) {
|
||||
return title.replace(/[^a-zA-Z0-9-_]/g, '_').toLowerCase().substring(0, 30);
|
||||
}
|
||||
|
||||
async checkWebGPU() {
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({ action: 'checkWebGPU' });
|
||||
if (response?.available) {
|
||||
this.el.gpuStatus.innerHTML = `
|
||||
<span class="status-indicator connected"></span>
|
||||
Available
|
||||
`;
|
||||
this.el.gpuModel.textContent = response.adapter || 'Unknown';
|
||||
} else {
|
||||
this.el.gpuStatus.innerHTML = `
|
||||
<span class="status-indicator disconnected"></span>
|
||||
Not Available
|
||||
`;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
updateAgentList() {
|
||||
const arr = Array.from(this.agents.values());
|
||||
this.el.agentCount.textContent = arr.length;
|
||||
if (arr.length === 0) {
|
||||
this.el.agentList.innerHTML = '<div class="empty-state">No active agents</div>';
|
||||
return;
|
||||
}
|
||||
this.el.agentList.innerHTML = arr.map(a => `
|
||||
<div class="agent-item">
|
||||
<div class="agent-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<circle cx="12" cy="12" r="10" stroke-width="2"/>
|
||||
<path d="M12 6v6l4 2" stroke-width="2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="agent-info">
|
||||
<div class="agent-name">${this.escapeHtml(a.name)}</div>
|
||||
<div class="agent-status">${a.status} • Tab ${a.tabId}</div>
|
||||
</div>
|
||||
<div class="agent-actions">
|
||||
<button class="icon-btn small" data-stop="${a.id}" title="Stop">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor">
|
||||
<rect x="4" y="4" width="8" height="8" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
this.el.agentList.querySelectorAll('[data-stop]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const id = btn.dataset.stop;
|
||||
chrome.runtime.sendMessage({ action: 'stopAgent', agentId: id });
|
||||
this.agents.delete(id);
|
||||
this.updateAgentList();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showAgentLauncher() {
|
||||
// Create agent launcher modal overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Launch Agent</h3>
|
||||
<button class="icon-btn close-modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="setting-item">
|
||||
<span>Target Tab</span>
|
||||
<select id="agent-tab-select">
|
||||
<option value="active">Active Tab</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setting-item">
|
||||
<span>Agent Type</span>
|
||||
<select id="agent-type-select">
|
||||
<option value="browser-control">Browser Automation</option>
|
||||
<option value="page-analyzer">Page Analyzer</option>
|
||||
<option value="form-filler">Form Filler</option>
|
||||
<option value="data-extractor">Data Extractor</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setting-item">
|
||||
<span>Instructions</span>
|
||||
<textarea id="agent-instructions" rows="3" placeholder="Describe what the agent should do..."></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="secondary-btn cancel-modal">Cancel</button>
|
||||
<button class="primary-btn launch-modal">Launch</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Populate tabs
|
||||
chrome.tabs.query({}, (tabs) => {
|
||||
const select = overlay.querySelector('#agent-tab-select');
|
||||
tabs.forEach(tab => {
|
||||
if (tab.id) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = tab.id;
|
||||
opt.textContent = `${tab.title?.substring(0, 40) || 'Untitled'} ${tab.active ? '(active)' : ''}`;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Close handlers
|
||||
overlay.querySelector('.close-modal').addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('.cancel-modal').addEventListener('click', () => overlay.remove());
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
|
||||
|
||||
// Launch handler
|
||||
overlay.querySelector('.launch-modal').addEventListener('click', async () => {
|
||||
const tabSelect = overlay.querySelector('#agent-tab-select');
|
||||
const typeSelect = overlay.querySelector('#agent-type-select');
|
||||
const instructions = overlay.querySelector('#agent-instructions');
|
||||
|
||||
let tabId;
|
||||
if (tabSelect.value === 'active') {
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
tabId = activeTab?.id;
|
||||
} else {
|
||||
tabId = parseInt(tabSelect.value);
|
||||
}
|
||||
|
||||
if (!tabId) {
|
||||
this.showError('No tab selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const agentType = typeSelect.value;
|
||||
const agentInstructions = instructions.value?.trim() || '';
|
||||
overlay.remove();
|
||||
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
action: 'launchAIWorker',
|
||||
tabId,
|
||||
model: agentType,
|
||||
instructions: agentInstructions,
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
const agentId = response.agentId || `agent-${Date.now().toString(36)}`;
|
||||
this.agents.set(agentId, {
|
||||
id: agentId,
|
||||
name: agentType,
|
||||
tabId,
|
||||
status: 'running',
|
||||
});
|
||||
this.updateAgentList();
|
||||
this.showNotification(`Agent launched on tab ${tabId}`);
|
||||
} else {
|
||||
this.showError(response?.error || 'Failed to launch agent');
|
||||
}
|
||||
} catch (error) {
|
||||
this.showError('Failed to launch agent: ' + error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Settings Tab
|
||||
// ===========================================================================
|
||||
|
||||
loadSettings() {
|
||||
chrome.storage.local.get([
|
||||
'mcpPort',
|
||||
'cdpPort',
|
||||
'zapPorts',
|
||||
'hanzo_default_model',
|
||||
'hanzo_rag_endpoint',
|
||||
'hanzo_rag_api_key',
|
||||
'hanzo_rag_kb',
|
||||
'hanzo_rag_top_k',
|
||||
'hanzo_rag_include_tab_context',
|
||||
'hanzo_rag_use_zap',
|
||||
'hanzo_chat_rag_enabled',
|
||||
'hanzo_chat_tab_context_enabled',
|
||||
], (result) => {
|
||||
const mcpPort = document.getElementById('mcp-port-setting');
|
||||
const cdpPort = document.getElementById('cdp-port-setting');
|
||||
const zapPorts = document.getElementById('zap-ports-setting');
|
||||
|
||||
if (mcpPort) mcpPort.value = result.mcpPort || 3001;
|
||||
if (cdpPort) cdpPort.value = result.cdpPort || 9223;
|
||||
if (zapPorts) zapPorts.value = (result.zapPorts || [9999,9998,9997,9996,9995]).join(',');
|
||||
if (result.hanzo_default_model && this.el.defaultModel) {
|
||||
this.el.defaultModel.value = result.hanzo_default_model;
|
||||
}
|
||||
|
||||
if (this.el.ragEndpoint) this.el.ragEndpoint.value = result.hanzo_rag_endpoint || '';
|
||||
if (this.el.ragApiKey) this.el.ragApiKey.value = result.hanzo_rag_api_key || '';
|
||||
if (this.el.ragKb) this.el.ragKb.value = result.hanzo_rag_kb || '';
|
||||
if (this.el.ragTopK) this.el.ragTopK.value = String(result.hanzo_rag_top_k || 5);
|
||||
if (this.el.ragIncludeTabContext) this.el.ragIncludeTabContext.checked = result.hanzo_rag_include_tab_context !== false;
|
||||
if (this.el.ragUseZap) this.el.ragUseZap.checked = result.hanzo_rag_use_zap !== false;
|
||||
if (this.el.ragEnabled) this.el.ragEnabled.checked = result.hanzo_chat_rag_enabled !== false;
|
||||
if (this.el.tabContextEnabled) this.el.tabContextEnabled.checked = result.hanzo_chat_tab_context_enabled !== false;
|
||||
this.setRagStatus(this.el.ragEnabled?.checked ? 'Ready' : 'RAG disabled');
|
||||
});
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
const mcpPort = parseInt(document.getElementById('mcp-port-setting')?.value) || 3001;
|
||||
const cdpPort = parseInt(document.getElementById('cdp-port-setting')?.value) || 9223;
|
||||
const zapPorts = (document.getElementById('zap-ports-setting')?.value || '')
|
||||
.split(',').map(p => parseInt(p.trim())).filter(p => p > 0 && p < 65536);
|
||||
|
||||
await chrome.storage.local.set({
|
||||
mcpPort,
|
||||
cdpPort,
|
||||
zapPorts: zapPorts.length ? zapPorts : [9999,9998,9997,9996,9995],
|
||||
hanzo_default_model: this.el.defaultModel?.value || 'gpt-4o',
|
||||
'safe-mode': document.getElementById('safe-mode')?.checked,
|
||||
'enable-webgpu': document.getElementById('enable-webgpu')?.checked,
|
||||
maxAgents: parseInt(document.getElementById('max-agents')?.value) || 3,
|
||||
hanzo_rag_endpoint: this.el.ragEndpoint?.value?.trim() || '',
|
||||
hanzo_rag_api_key: this.el.ragApiKey?.value?.trim() || '',
|
||||
hanzo_rag_kb: this.el.ragKb?.value?.trim() || '',
|
||||
hanzo_rag_top_k: Math.max(1, Math.min(parseInt(this.el.ragTopK?.value || '5', 10) || 5, 20)),
|
||||
hanzo_rag_include_tab_context: !!this.el.ragIncludeTabContext?.checked,
|
||||
hanzo_rag_use_zap: !!this.el.ragUseZap?.checked,
|
||||
hanzo_chat_rag_enabled: !!this.el.ragEnabled?.checked,
|
||||
hanzo_chat_tab_context_enabled: !!this.el.tabContextEnabled?.checked,
|
||||
});
|
||||
|
||||
this.showNotification('Settings saved');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Utility
|
||||
// ===========================================================================
|
||||
|
||||
startMonitoring() {
|
||||
setInterval(() => this.connectToMCP(), 10000);
|
||||
chrome.tabs.onUpdated.addListener(() => this.refreshTabFilesystem());
|
||||
chrome.tabs.onRemoved.addListener(() => this.refreshTabFilesystem());
|
||||
}
|
||||
|
||||
handleMessage(request) {
|
||||
switch (request.action) {
|
||||
case 'agentUpdate':
|
||||
if (request.agentId && this.agents.has(request.agentId)) {
|
||||
this.agents.get(request.agentId).status = request.status;
|
||||
this.updateAgentList();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
showNotification(message) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'notification';
|
||||
el.textContent = message;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), 3000);
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'notification error';
|
||||
el.textContent = message;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
const hanzoSidebar = new HanzoSidebar();
|
||||
@@ -299,7 +299,7 @@ export const agentTool: Tool = {
|
||||
Task: ${args.task}
|
||||
${args.tools ? `Available tools: ${args.tools.join(', ')}` : ''}
|
||||
Complete this task and report back with your findings.`,
|
||||
tools: args.tools ? [] : undefined // TODO: Load actual tools when MCP integration is complete
|
||||
tools: args.tools ?? []
|
||||
});
|
||||
|
||||
// Execute the task
|
||||
|
||||
@@ -87,14 +87,25 @@ export const vectorIndexTool: Tool = {
|
||||
metadata: args.metadata
|
||||
});
|
||||
} else {
|
||||
// TODO: Handle directory indexing
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Directory indexing not yet implemented'
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
const files = await fs.readdir(args.path, { recursive: true });
|
||||
for (const file of files) {
|
||||
const filePath = path.join(args.path, file as string);
|
||||
const fileStat = await fs.stat(filePath).catch(() => null);
|
||||
if (fileStat?.isFile()) {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
items.push({ content, path: filePath, metadata: args.metadata });
|
||||
} catch {
|
||||
// Skip binary/unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'No readable files found in directory' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +213,8 @@ export const vectorSearchTool: Tool = {
|
||||
.search(queryEmbedding)
|
||||
.limit(args.limit || 10);
|
||||
|
||||
// Apply filters if provided
|
||||
if (args.filter) {
|
||||
// TODO: Implement filtering
|
||||
results = results.where(args.filter);
|
||||
}
|
||||
|
||||
// Execute search
|
||||
|
||||
@@ -14,5 +14,4 @@
|
||||
// Main manager (without auth import for now)
|
||||
// export * from './cli-tool-manager';
|
||||
|
||||
// For now, just export a placeholder
|
||||
export const version = '1.5.7';
|
||||
export const version = '1.7.1';
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '../../src/config/agent-swarm-config';
|
||||
|
||||
vi.mock('fs');
|
||||
vi.mock('path');
|
||||
|
||||
describe('AgentSwarmManager', () => {
|
||||
let manager: AgentSwarmManager;
|
||||
@@ -98,28 +97,21 @@ describe('AgentSwarmManager', () => {
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(mockConfigPath, 'utf8');
|
||||
});
|
||||
|
||||
it.skip('should try multiple paths if default not found', async () => {
|
||||
it('should try multiple paths if default not found', async () => {
|
||||
const configYaml = yaml.dump(mockConfig);
|
||||
|
||||
// Mock process.cwd
|
||||
const originalCwd = process.cwd;
|
||||
process.cwd = vi.fn().mockReturnValue('/test');
|
||||
|
||||
|
||||
// Pass a path that won't be found, forcing fallback to alternate paths
|
||||
vi.mocked(fs.existsSync)
|
||||
.mockReturnValueOnce(false) // .hanzo/agents.yaml
|
||||
.mockReturnValueOnce(false) // primary configPath
|
||||
.mockReturnValueOnce(false) // agents.yaml
|
||||
.mockReturnValueOnce(true); // .agents.yaml
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(configYaml);
|
||||
|
||||
// Create new manager without specific path to test multiple paths
|
||||
const testManager = new AgentSwarmManager();
|
||||
|
||||
const testManager = new AgentSwarmManager('/nonexistent/agents.yaml');
|
||||
const config = await testManager.loadConfig();
|
||||
|
||||
|
||||
expect(config).toEqual(mockConfig);
|
||||
expect(fs.existsSync).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Restore process.cwd
|
||||
process.cwd = originalCwd;
|
||||
});
|
||||
|
||||
it('should throw error if no config found', async () => {
|
||||
@@ -409,11 +401,10 @@ describe('AgentSwarmManager', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
vi.mocked(path.dirname).mockReturnValue('.hanzo');
|
||||
|
||||
await AgentSwarmManager.initConfig('/test/agents.yaml');
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('.hanzo', { recursive: true });
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/test', { recursive: true });
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -430,7 +421,6 @@ describe('AgentSwarmManager', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
vi.mocked(path.dirname).mockReturnValue('.hanzo');
|
||||
|
||||
await AgentSwarmManager.initPeerNetworkConfig('/test/agents-peer.yaml');
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@hanzo/extension",
|
||||
"displayName": "Hanzo AI",
|
||||
"description": "The ultimate meta AI development platform. Manage and run all LLMs and CLI tools (Claude, Codex, Gemini, OpenHands, Aider) in one unified interface. Features MCP integration, async execution, git worktrees, and universal context sync.",
|
||||
"version": "1.5.8",
|
||||
"version": "1.7.1",
|
||||
"publisher": "hanzo-ai",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
|
||||
Reference in New Issue
Block a user