Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6487166c9 | ||
|
|
8cf8d32ce1 | ||
|
|
664e602886 | ||
|
|
19c0ebd52a | ||
|
|
9e11e21972 | ||
|
|
34b94e5a29 | ||
|
|
c02f5b9293 | ||
|
|
46170fa7b5 | ||
|
|
47889287e4 | ||
|
|
3be78f5763 | ||
|
|
935bbcac5f |
@@ -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
|
||||
@@ -223,12 +229,13 @@ jobs:
|
||||
name: jetbrains
|
||||
path: packages/jetbrains/build/distributions/*.zip
|
||||
|
||||
# ─── GitHub Release (only when all builds pass on tag) ───
|
||||
# ─── GitHub Release (on tag, after core build passes) ───
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, build-safari, build-jetbrains]
|
||||
if: >-
|
||||
always() &&
|
||||
github.ref_type == 'tag' &&
|
||||
needs.build.result == 'success'
|
||||
steps:
|
||||
@@ -248,17 +255,18 @@ jobs:
|
||||
echo "tag=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "name=Release $VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Collect release assets
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts -type f \( -name '*.zip' -o -name '*.vsix' -o -name '*.dxt' \) -exec cp {} release/ \;
|
||||
echo "Release assets:" && ls -la release/
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ steps.info.outputs.tag }}
|
||||
name: ${{ steps.info.outputs.name }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/extensions/browser/hanzo-ai-chrome-*.zip
|
||||
artifacts/extensions/browser/hanzo-ai-firefox-*.zip
|
||||
artifacts/extensions/vscode/*.vsix
|
||||
artifacts/safari/hanzo-ai-safari-*.zip
|
||||
artifacts/jetbrains/*.zip
|
||||
files: release/*
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -54,3 +54,4 @@ packages/*/*.tgz
|
||||
# Claude chats
|
||||
.claude/
|
||||
claude_chats/
|
||||
*.zip
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hanzo-ai-monorepo",
|
||||
"version": "1.6.3",
|
||||
"version": "1.7.6",
|
||||
"private": true,
|
||||
"description": "Hanzo AI Development Platform Monorepo",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/dev.git"
|
||||
"url": "https://github.com/hanzoai/extension.git"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.4",
|
||||
"engines": {
|
||||
|
||||
@@ -77,4 +77,35 @@ test.describe('Popup', () => {
|
||||
const openPanel = page.locator('#open-panel');
|
||||
await expect(openPanel).toBeAttached();
|
||||
});
|
||||
|
||||
test('on-page overlay toggle button exists', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
const openOverlay = page.locator('#open-page-overlay');
|
||||
await expect(openOverlay).toBeAttached();
|
||||
await expect(openOverlay).toContainText('Overlay');
|
||||
});
|
||||
|
||||
test('toggles on-page overlay on a live webpage', async ({ context, extensionId }) => {
|
||||
const targetPage = await context.newPage();
|
||||
await targetPage.goto('https://example.com', { waitUntil: 'domcontentloaded' });
|
||||
await expect(targetPage.locator('body')).toBeVisible();
|
||||
|
||||
// Content script mounts hidden overlay root.
|
||||
await expect(targetPage.locator('#hanzo-page-overlay-root')).toHaveCount(1);
|
||||
|
||||
const popupPage = await context.newPage();
|
||||
await popupPage.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
const toggleOverlayBtn = popupPage.locator('#open-page-overlay');
|
||||
await expect(toggleOverlayBtn).toBeVisible();
|
||||
await toggleOverlayBtn.click();
|
||||
|
||||
await targetPage.bringToFront();
|
||||
const overlayRoot = targetPage.locator('#hanzo-page-overlay-root');
|
||||
await expect(overlayRoot).toHaveAttribute('data-enabled', 'true');
|
||||
await expect(overlayRoot).toHaveAttribute('data-open', 'true');
|
||||
await expect(targetPage.locator('#hanzo-page-overlay-root .hanzo-page-panel')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,10 @@ test.describe('Sidebar', () => {
|
||||
await expect(authBtn).toBeVisible();
|
||||
await expect(authBtn).toContainText('Sign in');
|
||||
|
||||
const signupBtn = page.locator('#signup-btn');
|
||||
await expect(signupBtn).toBeVisible();
|
||||
await expect(signupBtn).toContainText('Create account');
|
||||
|
||||
// Should have link to docs
|
||||
const docsLink = loginPrompt.locator('a[href="https://docs.hanzo.ai"]');
|
||||
await expect(docsLink).toBeAttached();
|
||||
@@ -95,6 +99,9 @@ test.describe('Sidebar', () => {
|
||||
|
||||
// Chat UI elements should exist
|
||||
await expect(page.locator('#model-select')).toBeAttached();
|
||||
await expect(page.locator('#rag-enabled')).toBeAttached();
|
||||
await expect(page.locator('#tab-context-enabled')).toBeAttached();
|
||||
await expect(page.locator('#rag-status')).toBeAttached();
|
||||
await expect(page.locator('#chat-input')).toBeAttached();
|
||||
await expect(page.locator('#send-btn')).toBeAttached();
|
||||
});
|
||||
@@ -121,6 +128,7 @@ test.describe('Sidebar', () => {
|
||||
|
||||
await expect(page.locator('#mcp-status')).toBeAttached();
|
||||
await expect(page.locator('#mcp-tools')).toBeAttached();
|
||||
await expect(page.locator('#mcp-tool-list')).toBeAttached();
|
||||
await expect(page.locator('#agent-count')).toBeAttached();
|
||||
await expect(page.locator('#tab-fs')).toBeAttached();
|
||||
await expect(page.locator('#gpu-status')).toBeAttached();
|
||||
@@ -137,6 +145,12 @@ test.describe('Sidebar', () => {
|
||||
await expect(page.locator('#mcp-port-setting')).toBeAttached();
|
||||
await expect(page.locator('#cdp-port-setting')).toBeAttached();
|
||||
await expect(page.locator('#zap-ports-setting')).toBeAttached();
|
||||
await expect(page.locator('#rag-use-zap')).toBeAttached();
|
||||
await expect(page.locator('#rag-include-tab-context')).toBeAttached();
|
||||
await expect(page.locator('#rag-top-k')).toBeAttached();
|
||||
await expect(page.locator('#rag-kb')).toBeAttached();
|
||||
await expect(page.locator('#rag-endpoint')).toBeAttached();
|
||||
await expect(page.locator('#rag-api-key')).toBeAttached();
|
||||
await expect(page.locator('#save-settings')).toBeAttached();
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.6",
|
||||
"description": "Hanzo AI Browser Extension",
|
||||
"main": "dist/background.js",
|
||||
"scripts": {
|
||||
"build": "echo 'Browser extension build completed'",
|
||||
"build": "node src/build.js",
|
||||
"watch": "node src/build.js --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"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",
|
||||
@@ -25,6 +26,8 @@
|
||||
"@playwright/test": "^1.52.0",
|
||||
"esbuild": "^0.25.6",
|
||||
"jsdom": "^26.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^0.34.6",
|
||||
"ws": "^8.18.3"
|
||||
@@ -33,4 +36,4 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
+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,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);
|
||||
}
|
||||
@@ -49,7 +49,9 @@ function generateState(): string {
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IAM_BASE = 'https://hanzo.id';
|
||||
// Login UI lives on hanzo.id; Casdoor API is on iam.hanzo.ai
|
||||
const IAM_LOGIN = 'https://hanzo.id';
|
||||
const IAM_API = 'https://iam.hanzo.ai';
|
||||
const CLIENT_ID = 'app-hanzo';
|
||||
const SCOPES = 'openid profile email';
|
||||
|
||||
@@ -134,7 +136,7 @@ export async function login(): Promise<UserInfo> {
|
||||
const state = generateState();
|
||||
const redirectUri = getRedirectUri();
|
||||
|
||||
const authorizeUrl = new URL(`${IAM_BASE}/login/oauth/authorize`);
|
||||
const authorizeUrl = new URL(`${IAM_LOGIN}/login/oauth/authorize`);
|
||||
authorizeUrl.searchParams.set('client_id', CLIENT_ID);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
@@ -150,40 +152,58 @@ export async function login(): Promise<UserInfo> {
|
||||
const returnedState = url.searchParams.get('state');
|
||||
if (returnedState !== state) throw new Error('State mismatch — possible CSRF');
|
||||
|
||||
// Handle both code flow and implicit token flow responses.
|
||||
// The login page may return tokens directly (type=token) or an auth code (type=code).
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) {
|
||||
const error = url.searchParams.get('error_description') || url.searchParams.get('error') || 'No authorization code';
|
||||
const directToken = url.searchParams.get('access_token');
|
||||
|
||||
if (directToken) {
|
||||
// Implicit flow — tokens returned directly in redirect URL
|
||||
const tokens: TokenData = {
|
||||
access_token: directToken,
|
||||
refresh_token: url.searchParams.get('refresh_token') || undefined,
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
await storeTokens(tokens);
|
||||
} else if (code) {
|
||||
// Authorization code flow — exchange code for tokens via PKCE
|
||||
const tokenResponse = await fetch(`${IAM_API}/api/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errText = await tokenResponse.text();
|
||||
throw new Error(`Token exchange failed: ${errText}`);
|
||||
}
|
||||
|
||||
const tokens: TokenData = await tokenResponse.json();
|
||||
await storeTokens(tokens);
|
||||
} else {
|
||||
const error = url.searchParams.get('error_description') || url.searchParams.get('error') || 'No authorization code or token';
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenResponse = await fetch(`${IAM_BASE}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: CLIENT_ID,
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errText = await tokenResponse.text();
|
||||
throw new Error(`Token exchange failed: ${errText}`);
|
||||
}
|
||||
|
||||
const tokens: TokenData = await tokenResponse.json();
|
||||
await storeTokens(tokens);
|
||||
|
||||
// Fetch user info
|
||||
const user = await fetchUserInfo(tokens.access_token);
|
||||
// Fetch user info using the stored token
|
||||
const { accessToken } = await getStoredTokens();
|
||||
if (!accessToken) throw new Error('Login succeeded but no token was stored');
|
||||
const user = await fetchUserInfo(accessToken);
|
||||
await chrome.storage.local.set({ [STORAGE_KEYS.user]: user });
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function signup(): Promise<void> {
|
||||
await openExternalTab(`${IAM_LOGIN}/signup`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a browser tab for OAuth login and wait for the redirect.
|
||||
* Returns the full callback URL with authorization code.
|
||||
@@ -237,6 +257,18 @@ function openAuthTab(authorizeUrl: string, redirectUriPrefix: string): Promise<s
|
||||
});
|
||||
}
|
||||
|
||||
function openExternalTab(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.create({ url }, (tab) => {
|
||||
if (chrome.runtime.lastError || !tab?.id) {
|
||||
reject(new Error(chrome.runtime.lastError?.message || 'Failed to open tab'));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out — clear all stored tokens.
|
||||
*/
|
||||
@@ -276,10 +308,10 @@ export async function getValidAccessToken(): Promise<string | null> {
|
||||
* Refresh access token using refresh_token grant.
|
||||
*/
|
||||
async function refreshAccessToken(refreshToken: string): Promise<string> {
|
||||
const response = await fetch(`${IAM_BASE}/oauth/token`, {
|
||||
const response = await fetch(`${IAM_API}/api/login/oauth/access_token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
@@ -297,7 +329,7 @@ async function refreshAccessToken(refreshToken: string): Promise<string> {
|
||||
* Fetch user info from IAM.
|
||||
*/
|
||||
async function fetchUserInfo(accessToken: string): Promise<UserInfo> {
|
||||
const response = await fetch(`${IAM_BASE}/oauth/userinfo`, {
|
||||
const response = await fetch(`${IAM_API}/api/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch user info');
|
||||
|
||||
@@ -606,6 +606,15 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth.signup':
|
||||
try {
|
||||
await browser.tabs.create({ url: `${IAM_BASE}/signup` });
|
||||
sendResponse({ success: true });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth.logout':
|
||||
await browser.storage.local.remove(Object.values(STORAGE_KEYS));
|
||||
sendResponse({ success: true });
|
||||
|
||||
@@ -3,7 +3,20 @@ import { BrowserControl } from './browser-control';
|
||||
import { WebGPUAI } from './webgpu-ai';
|
||||
import { getCDPBridge, CDPBridge } from './cdp-bridge';
|
||||
import * as auth from './auth';
|
||||
import { listModels } from './chat-client';
|
||||
import { listModels, chatCompletion, ChatMessage } from './chat-client';
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -490,6 +555,18 @@ function sendControlMessageToTab(tabId: number, message: Record<string, unknown>
|
||||
});
|
||||
}
|
||||
|
||||
function sendMessageToTab(tabId: number, message: Record<string, unknown>): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
chrome.tabs.sendMessage(tabId, message, (response) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
reject(new Error(chrome.runtime.lastError.message || 'Failed to contact page content script'));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveTabId(): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
@@ -534,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',
|
||||
@@ -783,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;
|
||||
@@ -805,6 +902,28 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
|
||||
// --- In-page overlay (content script) ---
|
||||
case 'page.overlay.toggle':
|
||||
case 'page.overlay.show':
|
||||
case 'page.overlay.hide':
|
||||
case 'page.overlay.status': {
|
||||
try {
|
||||
const tabId = typeof request.tabId === 'number'
|
||||
? request.tabId
|
||||
: (sender.tab?.id ?? await getActiveTabId());
|
||||
if (tabId === null) {
|
||||
sendResponse({ success: false, error: 'No target tab found' });
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await sendMessageToTab(tabId, { action: request.action });
|
||||
sendResponse({ success: true, tabId, ...(response || {}) });
|
||||
} catch (error: any) {
|
||||
sendResponse({ success: false, error: error.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Auth (Hanzo IAM OAuth2 + PKCE) ---
|
||||
case 'auth.login':
|
||||
try {
|
||||
@@ -815,6 +934,15 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth.signup':
|
||||
try {
|
||||
await auth.signup();
|
||||
sendResponse({ success: true });
|
||||
} catch (error: any) {
|
||||
sendResponse({ success: false, error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth.logout':
|
||||
try {
|
||||
await auth.logout();
|
||||
@@ -898,13 +1026,183 @@ 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();
|
||||
if (!token) {
|
||||
sendResponse({ success: false, error: 'Not authenticated' });
|
||||
break;
|
||||
}
|
||||
|
||||
const senderKey = getSenderKey(sender);
|
||||
const requestedModel = String(request.model || 'auto');
|
||||
const messagesInput = Array.isArray(request.messages) ? request.messages : [];
|
||||
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),
|
||||
}));
|
||||
|
||||
const budgeted = trimMessagesToBudget(rawMessages, {
|
||||
maxMessages: CHAT_BUDGETS.maxMessages,
|
||||
maxInputTokens: CHAT_BUDGETS.maxInputTokens,
|
||||
});
|
||||
|
||||
if (!budgeted.messages.length) {
|
||||
sendResponse({ success: false, error: 'messages are required' });
|
||||
break;
|
||||
}
|
||||
|
||||
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: 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,
|
||||
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 });
|
||||
}
|
||||
break;
|
||||
|
||||
// --- Bridge status ---
|
||||
case 'bridge.status': {
|
||||
const bridgeConnected = cdpBridge.isBridgeConnected();
|
||||
sendResponse({
|
||||
success: true,
|
||||
connected: bridgeConnected,
|
||||
browsers: bridgeConnected ? [detectBrowser()] : [],
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Config persistence ---
|
||||
case 'config.save': {
|
||||
const { key, value } = request;
|
||||
// Forward to CDP bridge server so it can save to ~/.hanzo/extension/config.json
|
||||
cdpBridge.sendConfig(key, value);
|
||||
|
||||
// Also sync to IAM if authenticated (best-effort)
|
||||
try {
|
||||
const token = await auth.getValidAccessToken();
|
||||
if (token) {
|
||||
fetch('https://hanzo.id/api/update-user', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
properties: { [key]: value },
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Not authenticated
|
||||
}
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Takeover messages (forwarded from CDP bridge to content script) ---
|
||||
case 'hanzo.takeover.start': {
|
||||
const takeoverTabId = await resolveControlTabId(request.tabId);
|
||||
if (takeoverTabId === null) {
|
||||
sendResponse({ success: false, error: 'No target tab found' });
|
||||
break;
|
||||
}
|
||||
await startControlSession(takeoverTabId, request.task);
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'hanzo.takeover.end': {
|
||||
await stopControlSession();
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'hanzo.takeover.cursor': {
|
||||
const cursorTabId = await resolveControlTabId(request.tabId);
|
||||
if (cursorTabId !== null) {
|
||||
sendControlMessageToTab(cursorTabId, {
|
||||
action: 'ai.control.cursor',
|
||||
x: request.x,
|
||||
y: request.y,
|
||||
});
|
||||
}
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Content script element selection (routed from content script) ---
|
||||
case 'elementSelected':
|
||||
// Forward to ZAP-connected MCPs (primary)
|
||||
@@ -940,7 +1238,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) => {
|
||||
@@ -995,7 +1293,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);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,30 @@ const { execSync } = require('child_process');
|
||||
async function build() {
|
||||
console.log('Building browser extension...');
|
||||
|
||||
const hanzoUiRoot = path.resolve(__dirname, '../../../../ui');
|
||||
const hanzoUiPrimitives = path.join(hanzoUiRoot, 'pkg/ui/dist/primitives/index-common.js');
|
||||
const localReact = path.join(hanzoUiRoot, 'node_modules/react');
|
||||
const localReactDom = path.join(hanzoUiRoot, 'node_modules/react-dom');
|
||||
const forceLocalUiShim = process.env.HANZO_FORCE_LOCAL_UI_SHIM === '1';
|
||||
const canUseSharedUi =
|
||||
!forceLocalUiShim &&
|
||||
fs.existsSync(hanzoUiPrimitives) &&
|
||||
fs.existsSync(localReact) &&
|
||||
fs.existsSync(localReactDom);
|
||||
|
||||
if (canUseSharedUi) {
|
||||
console.log('Using shared @hanzo/ui primitives from sibling repo:', hanzoUiPrimitives);
|
||||
} else {
|
||||
console.warn(
|
||||
[
|
||||
'Shared @hanzo/ui dependencies were not found. Falling back to local primitives shim.',
|
||||
`Expected: ${hanzoUiPrimitives}`,
|
||||
`Expected: ${localReact}`,
|
||||
`Expected: ${localReactDom}`,
|
||||
].join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure dist directories exist
|
||||
fs.mkdirSync('dist/browser-extension', { recursive: true });
|
||||
fs.mkdirSync('dist/browser-extension/chrome', { recursive: true });
|
||||
@@ -57,6 +81,16 @@ async function build() {
|
||||
});
|
||||
|
||||
// Build sidebar + popup UI scripts (TypeScript-first, with JS fallback)
|
||||
const sidebarAliases = canUseSharedUi
|
||||
? {
|
||||
'@hanzo/ui/primitives-common': hanzoUiPrimitives,
|
||||
react: localReact,
|
||||
'react-dom': localReactDom,
|
||||
}
|
||||
: {
|
||||
'@hanzo/ui/primitives-common': path.join(__dirname, 'primitives-common-fallback.tsx'),
|
||||
};
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [fs.existsSync('src/sidebar.ts') ? 'src/sidebar.ts' : 'src/sidebar.js'],
|
||||
bundle: true,
|
||||
@@ -64,6 +98,7 @@ async function build() {
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'firefox91', 'safari14'],
|
||||
sourcemap: 'inline',
|
||||
alias: sidebarAliases,
|
||||
});
|
||||
|
||||
await esbuild.build({
|
||||
@@ -286,4 +321,7 @@ server.on('elementSelected', (data) => {
|
||||
console.log('\nTo publish to npm: cd dist/browser-extension && npm publish');
|
||||
}
|
||||
|
||||
build().catch(console.error);
|
||||
build().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import * as path from 'path';
|
||||
interface CDPClient {
|
||||
ws: WebSocket;
|
||||
capabilities: string[];
|
||||
browser: string;
|
||||
tabId?: number;
|
||||
}
|
||||
|
||||
@@ -87,9 +88,15 @@ class CDPBridgeServer {
|
||||
if (message.type === 'register') {
|
||||
this.clients.set(ws, {
|
||||
ws,
|
||||
capabilities: message.capabilities || []
|
||||
capabilities: message.capabilities || [],
|
||||
browser: message.browser || 'unknown',
|
||||
});
|
||||
console.log('[hanzo.browser] Registered:', message.capabilities?.join(', '));
|
||||
console.log(`[hanzo.browser] Registered ${message.browser || 'unknown'}:`, message.capabilities?.join(', '));
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'config') {
|
||||
this.saveConfig(message.key, message.value);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -111,6 +118,37 @@ class CDPBridgeServer {
|
||||
}
|
||||
}
|
||||
|
||||
/** Save a config value to ~/.hanzo/extension/config.json */
|
||||
private saveConfig(key: string, value: unknown): void {
|
||||
const configDir = path.join(
|
||||
process.env.HOME || process.env.USERPROFILE || '.',
|
||||
'.hanzo',
|
||||
'extension',
|
||||
);
|
||||
const configPath = path.join(configDir, 'config.json');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
config[key] = value;
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
||||
console.log(`[hanzo.browser] Config saved: ${key} = ${JSON.stringify(value)}`);
|
||||
} catch (error: any) {
|
||||
console.error(`[hanzo.browser] Failed to save config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get connected browser names */
|
||||
getConnectedBrowsers(): string[] {
|
||||
return Array.from(this.clients.values())
|
||||
.map((c) => (c as any).browser || 'unknown')
|
||||
.filter((b) => b !== 'unknown');
|
||||
}
|
||||
|
||||
private async sendRaw(method: string, params?: any): Promise<any> {
|
||||
const client = Array.from(this.clients.values())[0];
|
||||
if (!client) {
|
||||
@@ -332,6 +370,7 @@ async function startJSONRPCServer(bridgeServer: CDPBridgeServer) {
|
||||
res.end(JSON.stringify({
|
||||
service: 'hanzo.browser',
|
||||
connected: bridgeServer.isConnected(),
|
||||
browsers: bridgeServer.getConnectedBrowsers(),
|
||||
actions: [
|
||||
'navigate', 'navigate_back', 'reload', 'url', 'title', 'content',
|
||||
'screenshot', 'snapshot',
|
||||
@@ -341,6 +380,7 @@ async function startJSONRPCServer(bridgeServer: CDPBridgeServer) {
|
||||
'wait', 'wait_for_load',
|
||||
'tabs', 'new_tab', 'close_tab', 'select_tab',
|
||||
'console', 'network_requests',
|
||||
'takeover.start', 'takeover.end', 'takeover.cursor',
|
||||
'status'
|
||||
]
|
||||
}));
|
||||
|
||||
@@ -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,12 +279,20 @@ export class CDPBridge {
|
||||
this.wsServer = new WebSocket(url);
|
||||
|
||||
this.wsServer.onopen = () => {
|
||||
console.log('[CDP] Connected to bridge server');
|
||||
// Register as CDP provider
|
||||
debugLog('[CDP] Connected to bridge server');
|
||||
// Register as CDP provider with browser identification
|
||||
const browser = typeof navigator !== 'undefined'
|
||||
? (navigator.userAgent.includes('Firefox') ? 'firefox'
|
||||
: navigator.userAgent.includes('Edg/') ? 'edge'
|
||||
: navigator.userAgent.includes('Chrome') ? 'chrome'
|
||||
: navigator.userAgent.includes('Safari') ? 'safari'
|
||||
: 'unknown')
|
||||
: 'unknown';
|
||||
this.wsServer?.send(JSON.stringify({
|
||||
type: 'register',
|
||||
role: 'cdp-provider',
|
||||
capabilities: ['navigate', 'screenshot', 'click', 'type', 'evaluate']
|
||||
browser,
|
||||
capabilities: ['navigate', 'screenshot', 'click', 'type', 'evaluate', 'takeover']
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -302,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) {
|
||||
@@ -394,15 +406,24 @@ export class CDPBridge {
|
||||
|
||||
// Control overlay management
|
||||
case 'hanzo.control.start':
|
||||
this.notifyControlOverlay(tabId, 'ai.control.start', { task: params.task || 'AI is controlling this page' });
|
||||
case 'hanzo.takeover.start':
|
||||
this.notifyControlOverlay(tabId, 'ai.control.start', { task: params.task || 'Hanzo AI is controlling this tab' });
|
||||
result = { success: true };
|
||||
break;
|
||||
|
||||
case 'hanzo.control.stop':
|
||||
case 'hanzo.takeover.end':
|
||||
this.notifyControlOverlay(tabId, 'ai.control.stop', {});
|
||||
result = { success: true };
|
||||
break;
|
||||
|
||||
case 'hanzo.takeover.cursor':
|
||||
if (params?.x !== undefined && params?.y !== undefined) {
|
||||
this.notifyControlOverlay(tabId, 'ai.control.cursor', { x: params.x, y: params.y });
|
||||
}
|
||||
result = { success: true };
|
||||
break;
|
||||
|
||||
default:
|
||||
// Pass through to Chrome debugger
|
||||
result = await this.send(tabId, method, params);
|
||||
@@ -446,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)
|
||||
@@ -454,6 +494,21 @@ export class CDPBridge {
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
|
||||
/** Check whether the bridge WebSocket is connected. */
|
||||
isBridgeConnected(): boolean {
|
||||
return this.wsServer !== null && this.wsServer.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/** Send a config key/value to the bridge server for persistence. */
|
||||
sendConfig(key: string, value: unknown): void {
|
||||
if (!this.isBridgeConnected()) return;
|
||||
this.wsServer!.send(JSON.stringify({
|
||||
type: 'config',
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
private broadcastEvent(tabId: number, method: string, params: any): void {
|
||||
const event = JSON.stringify({
|
||||
type: 'event',
|
||||
@@ -461,7 +516,7 @@ export class CDPBridge {
|
||||
method,
|
||||
params
|
||||
});
|
||||
|
||||
|
||||
if (this.wsServer?.readyState === WebSocket.OPEN) {
|
||||
this.wsServer.send(event);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { flushSync } from 'react-dom';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Textarea,
|
||||
} from '@hanzo/ui/primitives-common';
|
||||
|
||||
function LoginPrompt() {
|
||||
return (
|
||||
<Card className="chat-modern-login-card">
|
||||
<CardHeader>
|
||||
<CardTitle>Chat with Zen AI models</CardTitle>
|
||||
<CardDescription>Models enabled in Hanzo Cloud are loaded automatically.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button id="auth-btn" className="chat-modern-auth-btn" type="button">
|
||||
Sign in
|
||||
</Button>
|
||||
<Button id="signup-btn" className="secondary-btn" type="button">
|
||||
Create account
|
||||
</Button>
|
||||
<p className="auth-note">
|
||||
Browser tools work without sign-in.{' '}
|
||||
<a href="https://docs.hanzo.ai" target="_blank" rel="noreferrer">
|
||||
Learn more
|
||||
</a>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatComposer() {
|
||||
return (
|
||||
<div className="chat-modern-composer">
|
||||
<div className="model-selector">
|
||||
<select id="model-select">
|
||||
<option value="claude-sonnet-4-20250514">Claude Sonnet 4</option>
|
||||
<option value="claude-opus-4-20250514">Claude Opus 4</option>
|
||||
<option value="gpt-4o">GPT-4o</option>
|
||||
<option value="zen-coder-flash">Zen Coder Flash</option>
|
||||
<option value="zen-max">Zen Max</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="chat-flags">
|
||||
<label className="flag-toggle">
|
||||
<input type="checkbox" id="rag-enabled" defaultChecked />
|
||||
<span>RAG</span>
|
||||
</label>
|
||||
<label className="flag-toggle">
|
||||
<input type="checkbox" id="tab-context-enabled" defaultChecked />
|
||||
<span>Tab Context</span>
|
||||
</label>
|
||||
<span id="rag-status" className="rag-status">
|
||||
Ready
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="input-row">
|
||||
<Textarea id="chat-input" className="chat-modern-input" placeholder="Ask anything..." rows={1} />
|
||||
<Button id="send-btn" className="send-btn chat-modern-send" title="Send" type="button">
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path d="M4 10l12-6-6 12-2-6-4-2z" strokeWidth="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function mountModernChatWidget() {
|
||||
const loginTarget = document.getElementById('chat-login-prompt');
|
||||
if (loginTarget) {
|
||||
loginTarget.innerHTML = '';
|
||||
const loginRoot = createRoot(loginTarget);
|
||||
flushSync(() => {
|
||||
loginRoot.render(<LoginPrompt />);
|
||||
});
|
||||
}
|
||||
|
||||
const composerTarget = document.getElementById('chat-composer');
|
||||
if (composerTarget) {
|
||||
composerTarget.innerHTML = '';
|
||||
const composerRoot = createRoot(composerTarget);
|
||||
flushSync(() => {
|
||||
composerRoot.render(<ChatComposer />);
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.6",
|
||||
"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.1",
|
||||
"version": "1.7.6",
|
||||
"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
-28
@@ -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,19 +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;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
@@ -54,6 +54,12 @@
|
||||
</svg>
|
||||
Open Chat Panel
|
||||
</button>
|
||||
<button id="open-page-overlay" class="btn btn-secondary" style="margin-bottom: 16px;">
|
||||
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path d="M4 4h16v12H8l-4 4V4z"/>
|
||||
</svg>
|
||||
Toggle On-Page Overlay
|
||||
</button>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="features">
|
||||
@@ -104,6 +110,26 @@
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Browser Backend -->
|
||||
<div class="setting-group backend-picker">
|
||||
<h3>Browser Backend</h3>
|
||||
<div class="backend-select-wrapper">
|
||||
<select id="browser-backend">
|
||||
<option value="auto">Auto (default)</option>
|
||||
<option value="this">This Browser</option>
|
||||
<option value="firefox">Firefox</option>
|
||||
<option value="chrome">Chrome</option>
|
||||
<option value="playwright">Playwright Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="backend-status">
|
||||
<span class="status-dot" id="bridge-status"></span>
|
||||
<span id="bridge-detail" class="status-detail">Checking bridge...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Connection Status -->
|
||||
<div class="status">
|
||||
<div class="status-item">
|
||||
|
||||
@@ -12,6 +12,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Open panel
|
||||
const openPanel = document.getElementById('open-panel');
|
||||
const openPageOverlay = document.getElementById('open-page-overlay');
|
||||
|
||||
// Status dots
|
||||
const zapStatus = document.getElementById('zap-status');
|
||||
@@ -86,6 +87,80 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
function isInjectableTab(tab: chrome.tabs.Tab | undefined): boolean {
|
||||
if (!tab || !tab.id) return false;
|
||||
const url = tab.url || '';
|
||||
if (!url) return false;
|
||||
if (url.startsWith('chrome://')) return false;
|
||||
if (url.startsWith('chrome-extension://')) return false;
|
||||
if (url.startsWith('devtools://')) return false;
|
||||
if (url.startsWith('edge://')) return false;
|
||||
if (url.startsWith('about:')) return false;
|
||||
if (url.startsWith('view-source:')) return false;
|
||||
if (url.startsWith('moz-extension://')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
openPageOverlay?.addEventListener('click', () => {
|
||||
openPageOverlay.textContent = 'Opening...';
|
||||
openPageOverlay.setAttribute('disabled', 'true');
|
||||
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const activeTab = tabs[0];
|
||||
let targetTab = isInjectableTab(activeTab) ? activeTab : null;
|
||||
|
||||
if (!targetTab) {
|
||||
chrome.tabs.query({ currentWindow: true }, (allTabs) => {
|
||||
targetTab = allTabs.find((tab) => isInjectableTab(tab)) || null;
|
||||
if (!targetTab?.id) {
|
||||
openPageOverlay.textContent = 'No valid page tab';
|
||||
setTimeout(() => {
|
||||
openPageOverlay.textContent = 'Toggle On-Page Overlay';
|
||||
openPageOverlay.removeAttribute('disabled');
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'page.overlay.toggle', tabId: targetTab.id }, (response) => {
|
||||
if (chrome.runtime.lastError || !response?.success) {
|
||||
openPageOverlay.textContent = 'Unavailable on this page';
|
||||
} else {
|
||||
const opened = !!response?.open;
|
||||
openPageOverlay.textContent = opened ? 'Overlay Opened' : 'Overlay Hidden';
|
||||
}
|
||||
setTimeout(() => {
|
||||
openPageOverlay.textContent = 'Toggle On-Page Overlay';
|
||||
openPageOverlay.removeAttribute('disabled');
|
||||
}, 900);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetTab?.id) {
|
||||
openPageOverlay.textContent = 'No Active Tab';
|
||||
setTimeout(() => {
|
||||
openPageOverlay.textContent = 'Toggle On-Page Overlay';
|
||||
openPageOverlay.removeAttribute('disabled');
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'page.overlay.toggle', tabId: targetTab.id }, (response) => {
|
||||
if (chrome.runtime.lastError || !response?.success) {
|
||||
openPageOverlay.textContent = 'Unavailable on this page';
|
||||
} else {
|
||||
const opened = !!response?.open;
|
||||
openPageOverlay.textContent = opened ? 'Overlay Opened' : 'Overlay Hidden';
|
||||
}
|
||||
setTimeout(() => {
|
||||
openPageOverlay.textContent = 'Toggle On-Page Overlay';
|
||||
openPageOverlay.removeAttribute('disabled');
|
||||
}, 900);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Status ---
|
||||
function refreshStatus() {
|
||||
// ZAP status
|
||||
@@ -187,6 +262,54 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Browser Backend Picker ---
|
||||
const backendSelect = document.getElementById('browser-backend') as HTMLSelectElement | null;
|
||||
const bridgeStatus = document.getElementById('bridge-status');
|
||||
const bridgeDetail = document.getElementById('bridge-detail');
|
||||
|
||||
function loadBackendPreference() {
|
||||
chrome.storage.sync.get(['browserBackend'], (result) => {
|
||||
if (backendSelect && result.browserBackend) {
|
||||
backendSelect.value = result.browserBackend;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshBridgeStatus() {
|
||||
chrome.runtime.sendMessage({ action: 'bridge.status' }, (response) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (response?.success && response.connected) {
|
||||
bridgeStatus?.classList.add('connected');
|
||||
bridgeStatus?.classList.remove('disconnected');
|
||||
const browsers = response.browsers || [];
|
||||
bridgeDetail.textContent = browsers.length
|
||||
? `Connected: ${browsers.join(', ')}`
|
||||
: 'Connected';
|
||||
} else {
|
||||
bridgeStatus?.classList.remove('connected');
|
||||
bridgeStatus?.classList.add('disconnected');
|
||||
bridgeDetail.textContent = 'Not connected';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
backendSelect?.addEventListener('change', () => {
|
||||
const value = backendSelect.value;
|
||||
|
||||
// Save to chrome.storage.sync (cross-device)
|
||||
chrome.storage.sync.set({ browserBackend: value });
|
||||
|
||||
// Forward to background to persist via CDP bridge + sync to IAM
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'config.save',
|
||||
key: 'browserBackend',
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
loadBackendPreference();
|
||||
refreshBridgeStatus();
|
||||
|
||||
// Init
|
||||
checkAuth();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
type Classy = { className?: string; children?: React.ReactNode };
|
||||
|
||||
function cx(base: string, extra?: string): string {
|
||||
return extra ? `${base} ${extra}` : base;
|
||||
}
|
||||
|
||||
export function Button(props: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<button {...rest} className={cx('hanzo-ui-btn', className)}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Textarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
const { className, children, ...rest } = props;
|
||||
return (
|
||||
<textarea {...rest} className={cx('hanzo-ui-textarea', className)}>
|
||||
{children}
|
||||
</textarea>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-ui-card', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-ui-card-header', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, children }: Classy) {
|
||||
return <h3 className={cx('hanzo-ui-card-title', className)}>{children}</h3>;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, children }: Classy) {
|
||||
return <p className={cx('hanzo-ui-card-description', className)}>{children}</p>;
|
||||
}
|
||||
|
||||
export function CardContent({ className, children }: Classy) {
|
||||
return <div className={cx('hanzo-ui-card-content', className)}>{children}</div>;
|
||||
}
|
||||
@@ -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,13 +255,57 @@ body {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Glass login card */
|
||||
.chat-modern-login-card {
|
||||
width: min(320px, 100%);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 14px;
|
||||
background: var(--glass-strong);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
color: var(--text-primary);
|
||||
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 {
|
||||
width: 100%;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chat-modern-login-card #signup-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.chat-modern-login-card .auth-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-modern-login-card .auth-note a {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.chat-modern-login-card .auth-note a:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ==================== Tab Bar ==================== */
|
||||
|
||||
.tab-bar {
|
||||
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 {
|
||||
@@ -201,12 +322,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 {
|
||||
@@ -214,6 +336,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 {
|
||||
@@ -221,6 +356,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.tools-scroll,
|
||||
@@ -249,6 +385,7 @@ body {
|
||||
color: var(--text-tertiary);
|
||||
padding: 40px 20px;
|
||||
font-size: 13px;
|
||||
animation: breathe 4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.msg {
|
||||
@@ -258,11 +395,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;
|
||||
@@ -273,10 +413,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;
|
||||
@@ -289,7 +431,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;
|
||||
}
|
||||
@@ -330,18 +472,23 @@ 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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.model-selector {
|
||||
@@ -351,12 +498,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 {
|
||||
@@ -396,7 +551,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);
|
||||
@@ -406,11 +561,18 @@ 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 {
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
@@ -425,16 +587,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 {
|
||||
@@ -459,7 +623,7 @@ body {
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
background: var(--glass-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -483,33 +647,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 {
|
||||
@@ -519,19 +705,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 {
|
||||
@@ -539,22 +728,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 {
|
||||
@@ -602,21 +811,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 {
|
||||
@@ -658,6 +869,7 @@ body {
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.user-details { flex: 1; }
|
||||
@@ -685,12 +897,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;
|
||||
@@ -727,7 +943,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);
|
||||
@@ -753,11 +969,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 {
|
||||
@@ -774,11 +997,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"] {
|
||||
@@ -830,7 +1059,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);
|
||||
@@ -845,25 +1076,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 ==================== */
|
||||
@@ -875,6 +1096,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;
|
||||
@@ -884,11 +1107,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 {
|
||||
@@ -926,7 +1152,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);
|
||||
@@ -953,8 +1179,3 @@ body {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<p class="auth-headline">Chat with Zen AI models</p>
|
||||
<p class="auth-sub">Claude, GPT-4o, Zen Coder Flash & more</p>
|
||||
<button id="auth-btn" class="primary-btn">Sign in</button>
|
||||
<button id="signup-btn" class="secondary-btn">Create account</button>
|
||||
<p class="auth-note">Browser tools work without sign-in. <a href="https://docs.hanzo.ai" target="_blank">Learn more</a></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,6 +62,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 +117,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 +236,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>
|
||||
|
||||
+227
-28
@@ -1,3 +1,15 @@
|
||||
import {
|
||||
CHAT_BUDGETS,
|
||||
debugLog,
|
||||
debounce,
|
||||
estimateTextTokens,
|
||||
loadDebugFlagFromStorage,
|
||||
makeChatRequestKey,
|
||||
pickModelForTokenBudget,
|
||||
recordUsageDelta,
|
||||
trimMessagesToBudget,
|
||||
} from './runtime-guard';
|
||||
|
||||
// Hanzo AI Browser Extension Sidebar — Chat + Tools + Settings
|
||||
|
||||
class HanzoSidebar {
|
||||
@@ -9,7 +21,27 @@ 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.authInFlight = false;
|
||||
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);
|
||||
|
||||
void loadDebugFlagFromStorage();
|
||||
this.initializeUI();
|
||||
this.setupEventListeners();
|
||||
this.checkAuth();
|
||||
@@ -46,6 +78,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'),
|
||||
@@ -65,8 +102,20 @@ class HanzoSidebar {
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Auth
|
||||
this.el.authBtn.addEventListener('click', () => this.login());
|
||||
// Auth (delegated so lazy-mounted React login card keeps working)
|
||||
document.addEventListener('click', (event) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (target.closest('#auth-btn')) {
|
||||
event.preventDefault();
|
||||
void this.login();
|
||||
return;
|
||||
}
|
||||
if (target.closest('#signup-btn')) {
|
||||
event.preventDefault();
|
||||
void this.signup();
|
||||
}
|
||||
});
|
||||
|
||||
// Tabs
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
@@ -81,7 +130,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');
|
||||
});
|
||||
@@ -103,6 +152,9 @@ class HanzoSidebar {
|
||||
this.handleMessage(request);
|
||||
return true;
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener(() => this.refreshTabFilesystemDebounced());
|
||||
chrome.tabs.onRemoved.addListener(() => this.refreshTabFilesystemDebounced());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -115,10 +167,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();
|
||||
|
||||
@@ -131,14 +179,20 @@ class HanzoSidebar {
|
||||
this.showChatLoginPrompt();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
debugLog('Auth check failed:', error);
|
||||
this.showChatLoginPrompt();
|
||||
}
|
||||
}
|
||||
|
||||
async login() {
|
||||
this.el.authBtn.disabled = true;
|
||||
this.el.authBtn.textContent = 'Signing in...';
|
||||
if (this.authInFlight) return;
|
||||
this.authInFlight = true;
|
||||
|
||||
const authBtn = document.getElementById('auth-btn') as HTMLButtonElement | null;
|
||||
if (authBtn) {
|
||||
authBtn.disabled = true;
|
||||
authBtn.textContent = 'Signing in...';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({ action: 'auth.login' });
|
||||
@@ -151,8 +205,21 @@ class HanzoSidebar {
|
||||
} catch (error) {
|
||||
this.showError('Sign in failed');
|
||||
} finally {
|
||||
this.el.authBtn.disabled = false;
|
||||
this.el.authBtn.textContent = 'Sign in with Hanzo';
|
||||
this.authInFlight = false;
|
||||
const latestAuthBtn = document.getElementById('auth-btn') as HTMLButtonElement | null;
|
||||
if (latestAuthBtn) {
|
||||
latestAuthBtn.disabled = false;
|
||||
latestAuthBtn.textContent = 'Sign in with Hanzo';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async signup() {
|
||||
const response = await chrome.runtime.sendMessage({ action: 'auth.signup' });
|
||||
if (!response?.success) {
|
||||
this.showError(response?.error || 'Unable to open signup');
|
||||
} else {
|
||||
this.showNotification('Signup opened in a new tab');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,6 +271,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 => {
|
||||
@@ -216,6 +290,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
|
||||
// ===========================================================================
|
||||
@@ -226,28 +331,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) {
|
||||
@@ -256,7 +379,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;
|
||||
@@ -279,13 +402,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;
|
||||
@@ -305,9 +465,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,
|
||||
});
|
||||
@@ -323,6 +484,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;
|
||||
|
||||
@@ -355,17 +519,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,6 +651,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';
|
||||
@@ -529,9 +710,7 @@ class HanzoSidebar {
|
||||
}
|
||||
|
||||
saveConversation() {
|
||||
// Keep last 50 messages
|
||||
const toStore = this.messages.slice(-50);
|
||||
chrome.storage.local.set({ hanzo_chat_messages: toStore });
|
||||
this.persistConversationDebounced();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -585,6 +764,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({});
|
||||
@@ -825,7 +1019,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,
|
||||
@@ -847,9 +1041,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) {
|
||||
|
||||
@@ -21,9 +21,13 @@ describe('Claude Code Browser Extension Integration', () => {
|
||||
await new Promise(resolve => ws.on('open', resolve));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
ws.close();
|
||||
server.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
// Fallback if close callback never fires
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('React source-map integration', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/dxt",
|
||||
"version": "1.5.7",
|
||||
"version": "1.7.6",
|
||||
"description": "Hanzo AI DXT Bundle",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -4,7 +4,7 @@ pluginGroup = ai.hanzo
|
||||
pluginName = Hanzo AI
|
||||
pluginRepositoryUrl = https://github.com/hanzoai/hanzo-ai-jetbrains
|
||||
# SemVer format -> https://semver.org
|
||||
pluginVersion = 0.1.0
|
||||
pluginVersion = 1.7.6
|
||||
|
||||
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
|
||||
pluginSinceBuild = 233
|
||||
|
||||
@@ -299,7 +299,7 @@ export const agentTool: Tool = {
|
||||
Task: ${args.task}
|
||||
${args.tools ? `Available tools: ${args.tools.join(', ')}` : ''}
|
||||
Complete this task and report back with your findings.`,
|
||||
tools: args.tools ? [] : undefined // TODO: Load actual tools when MCP integration is complete
|
||||
tools: args.tools ?? []
|
||||
});
|
||||
|
||||
// Execute the task
|
||||
|
||||
@@ -87,14 +87,25 @@ export const vectorIndexTool: Tool = {
|
||||
metadata: args.metadata
|
||||
});
|
||||
} else {
|
||||
// TODO: Handle directory indexing
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Directory indexing not yet implemented'
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
const files = await fs.readdir(args.path, { recursive: true });
|
||||
for (const file of files) {
|
||||
const filePath = path.join(args.path, file as string);
|
||||
const fileStat = await fs.stat(filePath).catch(() => null);
|
||||
if (fileStat?.isFile()) {
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
items.push({ content, path: filePath, metadata: args.metadata });
|
||||
} catch {
|
||||
// Skip binary/unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'No readable files found in directory' }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +213,8 @@ export const vectorSearchTool: Tool = {
|
||||
.search(queryEmbedding)
|
||||
.limit(args.limit || 10);
|
||||
|
||||
// Apply filters if provided
|
||||
if (args.filter) {
|
||||
// TODO: Implement filtering
|
||||
results = results.where(args.filter);
|
||||
}
|
||||
|
||||
// Execute search
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/cli-tools",
|
||||
"version": "1.5.7",
|
||||
"version": "1.7.6",
|
||||
"description": "Hanzo AI CLI Tools Integration",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -14,5 +14,4 @@
|
||||
// Main manager (without auth import for now)
|
||||
// export * from './cli-tool-manager';
|
||||
|
||||
// For now, just export a placeholder
|
||||
export const version = '1.5.7';
|
||||
export const version = '1.7.1';
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '../../src/config/agent-swarm-config';
|
||||
|
||||
vi.mock('fs');
|
||||
vi.mock('path');
|
||||
|
||||
describe('AgentSwarmManager', () => {
|
||||
let manager: AgentSwarmManager;
|
||||
@@ -98,28 +97,21 @@ describe('AgentSwarmManager', () => {
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith(mockConfigPath, 'utf8');
|
||||
});
|
||||
|
||||
it.skip('should try multiple paths if default not found', async () => {
|
||||
it('should try multiple paths if default not found', async () => {
|
||||
const configYaml = yaml.dump(mockConfig);
|
||||
|
||||
// Mock process.cwd
|
||||
const originalCwd = process.cwd;
|
||||
process.cwd = vi.fn().mockReturnValue('/test');
|
||||
|
||||
|
||||
// Pass a path that won't be found, forcing fallback to alternate paths
|
||||
vi.mocked(fs.existsSync)
|
||||
.mockReturnValueOnce(false) // .hanzo/agents.yaml
|
||||
.mockReturnValueOnce(false) // primary configPath
|
||||
.mockReturnValueOnce(false) // agents.yaml
|
||||
.mockReturnValueOnce(true); // .agents.yaml
|
||||
vi.mocked(fs.readFileSync).mockReturnValue(configYaml);
|
||||
|
||||
// Create new manager without specific path to test multiple paths
|
||||
const testManager = new AgentSwarmManager();
|
||||
|
||||
const testManager = new AgentSwarmManager('/nonexistent/agents.yaml');
|
||||
const config = await testManager.loadConfig();
|
||||
|
||||
|
||||
expect(config).toEqual(mockConfig);
|
||||
expect(fs.existsSync).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Restore process.cwd
|
||||
process.cwd = originalCwd;
|
||||
});
|
||||
|
||||
it('should throw error if no config found', async () => {
|
||||
@@ -409,11 +401,10 @@ describe('AgentSwarmManager', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
vi.mocked(path.dirname).mockReturnValue('.hanzo');
|
||||
|
||||
await AgentSwarmManager.initConfig('/test/agents.yaml');
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('.hanzo', { recursive: true });
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith('/test', { recursive: true });
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -430,7 +421,6 @@ describe('AgentSwarmManager', () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
vi.mocked(fs.mkdirSync).mockImplementation(() => undefined);
|
||||
vi.mocked(fs.writeFileSync).mockImplementation(() => undefined);
|
||||
vi.mocked(path.dirname).mockReturnValue('.hanzo');
|
||||
|
||||
await AgentSwarmManager.initPeerNetworkConfig('/test/agents-peer.yaml');
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@hanzo/extension",
|
||||
"displayName": "Hanzo AI",
|
||||
"description": "The ultimate meta AI development platform. Manage and run all LLMs and CLI tools (Claude, Codex, Gemini, OpenHands, Aider) in one unified interface. Features MCP integration, async execution, git worktrees, and universal context sync.",
|
||||
"version": "1.7.1",
|
||||
"version": "1.7.6",
|
||||
"publisher": "hanzo-ai",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
|
||||
@@ -134,7 +134,7 @@ export class BrowserExtensionServer extends EventEmitter {
|
||||
return null;
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.wss.close();
|
||||
public close(callback?: () => void) {
|
||||
this.wss.close(callback);
|
||||
}
|
||||
}
|
||||
Generated
+24
@@ -154,6 +154,12 @@ importers:
|
||||
jsdom:
|
||||
specifier: ^26.1.0
|
||||
version: 26.1.0
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1(react@18.3.1)
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.8.3
|
||||
@@ -4779,6 +4785,11 @@ packages:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
react-dom@18.3.1:
|
||||
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
|
||||
peerDependencies:
|
||||
react: ^18.3.1
|
||||
|
||||
react-is@18.3.1:
|
||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
||||
|
||||
@@ -4937,6 +4948,9 @@ packages:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
scheduler@0.23.2:
|
||||
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
|
||||
|
||||
semver@5.7.2:
|
||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||
hasBin: true
|
||||
@@ -10494,6 +10508,12 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
react-dom@18.3.1(react@18.3.1):
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
react: 18.3.1
|
||||
scheduler: 0.23.2
|
||||
|
||||
react-is@18.3.1: {}
|
||||
|
||||
react@18.3.1:
|
||||
@@ -10716,6 +10736,10 @@ snapshots:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
scheduler@0.23.2:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
semver@5.7.2: {}
|
||||
|
||||
semver@7.7.2: {}
|
||||
|
||||
Reference in New Issue
Block a user