Compare commits

...
5 Commits
Author SHA1 Message Date
Hanzo Dev 19c0ebd52a release(browser): cut 1.7.4 with resilient CI bundle checks 2026-03-04 22:00:10 -08:00
Hanzo Dev 9e11e21972 fix(ci): make bundle budget check resilient to missing dist artifacts 2026-03-04 21:54:59 -08:00
Hanzo Dev 34b94e5a29 feat(browser): harden runtime usage and cost controls 2026-03-04 21:49:00 -08:00
Hanzo Dev c02f5b9293 fix: remove connect-src from MV3 CSP, add vibrancy CSS
Chrome MV3 only allows script-src, object-src, worker-src in
content_security_policy.extension_pages. connect-src causes manifest
rejection. Also synced monochrome glass/vibrancy CSS from browser-extension.
2026-03-04 20:28:23 -08:00
Hanzo Dev 46170fa7b5 chore(browser): version publish artifacts by extension version 2026-03-04 20:27:31 -08:00
14 changed files with 1285 additions and 208 deletions
+6
View File
@@ -64,6 +64,12 @@ jobs:
working-directory: packages/browser
run: node src/build.js
- name: Check browser bundle budgets
working-directory: packages/browser
run: |
[ -f dist/browser-extension/background.js ] || node src/build.js
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
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@hanzo/browser-extension",
"version": "1.7.2",
"version": "1.7.4",
"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
View File
@@ -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,62 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
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;
function resolveBundlePath(relPath) {
const direct = path.join(root, relPath);
if (fs.existsSync(direct)) return direct;
const baseName = path.basename(relPath);
const chromeFallback = path.join(root, 'dist/browser-extension/chrome', baseName);
if (fs.existsSync(chromeFallback)) return chromeFallback;
const firefoxFallback = path.join(root, 'dist/browser-extension/firefox', baseName);
if (fs.existsSync(firefoxFallback)) return firefoxFallback;
return null;
}
function ensureBuildArtifacts() {
const hasAnyArtifacts = Object.keys(budgets).some((relPath) => !!resolveBundlePath(relPath));
if (hasAnyArtifacts) return;
console.log('No bundle artifacts found; running build once before budget check...');
execSync('node src/build.js', { cwd: root, stdio: 'inherit' });
}
ensureBuildArtifacts();
for (const [relPath, maxBytes] of Object.entries(budgets)) {
const filePath = resolveBundlePath(relPath);
if (!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';
const displayPath = path.relative(root, filePath);
console.log(`${status} ${relPath} [${displayPath}] ${kb}KB / ${limitKb}KB`);
if (size > maxBytes) {
failed = true;
}
}
if (failed) {
process.exit(1);
}
+169 -20
View File
@@ -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);
}
+27 -4
View File
@@ -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)
+2 -2
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Hanzo AI",
"version": "1.7.2",
"version": "1.7.4",
"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": [
{
+2 -2
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Hanzo AI",
"version": "1.7.2",
"version": "1.7.4",
"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
View File
@@ -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;
}
+293
View File
@@ -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';
}
+243 -70
View File
@@ -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; }
}
+28
View File
@@ -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
View File
@@ -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');
});
});