Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34b94e5a29 | ||
|
|
c02f5b9293 | ||
|
|
46170fa7b5 |
@@ -64,6 +64,10 @@ jobs:
|
||||
working-directory: packages/browser
|
||||
run: node src/build.js
|
||||
|
||||
- name: Check browser bundle budgets
|
||||
working-directory: packages/browser
|
||||
run: pnpm run check:bundle-budget
|
||||
|
||||
- name: Run Playwright E2E tests
|
||||
working-directory: packages/browser
|
||||
run: xvfb-run npx playwright test --config=e2e/playwright.config.ts
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.7.2",
|
||||
"version": "1.7.3",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
@@ -8,7 +8,8 @@
|
||||
"watch": "node src/build.js --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test --config=e2e/playwright.config.ts"
|
||||
"test:e2e": "playwright test --config=e2e/playwright.config.ts",
|
||||
"check:bundle-budget": "node scripts/check-bundle-budgets.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.50.3",
|
||||
|
||||
+29
-18
@@ -5,18 +5,28 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
VERSION="$(jq -r '.version // empty' package.json)"
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "Failed to resolve version from package.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CHROME_ZIP="hanzo-ai-chrome-${VERSION}.zip"
|
||||
FIREFOX_ZIP="hanzo-ai-firefox-${VERSION}.zip"
|
||||
|
||||
echo "=== Building browser extension ==="
|
||||
node src/build.js
|
||||
|
||||
echo "=== Packaging ==="
|
||||
(cd dist/browser-extension/chrome && zip -r ../../../hanzo-ai-chrome.zip . -x '*.DS_Store')
|
||||
(cd dist/browser-extension/firefox && zip -r ../../../hanzo-ai-firefox.zip . -x '*.DS_Store')
|
||||
echo "Chrome: hanzo-ai-chrome.zip ($(du -h hanzo-ai-chrome.zip | cut -f1))"
|
||||
echo "Firefox: hanzo-ai-firefox.zip ($(du -h hanzo-ai-firefox.zip | cut -f1))"
|
||||
rm -f "$CHROME_ZIP" "$FIREFOX_ZIP"
|
||||
(cd dist/browser-extension/chrome && zip -qr "../../../$CHROME_ZIP" . -x '*.DS_Store')
|
||||
(cd dist/browser-extension/firefox && zip -qr "../../../$FIREFOX_ZIP" . -x '*.DS_Store')
|
||||
echo "Chrome: $CHROME_ZIP ($(du -h "$CHROME_ZIP" | cut -f1))"
|
||||
echo "Firefox: $FIREFOX_ZIP ($(du -h "$FIREFOX_ZIP" | cut -f1))"
|
||||
|
||||
# --- Chrome Web Store ---
|
||||
if [ -n "${CHROME_EXTENSION_ID:-}" ] && [ -n "${CHROME_CLIENT_ID:-}" ]; then
|
||||
echo ""
|
||||
echo
|
||||
echo "=== Publishing to Chrome Web Store ==="
|
||||
ACCESS_TOKEN=$(curl -s -X POST "https://oauth2.googleapis.com/token" \
|
||||
-d "client_id=$CHROME_CLIENT_ID" \
|
||||
@@ -29,7 +39,7 @@ if [ -n "${CHROME_EXTENSION_ID:-}" ] && [ -n "${CHROME_CLIENT_ID:-}" ]; then
|
||||
"https://www.googleapis.com/upload/chromewebstore/v1.1/items/$CHROME_EXTENSION_ID" \
|
||||
-H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
-H "x-goog-api-version: 2" \
|
||||
-T hanzo-ai-chrome.zip | jq .
|
||||
-T "$CHROME_ZIP" | jq .
|
||||
|
||||
echo "Publishing..."
|
||||
curl -s -X POST \
|
||||
@@ -38,22 +48,22 @@ if [ -n "${CHROME_EXTENSION_ID:-}" ] && [ -n "${CHROME_CLIENT_ID:-}" ]; then
|
||||
-H "x-goog-api-version: 2" \
|
||||
-H "Content-Length: 0" | jq .
|
||||
else
|
||||
echo ""
|
||||
echo
|
||||
echo "=== Chrome Web Store (manual) ==="
|
||||
echo "Upload hanzo-ai-chrome.zip at: https://chrome.google.com/webstore/devconsole"
|
||||
echo ""
|
||||
echo "Upload $CHROME_ZIP at: https://chrome.google.com/webstore/devconsole"
|
||||
echo
|
||||
echo "To automate, set these env vars:"
|
||||
echo " CHROME_EXTENSION_ID - Your extension ID from the Chrome Web Store"
|
||||
echo " CHROME_CLIENT_ID - OAuth2 client ID"
|
||||
echo " CHROME_CLIENT_SECRET - OAuth2 client secret"
|
||||
echo " CHROME_REFRESH_TOKEN - OAuth2 refresh token"
|
||||
echo ""
|
||||
echo
|
||||
echo "Setup guide: https://developer.chrome.com/docs/webstore/using-api"
|
||||
fi
|
||||
|
||||
# --- Firefox Add-ons ---
|
||||
if [ -n "${AMO_API_KEY:-}" ]; then
|
||||
echo ""
|
||||
echo
|
||||
echo "=== Publishing to Firefox Add-ons ==="
|
||||
npx web-ext sign \
|
||||
--source-dir dist/browser-extension/firefox \
|
||||
@@ -62,18 +72,19 @@ if [ -n "${AMO_API_KEY:-}" ]; then
|
||||
--channel listed \
|
||||
--id "hanzo-ai@hanzo.ai"
|
||||
else
|
||||
echo ""
|
||||
echo
|
||||
echo "=== Firefox Add-ons (manual) ==="
|
||||
echo "Upload hanzo-ai-firefox.zip at: https://addons.mozilla.org/developers/"
|
||||
echo ""
|
||||
echo "Upload $FIREFOX_ZIP at: https://addons.mozilla.org/developers/"
|
||||
echo
|
||||
echo "To automate, set these env vars:"
|
||||
echo " AMO_API_KEY - Firefox Add-ons API key"
|
||||
echo " AMO_API_SECRET - Firefox Add-ons API secret"
|
||||
echo ""
|
||||
echo
|
||||
echo "Setup guide: https://extensionworkshop.com/documentation/develop/web-ext-command-reference/#web-ext-sign"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo
|
||||
|
||||
echo "=== Done ==="
|
||||
echo "Chrome zip: $SCRIPT_DIR/hanzo-ai-chrome.zip"
|
||||
echo "Firefox zip: $SCRIPT_DIR/hanzo-ai-firefox.zip"
|
||||
echo "Chrome zip: $SCRIPT_DIR/$CHROME_ZIP"
|
||||
echo "Firefox zip: $SCRIPT_DIR/$FIREFOX_ZIP"
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
|
||||
|
||||
const budgets = {
|
||||
'dist/browser-extension/background.js': 180 * 1024,
|
||||
'dist/browser-extension/sidebar.js': 5200 * 1024,
|
||||
'dist/browser-extension/content-script.js': 220 * 1024,
|
||||
};
|
||||
|
||||
let failed = false;
|
||||
|
||||
for (const [relPath, maxBytes] of Object.entries(budgets)) {
|
||||
const filePath = path.join(root, relPath);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`Missing bundle for budget check: ${relPath}`);
|
||||
failed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const size = fs.statSync(filePath).size;
|
||||
const kb = (size / 1024).toFixed(1);
|
||||
const limitKb = (maxBytes / 1024).toFixed(1);
|
||||
const status = size <= maxBytes ? 'OK' : 'OVER';
|
||||
console.log(`${status} ${relPath} ${kb}KB / ${limitKb}KB`);
|
||||
if (size > maxBytes) {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -4,6 +4,19 @@ import { WebGPUAI } from './webgpu-ai';
|
||||
import { getCDPBridge, CDPBridge } from './cdp-bridge';
|
||||
import * as auth from './auth';
|
||||
import { listModels, chatCompletion, ChatMessage } from './chat-client';
|
||||
import {
|
||||
ActionRateLimiter,
|
||||
CHAT_BUDGETS,
|
||||
InFlightRequestDeduper,
|
||||
debugLog,
|
||||
estimateTextTokens,
|
||||
loadDebugFlagFromStorage,
|
||||
makeChatRequestKey,
|
||||
pickModelForTokenBudget,
|
||||
readUsageMetrics,
|
||||
recordUsageDelta,
|
||||
trimMessagesToBudget,
|
||||
} from './runtime-guard';
|
||||
|
||||
// Initialize browser control
|
||||
const browserControl = new BrowserControl();
|
||||
@@ -73,6 +86,17 @@ const DEFAULT_CDP_PORT = 9223;
|
||||
const ZAP_RECONNECT_DELAY = 3000;
|
||||
const ZAP_DISCOVERY_TIMEOUT = 2000;
|
||||
const DEFAULT_RAG_TOP_K = 5;
|
||||
const MODEL_CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
let cachedModels: { at: number; models: Array<{ id: string; name?: string; description?: string }> } = {
|
||||
at: 0,
|
||||
models: [],
|
||||
};
|
||||
|
||||
const inFlightChatRequests = new InFlightRequestDeduper<string>();
|
||||
const senderRequestGeneration = new Map<string, number>();
|
||||
const controlMessageLimiter = new ActionRateLimiter();
|
||||
const controlMessageSignatures = new Map<string, string>();
|
||||
|
||||
/** Load port configuration from storage */
|
||||
async function getPortConfig(): Promise<{ zapPorts: number[]; mcpPort: number; cdpPort: number }> {
|
||||
@@ -117,6 +141,47 @@ async function getRagConfig(): Promise<{
|
||||
});
|
||||
}
|
||||
|
||||
function getSenderKey(sender: chrome.runtime.MessageSender): string {
|
||||
const tabPart = sender.tab?.id !== undefined ? String(sender.tab.id) : 'sidepanel';
|
||||
const urlPart = sender.url || sender.origin || 'unknown';
|
||||
return `${sender.id || 'local'}:${tabPart}:${urlPart}`;
|
||||
}
|
||||
|
||||
function bumpSenderGeneration(key: string): number {
|
||||
const next = (senderRequestGeneration.get(key) || 0) + 1;
|
||||
senderRequestGeneration.set(key, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function isSenderGenerationCurrent(key: string, generation: number): boolean {
|
||||
return senderRequestGeneration.get(key) === generation;
|
||||
}
|
||||
|
||||
async function listModelsCached(token: string): Promise<Array<{ id: string; name?: string; description?: string }>> {
|
||||
if (Date.now() - cachedModels.at < MODEL_CACHE_TTL_MS && cachedModels.models.length) {
|
||||
return cachedModels.models;
|
||||
}
|
||||
|
||||
const models = await listModels(token);
|
||||
cachedModels = {
|
||||
at: Date.now(),
|
||||
models: (models || [])
|
||||
.filter((model) => model && typeof model.id === 'string')
|
||||
.map((model) => ({
|
||||
id: String(model.id),
|
||||
name: model.name ? String(model.name) : undefined,
|
||||
description: model.description ? String(model.description) : undefined,
|
||||
})),
|
||||
};
|
||||
return cachedModels.models;
|
||||
}
|
||||
|
||||
function getCachedModelIds(): string[] {
|
||||
return cachedModels.models
|
||||
.map((model) => String(model?.id || '').trim())
|
||||
.filter((id) => !!id);
|
||||
}
|
||||
|
||||
/** Active ZAP WebSocket connections keyed by MCP id */
|
||||
const zapConnections = new Map<string, WebSocket>();
|
||||
|
||||
@@ -246,7 +311,7 @@ async function connectZap(url: string): Promise<string | null> {
|
||||
url,
|
||||
tools: (info.tools || []).map((t: any) => t.name),
|
||||
});
|
||||
console.log(`[Hanzo/ZAP] Connected to ${info.name || url} (${(info.tools || []).length} tools)`);
|
||||
debugLog(`[Hanzo/ZAP] Connected to ${info.name || url} (${(info.tools || []).length} tools)`);
|
||||
resolve(mcpId);
|
||||
break;
|
||||
}
|
||||
@@ -277,7 +342,7 @@ async function connectZap(url: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
zapState.connected = zapConnections.size > 0;
|
||||
console.log(`[Hanzo/ZAP] Disconnected from ${url}, reconnecting in ${ZAP_RECONNECT_DELAY}ms...`);
|
||||
debugLog(`[Hanzo/ZAP] Disconnected from ${url}, reconnecting in ${ZAP_RECONNECT_DELAY}ms...`);
|
||||
setTimeout(() => connectZap(url), ZAP_RECONNECT_DELAY);
|
||||
};
|
||||
|
||||
@@ -457,18 +522,18 @@ async function queryRagFromEndpoint(params: RagQueryParams): Promise<RagSnippet[
|
||||
* Discover and connect to ZAP servers on startup
|
||||
*/
|
||||
async function discoverZapServers() {
|
||||
console.log('[Hanzo/ZAP] Discovering MCP servers...');
|
||||
debugLog('[Hanzo/ZAP] Discovering MCP servers...');
|
||||
const { zapPorts } = await getPortConfig();
|
||||
const results = await Promise.all(zapPorts.map(p => probeZapServer(p)));
|
||||
const available = results.filter(Boolean) as string[];
|
||||
|
||||
if (available.length === 0) {
|
||||
console.log('[Hanzo/ZAP] No servers found, retrying in 10s...');
|
||||
debugLog('[Hanzo/ZAP] No servers found, retrying in 10s...');
|
||||
setTimeout(discoverZapServers, 10000);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Hanzo/ZAP] Found ${available.length} server(s): ${available.join(', ')}`);
|
||||
debugLog(`[Hanzo/ZAP] Found ${available.length} server(s): ${available.join(', ')}`);
|
||||
await Promise.all(available.map(url => connectZap(url)));
|
||||
}
|
||||
|
||||
@@ -546,13 +611,15 @@ async function stopControlSession(): Promise<void> {
|
||||
// Extension Lifecycle
|
||||
// =============================================================================
|
||||
|
||||
void loadDebugFlagFromStorage();
|
||||
|
||||
// Initialize WebGPU and CDP on install
|
||||
chrome.runtime.onInstalled.addListener(async () => {
|
||||
console.log('[Hanzo] Extension installed, initializing...');
|
||||
debugLog('[Hanzo] Extension installed, initializing...');
|
||||
|
||||
const gpuAvailable = await webgpuAI.initialize();
|
||||
if (gpuAvailable) {
|
||||
console.log('[Hanzo] WebGPU available, loading local models...');
|
||||
debugLog('[Hanzo] WebGPU available, loading local models...');
|
||||
try {
|
||||
await webgpuAI.loadModel({
|
||||
name: 'hanzo-browser-control',
|
||||
@@ -795,6 +862,24 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const key = `${tabId}:${request.action}`;
|
||||
const signature = JSON.stringify(request);
|
||||
const throttleMs = request.action === 'ai.control.cursor'
|
||||
? 40
|
||||
: request.action === 'ai.control.status'
|
||||
? 200
|
||||
: 90;
|
||||
if (!controlMessageLimiter.allow(key, throttleMs)) {
|
||||
sendResponse({ success: true, tabId, throttled: true });
|
||||
break;
|
||||
}
|
||||
if (controlMessageSignatures.get(key) === signature) {
|
||||
sendResponse({ success: true, tabId, deduped: true });
|
||||
break;
|
||||
}
|
||||
controlMessageSignatures.set(key, signature);
|
||||
|
||||
sendControlMessageToTab(tabId, request);
|
||||
sendResponse({ success: true, tabId });
|
||||
break;
|
||||
@@ -932,13 +1017,21 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
sendResponse({ success: false, error: 'Not authenticated' });
|
||||
break;
|
||||
}
|
||||
const models = await listModels(token);
|
||||
const models = await listModelsCached(token);
|
||||
sendResponse({ success: true, models });
|
||||
} catch (error: any) {
|
||||
sendResponse({ success: false, error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'chat.cancel': {
|
||||
const senderKey = getSenderKey(sender);
|
||||
bumpSenderGeneration(senderKey);
|
||||
await recordUsageDelta({ canceledRequests: 1 });
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'chat.complete':
|
||||
try {
|
||||
const token = await auth.getValidAccessToken();
|
||||
@@ -947,29 +1040,85 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
break;
|
||||
}
|
||||
|
||||
const model = String(request.model || 'gpt-4o');
|
||||
const senderKey = getSenderKey(sender);
|
||||
const requestedModel = String(request.model || 'auto');
|
||||
const messagesInput = Array.isArray(request.messages) ? request.messages : [];
|
||||
const messages: ChatMessage[] = messagesInput
|
||||
const rawMessages: 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) {
|
||||
const budgeted = trimMessagesToBudget(rawMessages, {
|
||||
maxMessages: CHAT_BUDGETS.maxMessages,
|
||||
maxInputTokens: CHAT_BUDGETS.maxInputTokens,
|
||||
});
|
||||
|
||||
if (!budgeted.messages.length) {
|
||||
sendResponse({ success: false, error: 'messages are required' });
|
||||
break;
|
||||
}
|
||||
|
||||
const content = await chatCompletion(token, {
|
||||
let availableModelIds = getCachedModelIds();
|
||||
if (!availableModelIds.length) {
|
||||
availableModelIds = (await listModelsCached(token)).map((model) => model.id);
|
||||
}
|
||||
const model = pickModelForTokenBudget(requestedModel, availableModelIds, budgeted.inputTokens);
|
||||
const temperature = typeof request.temperature === 'number' ? request.temperature : undefined;
|
||||
const maxTokens = typeof request.max_tokens === 'number'
|
||||
? Math.max(64, Math.min(request.max_tokens, CHAT_BUDGETS.maxOutputTokens))
|
||||
: CHAT_BUDGETS.maxOutputTokens;
|
||||
|
||||
const requestKey = makeChatRequestKey(model, budgeted.messages, temperature, maxTokens);
|
||||
const { promise, deduped } = inFlightChatRequests.getOrCreate(requestKey, async () => chatCompletion(token, {
|
||||
model,
|
||||
messages,
|
||||
temperature: typeof request.temperature === 'number' ? request.temperature : undefined,
|
||||
max_tokens: typeof request.max_tokens === 'number' ? request.max_tokens : undefined,
|
||||
messages: budgeted.messages,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
}));
|
||||
const generation = deduped
|
||||
? (senderRequestGeneration.get(senderKey) || 0)
|
||||
: bumpSenderGeneration(senderKey);
|
||||
|
||||
if (deduped) {
|
||||
await recordUsageDelta({ dedupeHits: 1 });
|
||||
debugLog('[Hanzo] Deduped in-flight chat request');
|
||||
}
|
||||
|
||||
const content = await promise;
|
||||
|
||||
if (!isSenderGenerationCurrent(senderKey, generation)) {
|
||||
await recordUsageDelta({ canceledRequests: 1 });
|
||||
sendResponse({ success: false, error: 'Chat request superseded by a newer request' });
|
||||
break;
|
||||
}
|
||||
|
||||
await recordUsageDelta({
|
||||
chatRequests: 1,
|
||||
inputTokens: budgeted.inputTokens,
|
||||
outputTokens: estimateTextTokens(content),
|
||||
});
|
||||
|
||||
sendResponse({ success: true, content });
|
||||
sendResponse({
|
||||
success: true,
|
||||
content,
|
||||
model,
|
||||
budget: {
|
||||
inputTokens: budgeted.inputTokens,
|
||||
droppedMessages: budgeted.droppedMessages,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
await recordUsageDelta({ errors: 1 });
|
||||
sendResponse({ success: false, error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'usage.metrics':
|
||||
try {
|
||||
const metrics = await readUsageMetrics();
|
||||
sendResponse({ success: true, metrics });
|
||||
} catch (error: any) {
|
||||
sendResponse({ success: false, error: error.message });
|
||||
}
|
||||
@@ -1080,7 +1229,7 @@ async function connectToMCP() {
|
||||
ws = new WebSocket(`ws://localhost:${mcpPort}/browser-extension`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[Hanzo] Connected to legacy MCP server');
|
||||
debugLog('[Hanzo] Connected to legacy MCP server');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -1135,7 +1284,7 @@ connectToMCP();
|
||||
getPortConfig().then(({ cdpPort }) => {
|
||||
try {
|
||||
cdpBridge.startWebSocketServer(cdpPort);
|
||||
console.log(`[Hanzo] CDP bridge connecting to ws://localhost:${cdpPort}/cdp`);
|
||||
debugLog(`[Hanzo] CDP bridge connecting to ws://localhost:${cdpPort}/cdp`);
|
||||
} catch (e) {
|
||||
console.error('[Hanzo] Failed to start CDP bridge:', e);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// CDP Bridge for hanzo-mcp Browser Tool Integration
|
||||
// Enables Playwright to control browser via Chrome DevTools Protocol
|
||||
import { ActionRateLimiter, debugLog, loadDebugFlagFromStorage } from './runtime-guard';
|
||||
|
||||
interface CDPSession {
|
||||
tabId: number;
|
||||
@@ -26,8 +27,11 @@ export class CDPBridge {
|
||||
private commandId: number = 0;
|
||||
private wsServer: WebSocket | null = null;
|
||||
private wsClients: Set<WebSocket> = new Set();
|
||||
private overlayLimiter = new ActionRateLimiter();
|
||||
private overlaySignatures = new Map<string, string>();
|
||||
|
||||
constructor() {
|
||||
void loadDebugFlagFromStorage();
|
||||
this.setupDebuggerListener();
|
||||
}
|
||||
|
||||
@@ -39,7 +43,7 @@ export class CDPBridge {
|
||||
});
|
||||
|
||||
chrome.debugger.onDetach.addListener((source, reason) => {
|
||||
console.log(`[CDP] Detached from tab ${source.tabId}: ${reason}`);
|
||||
debugLog(`[CDP] Detached from tab ${source.tabId}: ${reason}`);
|
||||
if (source.tabId) {
|
||||
this.sessions.delete(source.tabId);
|
||||
}
|
||||
@@ -66,7 +70,7 @@ export class CDPBridge {
|
||||
debuggee,
|
||||
connected: true
|
||||
});
|
||||
console.log(`[CDP] Attached to tab ${tabId}`);
|
||||
debugLog(`[CDP] Attached to tab ${tabId}`);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
@@ -275,7 +279,7 @@ export class CDPBridge {
|
||||
this.wsServer = new WebSocket(url);
|
||||
|
||||
this.wsServer.onopen = () => {
|
||||
console.log('[CDP] Connected to bridge server');
|
||||
debugLog('[CDP] Connected to bridge server');
|
||||
// Register as CDP provider with browser identification
|
||||
const browser = typeof navigator !== 'undefined'
|
||||
? (navigator.userAgent.includes('Firefox') ? 'firefox'
|
||||
@@ -310,7 +314,7 @@ export class CDPBridge {
|
||||
};
|
||||
|
||||
this.wsServer.onclose = () => {
|
||||
console.log('[CDP] Bridge disconnected, reconnecting in 5s...');
|
||||
debugLog('[CDP] Bridge disconnected, reconnecting in 5s...');
|
||||
setTimeout(() => this.connectToBridge(url), 5000);
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -463,6 +467,25 @@ export class CDPBridge {
|
||||
}
|
||||
|
||||
private notifyControlOverlay(tabId: number, action: string, data: any): void {
|
||||
const key = `${tabId}:${action}`;
|
||||
const signature = JSON.stringify(data || {});
|
||||
const throttleMs = action === 'ai.control.cursor'
|
||||
? 40
|
||||
: action === 'ai.control.status'
|
||||
? 200
|
||||
: action === 'ai.control.highlight'
|
||||
? 90
|
||||
: 0;
|
||||
|
||||
if (throttleMs > 0 && !this.overlayLimiter.allow(key, throttleMs)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.overlaySignatures.get(key) === signature && action !== 'ai.control.start' && action !== 'ai.control.stop') {
|
||||
return;
|
||||
}
|
||||
this.overlaySignatures.set(key, signature);
|
||||
|
||||
try {
|
||||
chrome.tabs.sendMessage(tabId, { action, ...data }, () => {
|
||||
// Ignore errors (tab may not have content script)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.7.2",
|
||||
"version": "1.7.3",
|
||||
"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:* wss://* http://* https://*;"
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.7.2",
|
||||
"version": "1.7.3",
|
||||
"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:* wss://* http://* https://*;"
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
|
||||
+163
-66
@@ -1,4 +1,4 @@
|
||||
/* Hanzo AI Browser Extension Popup Styles */
|
||||
/* Hanzo AI Browser Extension Popup — Monochrome Vibrancy */
|
||||
|
||||
:root {
|
||||
--primary: #FFFFFF;
|
||||
@@ -8,7 +8,10 @@
|
||||
--surface-light: #1A1A1A;
|
||||
--text: #FFFFFF;
|
||||
--text-dim: #888888;
|
||||
--border: #1F1F1F;
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--border-strong: rgba(255,255,255,0.14);
|
||||
--glass: rgba(255,255,255,0.04);
|
||||
--glass-strong: rgba(255,255,255,0.07);
|
||||
--success: #4CAF50;
|
||||
--warning: #FFC107;
|
||||
--error: #F44336;
|
||||
@@ -22,16 +25,53 @@
|
||||
|
||||
body {
|
||||
width: 380px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Inter', 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: var(--surface);
|
||||
min-height: 500px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Ambient glow */
|
||||
.container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120px;
|
||||
background: radial-gradient(ellipse at 50% 0%, rgba(255,255,255,0.03) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
|
||||
50% { box-shadow: 0 0 12px 2px rgba(255,255,255,0.06); }
|
||||
}
|
||||
|
||||
/* Header */
|
||||
@@ -41,17 +81,24 @@ header {
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--text);
|
||||
filter: drop-shadow(0 0 6px rgba(255,255,255,0.15));
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@@ -69,6 +116,7 @@ h3 {
|
||||
/* Sections */
|
||||
.section {
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.section.hidden {
|
||||
@@ -93,7 +141,9 @@ h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -102,18 +152,39 @@ h3 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Shimmer sweep */
|
||||
.btn-primary::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-dark);
|
||||
color: #000000;
|
||||
box-shadow: 0 0 16px rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
.btn-primary:hover::after {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-light);
|
||||
background: var(--glass-strong);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #3A3A3A;
|
||||
background: var(--glass);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: 0 0 8px rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
@@ -128,10 +199,18 @@ h3 {
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: var(--surface-light);
|
||||
background: var(--glass-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-icon svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@@ -145,7 +224,10 @@ h3 {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--surface-light);
|
||||
background: var(--glass-strong);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -154,6 +236,7 @@ h3 {
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.user-info > div {
|
||||
@@ -181,14 +264,16 @@ h3 {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: var(--surface-light);
|
||||
background: var(--glass-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.feature-toggle label:hover {
|
||||
background: #3A3A3A;
|
||||
background: var(--glass);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.feature-toggle input {
|
||||
@@ -199,9 +284,10 @@ h3 {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: var(--border);
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 12px;
|
||||
transition: background 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle::after {
|
||||
@@ -214,6 +300,7 @@ h3 {
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
input:checked + .toggle {
|
||||
@@ -251,19 +338,28 @@ input:checked + .toggle::after {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
background: rgba(255,255,255,0.1);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.3);
|
||||
animation: glowPulse 3s infinite;
|
||||
}
|
||||
|
||||
.status-dot.warning {
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 8px rgba(255, 193, 7, 0.2);
|
||||
}
|
||||
|
||||
.status-dot.error {
|
||||
background: var(--error);
|
||||
box-shadow: 0 0 8px rgba(244, 67, 54, 0.2);
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-detail {
|
||||
@@ -284,7 +380,8 @@ input:checked + .toggle::after {
|
||||
|
||||
/* Guide */
|
||||
.guide {
|
||||
background: var(--surface-light);
|
||||
background: var(--glass-strong);
|
||||
border: 1px solid var(--border);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -302,7 +399,7 @@ input:checked + .toggle::after {
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
background: var(--surface);
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
@@ -337,16 +434,63 @@ kbd {
|
||||
.setting-group select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg);
|
||||
background: var(--glass-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.setting-group input:focus,
|
||||
.setting-group select:focus {
|
||||
border-color: var(--border-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.setting-group input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Backend Picker */
|
||||
.backend-picker {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.backend-picker h3 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.backend-select-wrapper select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--glass-strong);
|
||||
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;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.backend-select-wrapper select:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.backend-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
@@ -366,57 +510,10 @@ kbd {
|
||||
.help-text a {
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.help-text a:hover {
|
||||
color: var(--text);
|
||||
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; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
export interface ChatLikeMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface MessageBudgetOptions {
|
||||
maxMessages?: number;
|
||||
maxInputTokens?: number;
|
||||
}
|
||||
|
||||
export interface MessageBudgetResult {
|
||||
messages: ChatLikeMessage[];
|
||||
inputTokens: number;
|
||||
droppedMessages: number;
|
||||
}
|
||||
|
||||
export interface UsageMetrics {
|
||||
periodStart: number;
|
||||
lastUpdated: number;
|
||||
chatRequests: number;
|
||||
dedupeHits: number;
|
||||
canceledRequests: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
const DEFAULT_MAX_MESSAGES = 24;
|
||||
const DEFAULT_MAX_INPUT_TOKENS = 6000;
|
||||
const USAGE_METRICS_KEY = 'hanzo_usage_metrics_v1';
|
||||
const METRIC_PERIOD_MS = 60 * 60 * 1000;
|
||||
|
||||
let debugEnabled = false;
|
||||
let queuedDelta: Partial<Omit<UsageMetrics, 'periodStart' | 'lastUpdated'>> = {};
|
||||
let flushTimer: number | null = null;
|
||||
|
||||
export const CHAT_BUDGETS = {
|
||||
maxMessages: DEFAULT_MAX_MESSAGES,
|
||||
maxInputTokens: DEFAULT_MAX_INPUT_TOKENS,
|
||||
maxOutputTokens: 1200,
|
||||
duplicateWindowMs: 4000,
|
||||
};
|
||||
|
||||
function hasChromeStorage(): boolean {
|
||||
return typeof chrome !== 'undefined' && !!chrome.storage?.local;
|
||||
}
|
||||
|
||||
export function estimateTextTokens(text: string): number {
|
||||
if (!text) return 0;
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
||||
}
|
||||
|
||||
export function estimateMessageTokens(messages: ChatLikeMessage[]): number {
|
||||
return (messages || []).reduce((sum, message) => sum + estimateTextTokens(message?.content || ''), 0);
|
||||
}
|
||||
|
||||
export function trimMessagesToBudget(
|
||||
inputMessages: ChatLikeMessage[],
|
||||
options: MessageBudgetOptions = {},
|
||||
): MessageBudgetResult {
|
||||
const maxMessages = options.maxMessages ?? DEFAULT_MAX_MESSAGES;
|
||||
const maxInputTokens = options.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
|
||||
|
||||
const normalized = (inputMessages || [])
|
||||
.filter((msg) => msg && typeof msg.role === 'string' && typeof msg.content === 'string')
|
||||
.map((msg) => ({
|
||||
role: msg.role === 'system' || msg.role === 'assistant' ? msg.role : 'user',
|
||||
content: String(msg.content),
|
||||
}))
|
||||
.slice(-maxMessages);
|
||||
|
||||
const keptReversed: ChatLikeMessage[] = [];
|
||||
let inputTokens = 0;
|
||||
let droppedMessages = 0;
|
||||
|
||||
for (let i = normalized.length - 1; i >= 0; i -= 1) {
|
||||
const message = normalized[i];
|
||||
const messageTokens = estimateTextTokens(message.content);
|
||||
const isNewestMessage = i === normalized.length - 1;
|
||||
const wouldOverflow = inputTokens + messageTokens > maxInputTokens;
|
||||
|
||||
if (wouldOverflow && !isNewestMessage) {
|
||||
droppedMessages += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
keptReversed.push(message);
|
||||
inputTokens += messageTokens;
|
||||
}
|
||||
|
||||
const messages = keptReversed.reverse();
|
||||
return {
|
||||
messages,
|
||||
inputTokens,
|
||||
droppedMessages,
|
||||
};
|
||||
}
|
||||
|
||||
export function stableHash(input: string): string {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(16);
|
||||
}
|
||||
|
||||
export function makeChatRequestKey(
|
||||
model: string,
|
||||
messages: ChatLikeMessage[],
|
||||
temperature?: number,
|
||||
maxTokens?: number,
|
||||
): string {
|
||||
const normalized = (messages || [])
|
||||
.map((msg) => `${msg.role}:${msg.content.replace(/\s+/g, ' ').trim()}`)
|
||||
.join('|');
|
||||
return stableHash(`${model}|${temperature ?? ''}|${maxTokens ?? ''}|${normalized}`);
|
||||
}
|
||||
|
||||
export class InFlightRequestDeduper<T> {
|
||||
private inFlight = new Map<string, Promise<T>>();
|
||||
|
||||
getOrCreate(key: string, factory: () => Promise<T>): { promise: Promise<T>; deduped: boolean } {
|
||||
const existing = this.inFlight.get(key);
|
||||
if (existing) {
|
||||
return { promise: existing, deduped: true };
|
||||
}
|
||||
|
||||
const promise = factory().finally(() => {
|
||||
this.inFlight.delete(key);
|
||||
});
|
||||
this.inFlight.set(key, promise);
|
||||
return { promise, deduped: false };
|
||||
}
|
||||
}
|
||||
|
||||
export class ActionRateLimiter {
|
||||
private lastActionAt = new Map<string, number>();
|
||||
|
||||
allow(key: string, intervalMs: number): boolean {
|
||||
const now = Date.now();
|
||||
const last = this.lastActionAt.get(key) || 0;
|
||||
if (now - last < intervalMs) {
|
||||
return false;
|
||||
}
|
||||
this.lastActionAt.set(key, now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: any[]) => void>(fn: T, delayMs: number): (...args: Parameters<T>) => void {
|
||||
let timer: number | null = null;
|
||||
return (...args: Parameters<T>) => {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
fn(...args);
|
||||
}, delayMs);
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadDebugFlagFromStorage(): Promise<boolean> {
|
||||
if (!hasChromeStorage()) {
|
||||
return debugEnabled;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get(['hanzo_debug'], (result) => {
|
||||
debugEnabled = !!result?.hanzo_debug;
|
||||
resolve(debugEnabled);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function debugLog(...args: unknown[]): void {
|
||||
if (debugEnabled) {
|
||||
console.log(...args);
|
||||
}
|
||||
}
|
||||
|
||||
function emptyUsageMetrics(now = Date.now()): UsageMetrics {
|
||||
return {
|
||||
periodStart: now,
|
||||
lastUpdated: now,
|
||||
chatRequests: 0,
|
||||
dedupeHits: 0,
|
||||
canceledRequests: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
errors: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeUsage(base: UsageMetrics, delta: Partial<Omit<UsageMetrics, 'periodStart' | 'lastUpdated'>>): UsageMetrics {
|
||||
const now = Date.now();
|
||||
const reset = now - base.periodStart > METRIC_PERIOD_MS ? emptyUsageMetrics(now) : base;
|
||||
return {
|
||||
...reset,
|
||||
lastUpdated: now,
|
||||
chatRequests: reset.chatRequests + (delta.chatRequests || 0),
|
||||
dedupeHits: reset.dedupeHits + (delta.dedupeHits || 0),
|
||||
canceledRequests: reset.canceledRequests + (delta.canceledRequests || 0),
|
||||
inputTokens: reset.inputTokens + (delta.inputTokens || 0),
|
||||
outputTokens: reset.outputTokens + (delta.outputTokens || 0),
|
||||
errors: reset.errors + (delta.errors || 0),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readUsageMetrics(): Promise<UsageMetrics> {
|
||||
if (!hasChromeStorage()) {
|
||||
return emptyUsageMetrics();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get([USAGE_METRICS_KEY], (result) => {
|
||||
const stored = result?.[USAGE_METRICS_KEY];
|
||||
if (!stored || typeof stored !== 'object') {
|
||||
resolve(emptyUsageMetrics());
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
...emptyUsageMetrics(),
|
||||
...stored,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function flushUsageDelta(): Promise<void> {
|
||||
if (!hasChromeStorage()) return;
|
||||
const delta = queuedDelta;
|
||||
queuedDelta = {};
|
||||
flushTimer = null;
|
||||
|
||||
const current = await readUsageMetrics();
|
||||
const merged = mergeUsage(current, delta);
|
||||
await new Promise<void>((resolve) => {
|
||||
chrome.storage.local.set({ [USAGE_METRICS_KEY]: merged }, () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordUsageDelta(
|
||||
delta: Partial<Omit<UsageMetrics, 'periodStart' | 'lastUpdated'>>,
|
||||
): Promise<void> {
|
||||
queuedDelta = {
|
||||
chatRequests: (queuedDelta.chatRequests || 0) + (delta.chatRequests || 0),
|
||||
dedupeHits: (queuedDelta.dedupeHits || 0) + (delta.dedupeHits || 0),
|
||||
canceledRequests: (queuedDelta.canceledRequests || 0) + (delta.canceledRequests || 0),
|
||||
inputTokens: (queuedDelta.inputTokens || 0) + (delta.inputTokens || 0),
|
||||
outputTokens: (queuedDelta.outputTokens || 0) + (delta.outputTokens || 0),
|
||||
errors: (queuedDelta.errors || 0) + (delta.errors || 0),
|
||||
};
|
||||
|
||||
if (flushTimer !== null) return;
|
||||
flushTimer = setTimeout(() => {
|
||||
void flushUsageDelta();
|
||||
}, 1000) as unknown as number;
|
||||
}
|
||||
|
||||
export function pickModelForTokenBudget(
|
||||
requestedModel: string,
|
||||
availableModels: string[],
|
||||
inputTokens: number,
|
||||
): string {
|
||||
if (requestedModel && requestedModel !== 'auto') {
|
||||
return requestedModel;
|
||||
}
|
||||
|
||||
const smallModelOrder = [
|
||||
'zen-coder-flash',
|
||||
'gpt-4o-mini',
|
||||
'claude-3-5-haiku-latest',
|
||||
'claude-3-5-haiku',
|
||||
'gemini-2.0-flash',
|
||||
];
|
||||
const largeModelOrder = [
|
||||
'gpt-4o',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-opus-4-20250514',
|
||||
'zen-max',
|
||||
];
|
||||
|
||||
const pickFirstExisting = (names: string[]) => names.find((name) => availableModels.includes(name));
|
||||
|
||||
if (inputTokens <= 900) {
|
||||
return pickFirstExisting(smallModelOrder) || availableModels[0] || 'gpt-4o-mini';
|
||||
}
|
||||
|
||||
return pickFirstExisting(largeModelOrder) || availableModels[0] || 'gpt-4o';
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Hanzo AI Browser Extension Sidebar */
|
||||
/* Hanzo AI Browser Extension Sidebar — Monochrome Vibrancy */
|
||||
|
||||
:root {
|
||||
--bg-primary: #000000;
|
||||
@@ -12,14 +12,19 @@
|
||||
|
||||
--accent: #FFFFFF;
|
||||
--accent-hover: #D4D4D4;
|
||||
--accent-dim: #FFFFFF10;
|
||||
--accent-dim: rgba(255,255,255,0.06);
|
||||
--accent-glow: rgba(255,255,255,0.03);
|
||||
|
||||
--success: #4CAF50;
|
||||
--warning: #FFC107;
|
||||
--error: #F44336;
|
||||
|
||||
--border: #1A1A1A;
|
||||
--border: rgba(255,255,255,0.08);
|
||||
--border-strong: rgba(255,255,255,0.14);
|
||||
--glass: rgba(255,255,255,0.04);
|
||||
--glass-strong: rgba(255,255,255,0.07);
|
||||
--shadow: 0 4px 12px rgba(0,0,0,0.6);
|
||||
--shadow-lg: 0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -29,16 +34,55 @@
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Inter', 'Segoe UI', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ==================== Shimmer Keyframes ==================== */
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%, 60%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(255,255,255,0); }
|
||||
50% { box-shadow: 0 0 12px 2px rgba(255,255,255,0.06); }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ==================== Layout ==================== */
|
||||
|
||||
.sidebar-container {
|
||||
@@ -48,6 +92,20 @@ body {
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
border-left: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Ambient glow behind sidebar */
|
||||
.sidebar-container::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 200px;
|
||||
background: radial-gradient(ellipse at 50% 0%, rgba(255,255,255,0.03) 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ==================== Header ==================== */
|
||||
@@ -58,8 +116,12 @@ body {
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-primary);
|
||||
background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@@ -68,12 +130,19 @@ body {
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--accent);
|
||||
filter: drop-shadow(0 0 6px rgba(255,255,255,0.15));
|
||||
transition: filter 0.3s ease;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 10px rgba(255,255,255,0.3));
|
||||
}
|
||||
|
||||
.header-right {
|
||||
@@ -88,6 +157,13 @@ body {
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.user-badge:hover {
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: 0 0 8px rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.header-avatar {
|
||||
@@ -137,6 +213,7 @@ body {
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
flex: 1;
|
||||
animation: fadeIn 0.4s ease;
|
||||
}
|
||||
|
||||
.auth-card.compact {
|
||||
@@ -178,16 +255,18 @@ body {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* React login card (shared @hanzo/ui primitives) */
|
||||
/* Glass login card */
|
||||
.chat-modern-login-card {
|
||||
width: min(320px, 100%);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-strong);
|
||||
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));
|
||||
background: var(--glass-strong);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 16px 30px color-mix(in srgb, black 28%, transparent);
|
||||
box-shadow: var(--shadow-lg), inset 0 1px 0 rgba(255,255,255,0.05);
|
||||
padding: 28px 24px;
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
.chat-modern-login-card .chat-modern-auth-btn {
|
||||
@@ -216,8 +295,12 @@ body {
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-primary);
|
||||
background: rgba(0,0,0,0.5);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tab {
|
||||
@@ -234,12 +317,13 @@ body {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
background: var(--glass);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
@@ -247,6 +331,19 @@ body {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Shimmer on active tab */
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
|
||||
animation: shimmer 3s infinite linear;
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
/* ==================== Tab Content ==================== */
|
||||
|
||||
.tab-content {
|
||||
@@ -254,6 +351,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.tools-scroll,
|
||||
@@ -282,6 +380,7 @@ body {
|
||||
color: var(--text-tertiary);
|
||||
padding: 40px 20px;
|
||||
font-size: 13px;
|
||||
animation: breathe 4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.msg {
|
||||
@@ -291,11 +390,14 @@ body {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.msg-user {
|
||||
align-self: flex-end;
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--glass-strong);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-right-radius: 4px;
|
||||
@@ -306,10 +408,12 @@ body {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border-bottom-left-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.msg-assistant pre {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin: 8px 0;
|
||||
@@ -322,7 +426,7 @@ body {
|
||||
.msg-assistant code {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--bg-primary);
|
||||
background: rgba(255,255,255,0.06);
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -363,18 +467,17 @@ body {
|
||||
.typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typingBounce {
|
||||
0%, 60%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
/* Chat Input */
|
||||
|
||||
.chat-input-area {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
background: rgba(0,0,0,0.5);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.chat-modern-composer {
|
||||
@@ -390,12 +493,20 @@ body {
|
||||
.model-selector select {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--glass-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.model-selector select:focus {
|
||||
border-color: var(--border-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-flags {
|
||||
@@ -435,7 +546,7 @@ body {
|
||||
.input-row textarea {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--glass-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
@@ -445,11 +556,14 @@ body {
|
||||
resize: none;
|
||||
outline: none;
|
||||
max-height: 120px;
|
||||
transition: border-color 0.2s;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.input-row textarea:focus {
|
||||
border-color: var(--accent);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.chat-modern-input {
|
||||
@@ -468,16 +582,18 @@ body {
|
||||
color: #000000;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.send-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 0 12px rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.4;
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.send-btn svg {
|
||||
@@ -502,7 +618,7 @@ body {
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
background: var(--glass-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -526,33 +642,55 @@ body {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Shimmer sweep on primary buttons */
|
||||
.primary-btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
box-shadow: 0 0 16px rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
.primary-btn:hover::after {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.primary-btn:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--glass-strong);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.secondary-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
background: var(--glass);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: 0 0 8px rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -562,19 +700,22 @@ body {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--glass-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.25s ease;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--accent);
|
||||
background: var(--glass);
|
||||
border-color: var(--border-strong);
|
||||
box-shadow: 0 0 12px rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
@@ -582,22 +723,42 @@ body {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ==================== Panels ==================== */
|
||||
/* ==================== Glass Panels ==================== */
|
||||
|
||||
.panel {
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--glass-strong);
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(150%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Subtle top highlight on glass panels */
|
||||
.panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 10%, rgba(255,255,255,0.08) 50%, transparent 90%);
|
||||
}
|
||||
|
||||
.panel:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.panel h3 {
|
||||
font-size: 13px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@@ -645,21 +806,23 @@ body {
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.status-indicator.connected {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.12);
|
||||
box-shadow: 0 0 8px rgba(76, 175, 80, 0.3);
|
||||
animation: glowPulse 3s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.warning {
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 3px rgba(255, 193, 7, 0.12);
|
||||
box-shadow: 0 0 8px rgba(255, 193, 7, 0.2);
|
||||
}
|
||||
|
||||
.status-indicator.disconnected {
|
||||
background: var(--error);
|
||||
box-shadow: 0 0 0 3px rgba(244, 67, 54, 0.12);
|
||||
box-shadow: 0 0 8px rgba(244, 67, 54, 0.2);
|
||||
}
|
||||
|
||||
.mono {
|
||||
@@ -701,6 +864,7 @@ body {
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.user-details { flex: 1; }
|
||||
@@ -728,12 +892,16 @@ body {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: var(--bg-secondary);
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.agent-item:hover { background: var(--bg-hover); }
|
||||
.agent-item:hover {
|
||||
background: var(--glass-strong);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.agent-icon {
|
||||
width: 32px;
|
||||
@@ -770,7 +938,7 @@ body {
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-item:hover { background: var(--bg-hover); }
|
||||
.tab-item:hover { background: var(--glass-strong); }
|
||||
|
||||
.tab-item.active {
|
||||
background: var(--accent-dim);
|
||||
@@ -796,11 +964,18 @@ body {
|
||||
.setting-item select {
|
||||
width: 140px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-primary);
|
||||
background: var(--glass-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.setting-item input:focus,
|
||||
.setting-item select:focus {
|
||||
border-color: var(--border-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.setting-item-column {
|
||||
@@ -817,11 +992,17 @@ body {
|
||||
.setting-item input[type="number"] {
|
||||
width: 70px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-primary);
|
||||
background: var(--glass-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.setting-item input[type="number"]:focus {
|
||||
border-color: var(--border-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.setting-item input[type="checkbox"] {
|
||||
@@ -873,7 +1054,9 @@ body {
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--glass-strong);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
@@ -888,25 +1071,15 @@ body {
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(20px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ==================== Scrollbar ==================== */
|
||||
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-tertiary); }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.14); }
|
||||
|
||||
/* ==================== Loading ==================== */
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.loading { animation: pulse 1.5s infinite; }
|
||||
|
||||
/* ==================== Agent Launcher Modal ==================== */
|
||||
@@ -918,6 +1091,8 @@ body {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -927,11 +1102,14 @@ body {
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 12px;
|
||||
width: calc(100% - 32px);
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-lg);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@@ -969,7 +1147,7 @@ body {
|
||||
|
||||
.modal-body textarea {
|
||||
width: 100%;
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--glass-strong);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
@@ -996,8 +1174,3 @@ body {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
<div id="chat-composer" class="chat-input-area">
|
||||
<div class="model-selector">
|
||||
<select id="model-select">
|
||||
<option value="auto">Auto (cost-aware)</option>
|
||||
<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>
|
||||
@@ -115,6 +116,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Usage (1h)</h3>
|
||||
<div class="mcp-status">
|
||||
<div class="status-row">
|
||||
<span>Chat Requests</span>
|
||||
<span id="usage-chat-requests" class="status-value mono">0</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span>Input Tokens</span>
|
||||
<span id="usage-input-tokens" class="status-value mono">0</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span>Output Tokens</span>
|
||||
<span id="usage-output-tokens" class="status-value mono">0</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span>Dedupe Hits</span>
|
||||
<span id="usage-dedupe" class="status-value mono">0</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span>Canceled</span>
|
||||
<span id="usage-canceled" class="status-value mono">0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Agents -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
@@ -208,6 +235,7 @@
|
||||
<label class="setting-item">
|
||||
<span>Default Model</span>
|
||||
<select id="default-model">
|
||||
<option value="auto">Auto (cost-aware)</option>
|
||||
<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>
|
||||
|
||||
+188
-24
@@ -1,4 +1,14 @@
|
||||
import { mountModernChatWidget } from './chat-widget';
|
||||
import {
|
||||
CHAT_BUDGETS,
|
||||
debugLog,
|
||||
debounce,
|
||||
estimateTextTokens,
|
||||
loadDebugFlagFromStorage,
|
||||
makeChatRequestKey,
|
||||
pickModelForTokenBudget,
|
||||
recordUsageDelta,
|
||||
trimMessagesToBudget,
|
||||
} from './runtime-guard';
|
||||
|
||||
// Hanzo AI Browser Extension Sidebar — Chat + Tools + Settings
|
||||
|
||||
@@ -11,8 +21,26 @@ class HanzoSidebar {
|
||||
this.streamController = null;
|
||||
this.authenticated = false;
|
||||
this.ragContext = null;
|
||||
this.chatWidgetMounted = false;
|
||||
this.chatWidgetMountPromise = null;
|
||||
this.toolsInitialized = false;
|
||||
this.monitoringStarted = false;
|
||||
this.lastRequestKey = null;
|
||||
this.lastRequestAt = 0;
|
||||
this.requestEpoch = 0;
|
||||
this.lastInputTokens = 0;
|
||||
this.lastOutputTokens = 0;
|
||||
this.refreshTabFilesystemDebounced = debounce(() => {
|
||||
if (this.currentTab === 'tools') {
|
||||
void this.refreshTabFilesystem();
|
||||
}
|
||||
}, 500);
|
||||
this.persistConversationDebounced = debounce(() => {
|
||||
const toStore = this.messages.slice(-50);
|
||||
chrome.storage.local.set({ hanzo_chat_messages: toStore });
|
||||
}, 400);
|
||||
|
||||
mountModernChatWidget();
|
||||
void loadDebugFlagFromStorage();
|
||||
this.initializeUI();
|
||||
this.setupEventListeners();
|
||||
this.checkAuth();
|
||||
@@ -49,6 +77,11 @@ class HanzoSidebar {
|
||||
gpuStatus: document.getElementById('gpu-status'),
|
||||
gpuModel: document.getElementById('gpu-model'),
|
||||
launchAgent: document.getElementById('launch-agent'),
|
||||
usageChatRequests: document.getElementById('usage-chat-requests'),
|
||||
usageInputTokens: document.getElementById('usage-input-tokens'),
|
||||
usageOutputTokens: document.getElementById('usage-output-tokens'),
|
||||
usageDedupe: document.getElementById('usage-dedupe'),
|
||||
usageCanceled: document.getElementById('usage-canceled'),
|
||||
|
||||
// Settings
|
||||
tabSettings: document.getElementById('tab-settings'),
|
||||
@@ -84,7 +117,7 @@ class HanzoSidebar {
|
||||
this.sendMessage();
|
||||
}
|
||||
});
|
||||
this.el.chatInput.addEventListener('input', () => this.autoResize());
|
||||
this.el.chatInput.addEventListener('input', () => this.scheduleAutoResize());
|
||||
this.el.ragEnabled?.addEventListener('change', () => {
|
||||
this.setRagStatus(this.el.ragEnabled.checked ? 'RAG enabled' : 'RAG disabled');
|
||||
});
|
||||
@@ -106,6 +139,9 @@ class HanzoSidebar {
|
||||
this.handleMessage(request);
|
||||
return true;
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener(() => this.refreshTabFilesystemDebounced());
|
||||
chrome.tabs.onRemoved.addListener(() => this.refreshTabFilesystemDebounced());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -118,10 +154,6 @@ class HanzoSidebar {
|
||||
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();
|
||||
|
||||
@@ -134,7 +166,7 @@ class HanzoSidebar {
|
||||
this.showChatLoginPrompt();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
debugLog('Auth check failed:', error);
|
||||
this.showChatLoginPrompt();
|
||||
}
|
||||
}
|
||||
@@ -207,6 +239,13 @@ class HanzoSidebar {
|
||||
|
||||
switchTab(name) {
|
||||
this.currentTab = name;
|
||||
if (name === 'chat') {
|
||||
void this.ensureChatWidgetMounted();
|
||||
}
|
||||
if (name === 'tools') {
|
||||
this.ensureToolsInitialized();
|
||||
void this.refreshUsageMetrics();
|
||||
}
|
||||
|
||||
// Update tab buttons
|
||||
document.querySelectorAll('.tab').forEach(t => {
|
||||
@@ -219,6 +258,37 @@ class HanzoSidebar {
|
||||
if (target) target.classList.remove('hidden');
|
||||
}
|
||||
|
||||
async ensureChatWidgetMounted() {
|
||||
if (this.chatWidgetMounted) return;
|
||||
if (this.chatWidgetMountPromise) {
|
||||
await this.chatWidgetMountPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.chatWidgetMountPromise = import('./chat-widget')
|
||||
.then((mod) => {
|
||||
mod.mountModernChatWidget();
|
||||
this.chatWidgetMounted = true;
|
||||
})
|
||||
.catch((error) => {
|
||||
debugLog('Failed to mount chat widget:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.chatWidgetMountPromise = null;
|
||||
});
|
||||
|
||||
await this.chatWidgetMountPromise;
|
||||
}
|
||||
|
||||
ensureToolsInitialized() {
|
||||
if (this.toolsInitialized) return;
|
||||
this.toolsInitialized = true;
|
||||
void this.connectToMCP();
|
||||
void this.refreshTabFilesystem();
|
||||
void this.checkWebGPU();
|
||||
void this.refreshUsageMetrics();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Chat
|
||||
// ===========================================================================
|
||||
@@ -229,28 +299,46 @@ class HanzoSidebar {
|
||||
if (response?.success && response.models?.length) {
|
||||
const select = this.el.modelSelect;
|
||||
select.innerHTML = '';
|
||||
const autoOpt = document.createElement('option');
|
||||
autoOpt.value = 'auto';
|
||||
autoOpt.textContent = 'Auto (cost-aware)';
|
||||
select.appendChild(autoOpt);
|
||||
|
||||
for (const model of response.models) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = model.id;
|
||||
opt.textContent = model.name || model.id;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
select.value = 'auto';
|
||||
// Also populate settings default model
|
||||
this.el.defaultModel.innerHTML = select.innerHTML;
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
debugLog('Failed to load models:', error);
|
||||
// Keep default options
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
const text = this.el.chatInput.value.trim();
|
||||
if (!text || this.streaming) return;
|
||||
if (!text) return;
|
||||
if (!this.authenticated) {
|
||||
this.showError('Please sign in to use chat');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.streaming && this.streamController) {
|
||||
this.streamController.abort();
|
||||
await chrome.runtime.sendMessage({ action: 'chat.cancel' }).catch(() => {});
|
||||
await recordUsageDelta({ canceledRequests: 1 });
|
||||
this.streaming = false;
|
||||
this.streamController = null;
|
||||
this.el.sendBtn.disabled = false;
|
||||
}
|
||||
|
||||
const requestId = ++this.requestEpoch;
|
||||
|
||||
// Get token
|
||||
const tokenResp = await chrome.runtime.sendMessage({ action: 'auth.getToken' });
|
||||
if (!tokenResp?.success || !tokenResp.token) {
|
||||
@@ -259,7 +347,7 @@ class HanzoSidebar {
|
||||
}
|
||||
|
||||
const userMessage = { role: 'user', content: text };
|
||||
const model = this.el.modelSelect.value;
|
||||
const selectedModel = this.el.modelSelect.value || 'auto';
|
||||
let requestMessages = [...this.messages, userMessage];
|
||||
|
||||
this.ragContext = null;
|
||||
@@ -282,13 +370,50 @@ class HanzoSidebar {
|
||||
this.setRagStatus('RAG disabled');
|
||||
}
|
||||
|
||||
const budgeted = trimMessagesToBudget(requestMessages, {
|
||||
maxMessages: CHAT_BUDGETS.maxMessages,
|
||||
maxInputTokens: CHAT_BUDGETS.maxInputTokens,
|
||||
});
|
||||
|
||||
if (!budgeted.messages.length) {
|
||||
this.showError('Message budget exhausted, please shorten your prompt');
|
||||
return;
|
||||
}
|
||||
|
||||
const availableModelIds = Array.from(this.el.modelSelect?.options || [])
|
||||
.map((option) => option.value)
|
||||
.filter((value) => value && value !== 'auto');
|
||||
const resolvedModel = pickModelForTokenBudget(selectedModel, availableModelIds, budgeted.inputTokens);
|
||||
const maxTokens = Math.min(
|
||||
CHAT_BUDGETS.maxOutputTokens,
|
||||
Math.max(200, Math.ceil(estimateTextTokens(text) * 1.8)),
|
||||
);
|
||||
const requestKey = makeChatRequestKey(resolvedModel, budgeted.messages, undefined, maxTokens);
|
||||
|
||||
if (
|
||||
this.lastRequestKey === requestKey &&
|
||||
Date.now() - this.lastRequestAt < CHAT_BUDGETS.duplicateWindowMs
|
||||
) {
|
||||
this.showNotification('Duplicate request skipped');
|
||||
await recordUsageDelta({ dedupeHits: 1 });
|
||||
return;
|
||||
}
|
||||
this.lastRequestKey = requestKey;
|
||||
this.lastRequestAt = Date.now();
|
||||
|
||||
if (budgeted.droppedMessages > 0) {
|
||||
this.setRagStatus(
|
||||
`Trimmed ${budgeted.droppedMessages} older message(s) for token budget`,
|
||||
);
|
||||
}
|
||||
|
||||
// Add user message to transcript
|
||||
this.messages.push(userMessage);
|
||||
this.appendMessage('user', text);
|
||||
|
||||
// Clear input
|
||||
this.el.chatInput.value = '';
|
||||
this.autoResize();
|
||||
this.scheduleAutoResize();
|
||||
|
||||
// Show typing
|
||||
this.streaming = true;
|
||||
@@ -308,9 +433,10 @@ class HanzoSidebar {
|
||||
'Authorization': `Bearer ${tokenResp.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: requestMessages,
|
||||
model: resolvedModel,
|
||||
messages: budgeted.messages,
|
||||
stream: true,
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
signal: (this.streamController = new AbortController()).signal,
|
||||
});
|
||||
@@ -326,6 +452,9 @@ class HanzoSidebar {
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
if (requestId !== this.requestEpoch) {
|
||||
throw new DOMException('Superseded by a newer request', 'AbortError');
|
||||
}
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
@@ -358,17 +487,30 @@ class HanzoSidebar {
|
||||
}
|
||||
|
||||
this.messages.push({ role: 'assistant', content: fullContent });
|
||||
this.lastInputTokens = budgeted.inputTokens;
|
||||
this.lastOutputTokens = estimateTextTokens(fullContent);
|
||||
await recordUsageDelta({
|
||||
chatRequests: 1,
|
||||
inputTokens: this.lastInputTokens,
|
||||
outputTokens: this.lastOutputTokens,
|
||||
});
|
||||
this.saveConversation();
|
||||
if (this.currentTab === 'tools') {
|
||||
void this.refreshUsageMetrics();
|
||||
}
|
||||
} catch (error) {
|
||||
typingEl.remove();
|
||||
if (error?.name !== 'AbortError') {
|
||||
assistantEl.remove();
|
||||
this.appendError(error?.message || 'Chat failed');
|
||||
await recordUsageDelta({ errors: 1 });
|
||||
}
|
||||
} finally {
|
||||
this.streaming = false;
|
||||
this.streamController = null;
|
||||
this.el.sendBtn.disabled = false;
|
||||
if (requestId === this.requestEpoch) {
|
||||
this.streaming = false;
|
||||
this.streamController = null;
|
||||
this.el.sendBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,6 +619,10 @@ class HanzoSidebar {
|
||||
this.el.chatMessages.scrollTop = this.el.chatMessages.scrollHeight;
|
||||
}
|
||||
|
||||
scheduleAutoResize() {
|
||||
requestAnimationFrame(() => this.autoResize());
|
||||
}
|
||||
|
||||
autoResize() {
|
||||
const el = this.el.chatInput;
|
||||
el.style.height = 'auto';
|
||||
@@ -532,9 +678,7 @@ class HanzoSidebar {
|
||||
}
|
||||
|
||||
saveConversation() {
|
||||
// Keep last 50 messages
|
||||
const toStore = this.messages.slice(-50);
|
||||
chrome.storage.local.set({ hanzo_chat_messages: toStore });
|
||||
this.persistConversationDebounced();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -588,6 +732,21 @@ class HanzoSidebar {
|
||||
this.el.mcpToolList.textContent = `${preview.join('\n')}${suffix}`;
|
||||
}
|
||||
|
||||
async refreshUsageMetrics() {
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({ action: 'usage.metrics' });
|
||||
if (!response?.success || !response.metrics) return;
|
||||
const metrics = response.metrics;
|
||||
if (this.el.usageChatRequests) this.el.usageChatRequests.textContent = String(metrics.chatRequests || 0);
|
||||
if (this.el.usageInputTokens) this.el.usageInputTokens.textContent = String(metrics.inputTokens || 0);
|
||||
if (this.el.usageOutputTokens) this.el.usageOutputTokens.textContent = String(metrics.outputTokens || 0);
|
||||
if (this.el.usageDedupe) this.el.usageDedupe.textContent = String(metrics.dedupeHits || 0);
|
||||
if (this.el.usageCanceled) this.el.usageCanceled.textContent = String(metrics.canceledRequests || 0);
|
||||
} catch (error) {
|
||||
debugLog('Failed to fetch usage metrics:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshTabFilesystem() {
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
@@ -828,7 +987,7 @@ class HanzoSidebar {
|
||||
mcpPort,
|
||||
cdpPort,
|
||||
zapPorts: zapPorts.length ? zapPorts : [9999,9998,9997,9996,9995],
|
||||
hanzo_default_model: this.el.defaultModel?.value || 'gpt-4o',
|
||||
hanzo_default_model: this.el.defaultModel?.value || 'auto',
|
||||
'safe-mode': document.getElementById('safe-mode')?.checked,
|
||||
'enable-webgpu': document.getElementById('enable-webgpu')?.checked,
|
||||
maxAgents: parseInt(document.getElementById('max-agents')?.value) || 3,
|
||||
@@ -850,9 +1009,14 @@ class HanzoSidebar {
|
||||
// ===========================================================================
|
||||
|
||||
startMonitoring() {
|
||||
setInterval(() => this.connectToMCP(), 10000);
|
||||
chrome.tabs.onUpdated.addListener(() => this.refreshTabFilesystem());
|
||||
chrome.tabs.onRemoved.addListener(() => this.refreshTabFilesystem());
|
||||
if (this.monitoringStarted) return;
|
||||
this.monitoringStarted = true;
|
||||
|
||||
setInterval(() => {
|
||||
if (this.currentTab !== 'tools') return;
|
||||
void this.connectToMCP();
|
||||
void this.refreshUsageMetrics();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
handleMessage(request) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
InFlightRequestDeduper,
|
||||
makeChatRequestKey,
|
||||
pickModelForTokenBudget,
|
||||
trimMessagesToBudget,
|
||||
} from '../src/runtime-guard';
|
||||
|
||||
describe('runtime-guard', () => {
|
||||
it('trims older messages to respect token budget while keeping newest', () => {
|
||||
const messages = [
|
||||
{ role: 'system', content: 'sys '.repeat(200) },
|
||||
{ role: 'user', content: 'older '.repeat(400) },
|
||||
{ role: 'assistant', content: 'older answer '.repeat(300) },
|
||||
{ role: 'user', content: 'latest prompt' },
|
||||
];
|
||||
|
||||
const result = trimMessagesToBudget(messages as any, {
|
||||
maxMessages: 24,
|
||||
maxInputTokens: 300,
|
||||
});
|
||||
|
||||
expect(result.messages.length).toBeGreaterThan(0);
|
||||
expect(result.messages[result.messages.length - 1].content).toContain('latest prompt');
|
||||
expect(result.droppedMessages).toBeGreaterThan(0);
|
||||
expect(result.inputTokens).toBeLessThanOrEqual(300 + 10);
|
||||
});
|
||||
|
||||
it('creates deterministic chat request keys', () => {
|
||||
const messages = [{ role: 'user', content: 'hello world' }];
|
||||
const k1 = makeChatRequestKey('gpt-4o', messages as any, 0.2, 500);
|
||||
const k2 = makeChatRequestKey('gpt-4o', messages as any, 0.2, 500);
|
||||
const k3 = makeChatRequestKey('gpt-4o', [{ role: 'user', content: 'hello world!' }] as any, 0.2, 500);
|
||||
|
||||
expect(k1).toBe(k2);
|
||||
expect(k1).not.toBe(k3);
|
||||
});
|
||||
|
||||
it('dedupes in-flight requests with the same key', async () => {
|
||||
const deduper = new InFlightRequestDeduper<string>();
|
||||
let runs = 0;
|
||||
|
||||
const first = deduper.getOrCreate('same', async () => {
|
||||
runs += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
return 'ok';
|
||||
});
|
||||
const second = deduper.getOrCreate('same', async () => {
|
||||
runs += 1;
|
||||
return 'second';
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([first.promise, second.promise]);
|
||||
|
||||
expect(a).toBe('ok');
|
||||
expect(b).toBe('ok');
|
||||
expect(first.deduped).toBe(false);
|
||||
expect(second.deduped).toBe(true);
|
||||
expect(runs).toBe(1);
|
||||
});
|
||||
|
||||
it('routes auto model to smaller model for small prompts', () => {
|
||||
const model = pickModelForTokenBudget(
|
||||
'auto',
|
||||
['gpt-4o', 'zen-coder-flash', 'zen-max'],
|
||||
200,
|
||||
);
|
||||
expect(model).toBe('zen-coder-flash');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user