Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da91976f3e | ||
|
|
e6b369a5d2 | ||
|
|
6b31b22d0d | ||
|
|
21e012047b | ||
|
|
6b8431a09b | ||
|
|
114d3c92d8 | ||
|
|
966ec0fd60 | ||
|
|
047d3f1843 | ||
|
|
64d7782869 | ||
|
|
b6dc2c0a6d | ||
|
|
b0d8a57ea2 | ||
|
|
cdd576ed1f | ||
|
|
cd89e9bd60 | ||
|
|
daa6a6a38c | ||
|
|
05ebd0b307 | ||
|
|
86e1866d3b | ||
|
|
c6487166c9 | ||
|
|
8cf8d32ce1 | ||
|
|
664e602886 | ||
|
|
19c0ebd52a | ||
|
|
9e11e21972 | ||
|
|
34b94e5a29 | ||
|
|
c02f5b9293 | ||
|
|
46170fa7b5 | ||
|
|
47889287e4 | ||
|
|
3be78f5763 | ||
|
|
935bbcac5f | ||
|
|
90c7267e22 | ||
|
|
75c9c87f52 | ||
|
|
24112477d4 | ||
|
|
acb512a12b | ||
|
|
22742ad2ea | ||
|
|
22df109c70 | ||
|
|
4b93aa6c7a | ||
|
|
1c5177bb67 | ||
|
|
23f74c4491 |
@@ -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
|
||||
@@ -94,11 +100,16 @@ jobs:
|
||||
working-directory: packages/browser
|
||||
run: node src/build.js
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: echo "ver=$(node -p "require('./packages/browser/src/manifest.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Package Chrome + Firefox
|
||||
working-directory: packages/browser
|
||||
run: |
|
||||
cd dist/browser-extension/chrome && zip -r ../../../hanzo-ai-chrome.zip .
|
||||
cd ../../browser-extension/firefox && zip -r ../../../hanzo-ai-firefox.zip .
|
||||
VER="${{ steps.version.outputs.ver }}"
|
||||
cd dist/browser-extension/chrome && zip -r ../../../hanzo-ai-chrome-${VER}.zip .
|
||||
cd ../../browser-extension/firefox && zip -r ../../../hanzo-ai-firefox-${VER}.zip .
|
||||
|
||||
- name: Lint Firefox
|
||||
working-directory: packages/browser
|
||||
@@ -139,8 +150,8 @@ jobs:
|
||||
with:
|
||||
name: extensions
|
||||
path: |
|
||||
packages/browser/hanzo-ai-chrome.zip
|
||||
packages/browser/hanzo-ai-firefox.zip
|
||||
packages/browser/hanzo-ai-chrome-*.zip
|
||||
packages/browser/hanzo-ai-firefox-*.zip
|
||||
packages/vscode/*.vsix
|
||||
packages/dxt/dist/*.dxt
|
||||
|
||||
@@ -179,14 +190,20 @@ jobs:
|
||||
CODE_SIGN_IDENTITY="-" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \
|
||||
-derivedDataPath dist/safari-build-ios
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: echo "ver=$(node -p "require('./packages/browser/src/manifest.json').version")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Package Safari
|
||||
working-directory: packages/browser
|
||||
run: cd dist/safari && zip -r ../../hanzo-ai-safari.zip "Hanzo AI/"
|
||||
run: |
|
||||
VER="${{ steps.version.outputs.ver }}"
|
||||
cd dist/safari && zip -r ../../hanzo-ai-safari-${VER}.zip "Hanzo AI/"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: safari
|
||||
path: packages/browser/hanzo-ai-safari.zip
|
||||
path: packages/browser/hanzo-ai-safari-*.zip
|
||||
|
||||
# ─── JetBrains ───
|
||||
build-jetbrains:
|
||||
@@ -199,7 +216,6 @@ jobs:
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
- uses: gradle/gradle-build-action@v2
|
||||
|
||||
- name: Build plugin
|
||||
working-directory: packages/jetbrains
|
||||
@@ -212,12 +228,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:
|
||||
@@ -237,17 +254,19 @@ jobs:
|
||||
echo "tag=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "name=Release $VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Collect release assets
|
||||
run: |
|
||||
mkdir -p release
|
||||
# Only collect artifacts with our expected naming patterns (prevents stale/duplicate files)
|
||||
find artifacts -type f \( -name 'hanzo-ai-*.zip' -o -name 'hanzo-ai-*.vsix' -o -name 'hanzoai-*.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 }}
|
||||
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- uses: gradle/gradle-build-action@v2
|
||||
|
||||
- name: Build and publish
|
||||
working-directory: packages/jetbrains
|
||||
run: ./gradlew publishPlugin
|
||||
|
||||
@@ -45,6 +45,8 @@ lib/graphene/
|
||||
.dev/
|
||||
test/test-results.json
|
||||
test-results/
|
||||
playwright-report/
|
||||
packages/*/playwright-report/
|
||||
packages/*/dist/
|
||||
packages/*/node_modules/
|
||||
packages/*/*.tgz
|
||||
@@ -52,3 +54,4 @@ packages/*/*.tgz
|
||||
# Claude chats
|
||||
.claude/
|
||||
claude_chats/
|
||||
*.zip
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hanzo-ai-monorepo",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.14",
|
||||
"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": {
|
||||
@@ -30,4 +30,4 @@
|
||||
"package:vscode": "pnpm --filter @hanzo/extension run package",
|
||||
"package:dxt": "pnpm --filter @hanzo/dxt run package"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
/**
|
||||
* End-to-end tests for the login + chat flow.
|
||||
*
|
||||
* These tests exercise the real Casdoor OAuth2 flow using the z@hanzo.ai
|
||||
* test account. They verify:
|
||||
* 1. Login tab opens correctly
|
||||
* 2. Password auth succeeds
|
||||
* 3. Token is stored and user info is fetched
|
||||
* 4. Chat sends a message to a Zen model via llm.hanzo.ai
|
||||
*
|
||||
* Requirements:
|
||||
* - IAM at iam.hanzo.ai / hanzo.id must be reachable
|
||||
* - LLM gateway at llm.hanzo.ai must be reachable
|
||||
* - Extension must be built: npm run build
|
||||
*/
|
||||
|
||||
const TEST_USER = 'z';
|
||||
const TEST_EMAIL = 'z@hanzo.ai';
|
||||
|
||||
// These tests require live IAM/LLM access — skip in CI
|
||||
const descFn = process.env.CI ? test.describe.skip : test.describe;
|
||||
|
||||
descFn('Auth + Chat', () => {
|
||||
test('login via tab-based OAuth flow and verify user stored', async ({ context, extensionId }) => {
|
||||
const sidebar = await context.newPage();
|
||||
await sidebar.goto(`chrome-extension://${extensionId}/sidebar.html`);
|
||||
await sidebar.waitForTimeout(500);
|
||||
|
||||
// Switch to chat tab — should show login prompt
|
||||
await sidebar.locator('[data-tab="chat"]').click();
|
||||
const loginPrompt = sidebar.locator('#chat-login-prompt');
|
||||
await expect(loginPrompt).toBeVisible();
|
||||
|
||||
// Click "Sign in" — this sends auth.login to the background,
|
||||
// which opens a new tab to hanzo.id/oauth/authorize
|
||||
const [authPage] = await Promise.all([
|
||||
context.waitForEvent('page'),
|
||||
sidebar.locator('#auth-btn').click(),
|
||||
]);
|
||||
|
||||
// Wait for the Casdoor login page to load
|
||||
await authPage.waitForLoadState('networkidle');
|
||||
const authUrl = authPage.url();
|
||||
expect(authUrl).toContain('hanzo.id');
|
||||
|
||||
// Fill in credentials on the Casdoor login page
|
||||
// Casdoor uses a form with username + password inputs
|
||||
const usernameInput = authPage.locator('input[name="username"], input#input');
|
||||
const passwordInput = authPage.locator('input[name="password"], input[type="password"]');
|
||||
|
||||
if (await usernameInput.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await usernameInput.fill(TEST_USER);
|
||||
await passwordInput.fill('IloveHanzo2026!!!');
|
||||
|
||||
// Submit the form
|
||||
const submitBtn = authPage.locator('button[type="submit"], .login-button, button:has-text("Sign In"), button:has-text("Sign in")');
|
||||
await submitBtn.click();
|
||||
|
||||
// Wait for redirect — the background script should catch the callback URL
|
||||
// and close this tab automatically
|
||||
await authPage.waitForEvent('close', { timeout: 30_000 }).catch(() => {
|
||||
// Tab might not close if redirect is to a real page
|
||||
});
|
||||
} else {
|
||||
// If no form visible, Casdoor might have a different UI — skip
|
||||
test.skip(true, 'Casdoor login form not found');
|
||||
}
|
||||
|
||||
// Back on sidebar — verify auth state
|
||||
await sidebar.waitForTimeout(2000);
|
||||
|
||||
// Check storage for the access token
|
||||
const tokenStored = await sidebar.evaluate(() => {
|
||||
return new Promise(resolve => {
|
||||
chrome.storage.local.get(['hanzo_iam_access_token', 'hanzo_iam_user'], (result) => {
|
||||
resolve({
|
||||
hasToken: !!result.hanzo_iam_access_token,
|
||||
user: result.hanzo_iam_user,
|
||||
});
|
||||
});
|
||||
});
|
||||
}) as any;
|
||||
|
||||
expect(tokenStored.hasToken).toBe(true);
|
||||
expect(tokenStored.user).toBeTruthy();
|
||||
// /api/get-account should have returned name and email
|
||||
if (tokenStored.user) {
|
||||
expect(tokenStored.user.name).toBeTruthy();
|
||||
expect(tokenStored.user.email).toBe(TEST_EMAIL);
|
||||
}
|
||||
});
|
||||
|
||||
test('chat with Zen model after login', async ({ context, extensionId }) => {
|
||||
const sidebar = await context.newPage();
|
||||
await sidebar.goto(`chrome-extension://${extensionId}/sidebar.html`);
|
||||
|
||||
// Inject a mock token to skip the login flow
|
||||
await sidebar.evaluate(() => {
|
||||
return new Promise<void>(resolve => {
|
||||
// We need a real token for this test
|
||||
chrome.storage.local.set({
|
||||
hanzo_iam_access_token: 'test-will-be-replaced',
|
||||
hanzo_iam_user: { name: 'Test', email: 'z@hanzo.ai' },
|
||||
}, resolve);
|
||||
});
|
||||
});
|
||||
|
||||
// Get a real token via direct API call
|
||||
const tokenResult = await sidebar.evaluate(async () => {
|
||||
try {
|
||||
const resp = await fetch('https://iam.hanzo.ai/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
application: 'app-hanzo',
|
||||
organization: 'hanzo',
|
||||
username: 'z',
|
||||
password: 'IloveHanzo2026!!!',
|
||||
type: 'token',
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.status === 'ok' && data.data) {
|
||||
// Store the real token
|
||||
await new Promise<void>(resolve => {
|
||||
chrome.storage.local.set({ hanzo_iam_access_token: data.data }, resolve);
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: data.msg };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
expect(tokenResult.success).toBe(true);
|
||||
|
||||
// Reload to pick up the token
|
||||
await sidebar.reload();
|
||||
await sidebar.waitForTimeout(1000);
|
||||
|
||||
// Switch to chat tab
|
||||
await sidebar.locator('[data-tab="chat"]').click();
|
||||
|
||||
// Login prompt should be hidden, composer should be visible
|
||||
const composer = sidebar.locator('#chat-composer');
|
||||
await expect(composer).not.toHaveClass(/hidden/, { timeout: 5000 });
|
||||
|
||||
// Select a Zen model
|
||||
const modelSelect = sidebar.locator('#model-select');
|
||||
const options = await modelSelect.locator('option').allTextContents();
|
||||
const zenOption = options.find(o => o.toLowerCase().includes('zen'));
|
||||
if (zenOption) {
|
||||
await modelSelect.selectOption({ label: zenOption });
|
||||
}
|
||||
|
||||
// Type a message and send
|
||||
const chatInput = sidebar.locator('#chat-input');
|
||||
await chatInput.fill('Hello, say "pong" and nothing else.');
|
||||
await sidebar.locator('#send-btn').click();
|
||||
|
||||
// Wait for a response (the assistant message should appear)
|
||||
const assistantMsg = sidebar.locator('.message.assistant');
|
||||
await expect(assistantMsg.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Verify response contains text
|
||||
const responseText = await assistantMsg.first().textContent();
|
||||
expect(responseText).toBeTruthy();
|
||||
expect(responseText!.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,11 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test.describe('Popup', () => {
|
||||
test('loads and renders login UI', async ({ context, extensionId }) => {
|
||||
test('shows login section when not authenticated', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
// Verify popup renders
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
|
||||
// Login section should be visible (user not authenticated)
|
||||
// Login section visible (user not authenticated)
|
||||
const loginSection = page.locator('#login-section');
|
||||
await expect(loginSection).toBeVisible();
|
||||
|
||||
@@ -19,6 +16,18 @@ test.describe('Popup', () => {
|
||||
await expect(loginBtn).toBeEnabled();
|
||||
});
|
||||
|
||||
test('main section visible without auth (tools work without login)', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
// Wait for JS to initialize
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Main section always visible (tools work without auth)
|
||||
const mainSection = page.locator('#main-section');
|
||||
await expect(mainSection).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows feature toggles', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
@@ -41,11 +50,10 @@ test.describe('Popup', () => {
|
||||
await expect(page.locator('#gpu-status')).toBeAttached();
|
||||
});
|
||||
|
||||
test('settings panel opens and closes', async ({ context, extensionId }) => {
|
||||
test('settings button exists in main section', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/popup.html`);
|
||||
|
||||
// Main section may be hidden (auth gate), so check settings button exists
|
||||
const settingsBtn = page.locator('#open-settings');
|
||||
await expect(settingsBtn).toBeAttached();
|
||||
});
|
||||
@@ -69,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,65 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test.describe('Sidebar', () => {
|
||||
test('loads and shows auth gate when not logged in', async ({ context, extensionId }) => {
|
||||
test('loads and shows tab bar immediately (no auth gate)', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/sidebar.html`);
|
||||
|
||||
// Auth section should be visible
|
||||
const authSection = page.locator('#auth-section');
|
||||
await expect(authSection).toBeVisible();
|
||||
|
||||
// Auth button should be present
|
||||
const authBtn = page.locator('#auth-btn');
|
||||
await expect(authBtn).toBeVisible();
|
||||
await expect(authBtn).toContainText('Sign in with Hanzo');
|
||||
// Tab bar should be visible (tools work without login)
|
||||
const tabBar = page.locator('#tab-bar');
|
||||
await expect(tabBar).toBeVisible();
|
||||
});
|
||||
|
||||
test('tab bar is hidden before auth', async ({ context, extensionId }) => {
|
||||
test('defaults to tools tab when not authenticated', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/sidebar.html`);
|
||||
|
||||
const tabBar = page.locator('#tab-bar');
|
||||
await expect(tabBar).toHaveClass(/hidden/);
|
||||
// Wait for JS to initialize
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Tools tab should be active by default (no auth required)
|
||||
const toolsTab = page.locator('#tab-tools');
|
||||
await expect(toolsTab).toBeVisible();
|
||||
});
|
||||
|
||||
test('chat tab shows login prompt when not authenticated', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/sidebar.html`);
|
||||
|
||||
// Switch to chat tab
|
||||
await page.locator('[data-tab="chat"]').click();
|
||||
|
||||
// Login prompt should be visible in chat area
|
||||
const loginPrompt = page.locator('#chat-login-prompt');
|
||||
await expect(loginPrompt).toBeVisible();
|
||||
|
||||
// Should mention Zen AI models
|
||||
await expect(loginPrompt).toContainText('Zen AI models');
|
||||
|
||||
// Sign in button should be present
|
||||
const authBtn = page.locator('#auth-btn');
|
||||
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();
|
||||
});
|
||||
|
||||
test('chat composer is hidden when not authenticated', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/sidebar.html`);
|
||||
|
||||
// Switch to chat tab
|
||||
await page.locator('[data-tab="chat"]').click();
|
||||
|
||||
// Chat composer (input area) should be hidden
|
||||
const chatComposer = page.locator('#chat-composer');
|
||||
await expect(chatComposer).toHaveClass(/hidden/);
|
||||
});
|
||||
|
||||
test('has chat, tools, and settings tabs', async ({ context, extensionId }) => {
|
||||
@@ -33,40 +72,67 @@ test.describe('Sidebar', () => {
|
||||
await expect(page.locator('[data-tab="settings"]')).toBeAttached();
|
||||
});
|
||||
|
||||
test('tab switching works', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/sidebar.html`);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Click chat tab
|
||||
await page.locator('[data-tab="chat"]').click();
|
||||
await expect(page.locator('#tab-chat')).toBeVisible();
|
||||
await expect(page.locator('#tab-tools')).toHaveClass(/hidden/);
|
||||
|
||||
// Click tools tab
|
||||
await page.locator('[data-tab="tools"]').click();
|
||||
await expect(page.locator('#tab-tools')).toBeVisible();
|
||||
await expect(page.locator('#tab-chat')).toHaveClass(/hidden/);
|
||||
|
||||
// Click settings tab
|
||||
await page.locator('[data-tab="settings"]').click();
|
||||
await expect(page.locator('#tab-settings')).toBeVisible();
|
||||
await expect(page.locator('#tab-tools')).toHaveClass(/hidden/);
|
||||
});
|
||||
|
||||
test('chat tab has model selector and input', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/sidebar.html`);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
test('model selector has default options', async ({ context, extensionId }) => {
|
||||
test('model selector includes Zen models', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/sidebar.html`);
|
||||
|
||||
const options = page.locator('#model-select option');
|
||||
const count = await options.count();
|
||||
expect(count).toBeGreaterThanOrEqual(2);
|
||||
expect(count).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Should include Claude and GPT models
|
||||
// Should include Claude, GPT, and Zen models
|
||||
const values = await options.allTextContents();
|
||||
const allText = values.join(' ');
|
||||
expect(allText).toContain('GPT');
|
||||
expect(allText).toContain('Claude');
|
||||
expect(allText).toContain('GPT');
|
||||
expect(allText).toContain('Zen');
|
||||
});
|
||||
|
||||
test('tools tab has ZAP status', async ({ context, extensionId }) => {
|
||||
test('tools tab has ZAP status and MCP info', async ({ context, extensionId }) => {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`chrome-extension://${extensionId}/sidebar.html`);
|
||||
|
||||
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();
|
||||
await expect(page.locator('#hanzo-node-status')).toBeAttached();
|
||||
await expect(page.locator('#hanzo-mcp-status')).toBeAttached();
|
||||
});
|
||||
|
||||
test('settings tab has account and connection config', async ({ context, extensionId }) => {
|
||||
@@ -80,6 +146,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();
|
||||
});
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 218 KiB |
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "@hanzo/browser-extension",
|
||||
"version": "1.5.7",
|
||||
"version": "1.7.14",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Hanzo IAM OAuth2 + PKCE authentication for browser extensions.
|
||||
*
|
||||
* Uses chrome.identity.launchWebAuthFlow() — the proper extension OAuth API.
|
||||
* Uses tab-based auth flow: opens a real browser tab for login,
|
||||
* monitors for redirect via chrome.tabs.onUpdated, then exchanges
|
||||
* the authorization code for tokens.
|
||||
*
|
||||
* Token storage uses hanzo_iam_* prefix (matching @hanzo/iam SDK convention).
|
||||
*/
|
||||
|
||||
@@ -46,15 +49,19 @@ function generateState(): string {
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IAM_BASE = 'https://iam.hanzo.ai';
|
||||
// Each Hanzo extension is a separate OAuth app for per-app tracking
|
||||
// in console.hanzo.ai and unified billing.hanzo.ai:
|
||||
// hanzo-browser — this extension (Chrome/Firefox/Safari)
|
||||
// hanzo-vscode — VS Code extension
|
||||
// hanzo-jetbrains — JetBrains plugin
|
||||
const CLIENT_ID = 'hanzo-browser';
|
||||
// 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';
|
||||
|
||||
// Redirect URI: use a web callback that Casdoor has registered.
|
||||
// The tab-based auth flow catches the redirect URL before the page loads,
|
||||
// extracts the code, and closes the tab — works across Chrome, Firefox, Safari.
|
||||
function getRedirectUri(): string {
|
||||
return 'https://hanzo.ai/callback';
|
||||
}
|
||||
|
||||
// Storage keys (matching @hanzo/iam SDK convention)
|
||||
const STORAGE_KEYS = {
|
||||
accessToken: 'hanzo_iam_access_token',
|
||||
@@ -116,19 +123,21 @@ async function getStoredTokens(): Promise<{
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuth2 + PKCE Flow
|
||||
// OAuth2 + PKCE Flow (tab-based)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Initiate OAuth2 login via chrome.identity.launchWebAuthFlow.
|
||||
* Returns user info on success.
|
||||
* Initiate OAuth2 login by opening a browser tab to Casdoor.
|
||||
* Monitors the tab URL for the redirect back to callback.html,
|
||||
* then extracts the authorization code and exchanges it for tokens.
|
||||
*/
|
||||
export async function login(): Promise<UserInfo> {
|
||||
const { codeVerifier, codeChallenge } = await generatePkceChallenge();
|
||||
const state = generateState();
|
||||
const redirectUri = chrome.identity.getRedirectURL();
|
||||
const redirectUri = getRedirectUri();
|
||||
|
||||
const authorizeUrl = new URL(`${IAM_BASE}/login/oauth/authorize`);
|
||||
// Authorization Code + PKCE (OAuth 2.1 standard for public clients)
|
||||
const authorizeUrl = new URL(`${IAM_LOGIN}/oauth/authorize`);
|
||||
authorizeUrl.searchParams.set('client_id', CLIENT_ID);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
@@ -137,52 +146,130 @@ export async function login(): Promise<UserInfo> {
|
||||
authorizeUrl.searchParams.set('scope', SCOPES);
|
||||
authorizeUrl.searchParams.set('state', state);
|
||||
|
||||
// Launch browser-managed auth popup
|
||||
const callbackUrl = await chrome.identity.launchWebAuthFlow({
|
||||
url: authorizeUrl.toString(),
|
||||
interactive: true,
|
||||
});
|
||||
|
||||
if (!callbackUrl) throw new Error('Authentication cancelled');
|
||||
// Open a real browser tab for login (works with all OAuth providers)
|
||||
const callbackUrl = await openAuthTab(authorizeUrl.toString(), redirectUri);
|
||||
|
||||
const url = new URL(callbackUrl);
|
||||
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}/oauth/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}/api/login/oauth/access_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.
|
||||
*/
|
||||
function openAuthTab(authorizeUrl: string, redirectUriPrefix: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let authTabId: number | undefined;
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Login timed out'));
|
||||
}, 300_000); // 5 minute timeout
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timeout);
|
||||
chrome.tabs.onUpdated.removeListener(onTabUpdated);
|
||||
chrome.tabs.onRemoved.removeListener(onTabRemoved);
|
||||
}
|
||||
|
||||
function onTabUpdated(tabId: number, changeInfo: chrome.tabs.TabChangeInfo) {
|
||||
if (tabId !== authTabId || !changeInfo.url) return;
|
||||
|
||||
// Check if the tab has navigated to our callback URL
|
||||
if (changeInfo.url.startsWith(redirectUriPrefix)) {
|
||||
const callbackUrl = changeInfo.url;
|
||||
cleanup();
|
||||
// Close the auth tab
|
||||
chrome.tabs.remove(tabId).catch(() => {});
|
||||
resolve(callbackUrl);
|
||||
}
|
||||
}
|
||||
|
||||
function onTabRemoved(tabId: number) {
|
||||
if (tabId !== authTabId) return;
|
||||
cleanup();
|
||||
reject(new Error('Login cancelled'));
|
||||
}
|
||||
|
||||
// Listen for tab URL changes and tab closure
|
||||
chrome.tabs.onUpdated.addListener(onTabUpdated);
|
||||
chrome.tabs.onRemoved.addListener(onTabRemoved);
|
||||
|
||||
// Open the auth tab
|
||||
chrome.tabs.create({ url: authorizeUrl }, (tab) => {
|
||||
if (chrome.runtime.lastError || !tab?.id) {
|
||||
cleanup();
|
||||
reject(new Error(chrome.runtime.lastError?.message || 'Failed to open login tab'));
|
||||
return;
|
||||
}
|
||||
authTabId = tab.id;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
@@ -222,10 +309,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}/api/login/oauth/access_token`, {
|
||||
const response = await fetch(`${IAM_API}/oauth/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,
|
||||
@@ -241,11 +328,30 @@ async function refreshAccessToken(refreshToken: string): Promise<string> {
|
||||
|
||||
/**
|
||||
* Fetch user info from IAM.
|
||||
* /api/userinfo only returns sub/iss/aud; /api/get-account has full profile.
|
||||
*/
|
||||
async function fetchUserInfo(accessToken: string): Promise<UserInfo> {
|
||||
const response = await fetch(`${IAM_BASE}/api/userinfo`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
// Try /api/get-account first (returns name, email, avatar, etc.)
|
||||
try {
|
||||
const acctResp = await fetch(`${IAM_API}/api/get-account`, { headers });
|
||||
if (acctResp.ok) {
|
||||
const acctJson = await acctResp.json();
|
||||
const acct = acctJson.data || acctJson;
|
||||
if (acct.name || acct.email) {
|
||||
return {
|
||||
sub: acct.id || acct.sub,
|
||||
name: acct.displayName || acct.name,
|
||||
email: acct.email,
|
||||
picture: acct.avatar || undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Fallback to standard /api/userinfo
|
||||
const response = await fetch(`${IAM_API}/api/userinfo`, { headers });
|
||||
if (!response.ok) throw new Error('Failed to fetch user info');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -26,6 +26,28 @@ interface CDPResponse {
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
interface ControlSession {
|
||||
active: boolean;
|
||||
tabId: number | null;
|
||||
task: string | null;
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
interface RagSnippet {
|
||||
content: string;
|
||||
title?: string;
|
||||
source?: string;
|
||||
score?: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const controlSession: ControlSession = {
|
||||
active: false,
|
||||
tabId: null,
|
||||
task: null,
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
class HanzoFirefoxExtension {
|
||||
private wsConnection: WebSocket | null = null;
|
||||
private cdpPort: number = 9223;
|
||||
@@ -170,6 +192,7 @@ class HanzoFirefoxExtension {
|
||||
|
||||
case 'Page.navigate': {
|
||||
if (activeTab?.id) {
|
||||
this.notifyOverlay(activeTab.id, 'ai.control.status', { text: `Navigating to ${params.url}` });
|
||||
await browser.tabs.update(activeTab.id, { url: params.url as string });
|
||||
return { frameId: 'main' };
|
||||
}
|
||||
@@ -230,6 +253,8 @@ class HanzoFirefoxExtension {
|
||||
case 'hanzo.click': {
|
||||
if (activeTab?.id && params.selector) {
|
||||
const selector = this.escapeSelector(params.selector as string);
|
||||
this.notifyOverlay(activeTab.id, 'ai.control.highlight', { selector: params.selector as string });
|
||||
this.notifyOverlay(activeTab.id, 'ai.control.status', { text: `Clicking ${params.selector}` });
|
||||
await this.executeScriptWithTimeout(activeTab.id,
|
||||
`document.querySelector('${selector}')?.click()`
|
||||
);
|
||||
@@ -242,6 +267,8 @@ class HanzoFirefoxExtension {
|
||||
if (activeTab?.id && params.selector) {
|
||||
const selector = this.escapeSelector(params.selector as string);
|
||||
const value = this.escapeValue((params.value as string) || '');
|
||||
this.notifyOverlay(activeTab.id, 'ai.control.highlight', { selector: params.selector as string });
|
||||
this.notifyOverlay(activeTab.id, 'ai.control.status', { text: `Filling ${params.selector}` });
|
||||
await this.executeScriptWithTimeout(activeTab.id, `
|
||||
var el = document.querySelector('${selector}');
|
||||
if (el) {
|
||||
@@ -256,6 +283,20 @@ class HanzoFirefoxExtension {
|
||||
throw new Error('No active tab or selector');
|
||||
}
|
||||
|
||||
case 'hanzo.control.start': {
|
||||
const targetTabId = await this.resolveControlTabId(params.tabId as number | undefined);
|
||||
if (targetTabId) {
|
||||
await this.startControlSession(targetTabId, params.task as string | undefined);
|
||||
return { success: true };
|
||||
}
|
||||
throw new Error('No active tab');
|
||||
}
|
||||
|
||||
case 'hanzo.control.stop': {
|
||||
await this.stopControlSession();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
case 'hanzo.type': {
|
||||
if (activeTab?.id && params.text) {
|
||||
const text = this.escapeValue(params.text as string);
|
||||
@@ -299,6 +340,42 @@ class HanzoFirefoxExtension {
|
||||
}
|
||||
}
|
||||
|
||||
private notifyOverlay(tabId: number, action: string, data: Record<string, unknown>): void {
|
||||
browser.tabs.sendMessage(tabId, { action, ...data }).catch(() => {});
|
||||
}
|
||||
|
||||
async getActiveTabId(): Promise<number | null> {
|
||||
const [tab] = await browser.tabs.query({ active: true, currentWindow: true });
|
||||
return tab?.id ?? null;
|
||||
}
|
||||
|
||||
async resolveControlTabId(tabId?: number): Promise<number | null> {
|
||||
if (typeof tabId === 'number') return tabId;
|
||||
if (controlSession.active && controlSession.tabId !== null) return controlSession.tabId;
|
||||
return this.getActiveTabId();
|
||||
}
|
||||
|
||||
async startControlSession(tabId: number, task?: string): Promise<void> {
|
||||
if (controlSession.active && controlSession.tabId !== null && controlSession.tabId !== tabId) {
|
||||
this.notifyOverlay(controlSession.tabId, 'ai.control.stop', {});
|
||||
}
|
||||
controlSession.active = true;
|
||||
controlSession.tabId = tabId;
|
||||
controlSession.task = task || 'AI is controlling this page';
|
||||
controlSession.startedAt = Date.now();
|
||||
this.notifyOverlay(tabId, 'ai.control.start', { task: controlSession.task });
|
||||
}
|
||||
|
||||
async stopControlSession(): Promise<void> {
|
||||
if (controlSession.active && controlSession.tabId !== null) {
|
||||
this.notifyOverlay(controlSession.tabId, 'ai.control.stop', {});
|
||||
}
|
||||
controlSession.active = false;
|
||||
controlSession.tabId = null;
|
||||
controlSession.task = null;
|
||||
controlSession.startedAt = null;
|
||||
}
|
||||
|
||||
private escapeSelector(selector: string): string {
|
||||
return selector.replace(/'/g, "\\'");
|
||||
}
|
||||
@@ -316,9 +393,11 @@ class HanzoFirefoxExtension {
|
||||
// Uses browser.identity.launchWebAuthFlow for OAuth
|
||||
// =============================================================================
|
||||
|
||||
const IAM_BASE = 'https://iam.hanzo.ai';
|
||||
// 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 API_BASE = 'https://api.hanzo.ai';
|
||||
const CLIENT_ID = 'hanzo-browser';
|
||||
const CLIENT_ID = 'app-hanzo';
|
||||
const SCOPES = 'openid profile email';
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
@@ -347,13 +426,108 @@ function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
async function getRagConfig(): Promise<{
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
knowledgeBase: string;
|
||||
topK: number;
|
||||
includeTabContext: boolean;
|
||||
}> {
|
||||
const result = await browser.storage.local.get([
|
||||
'hanzo_rag_endpoint',
|
||||
'hanzo_rag_api_key',
|
||||
'hanzo_rag_kb',
|
||||
'hanzo_rag_top_k',
|
||||
'hanzo_rag_include_tab_context',
|
||||
]);
|
||||
|
||||
const topKParsed = Number.parseInt(String(result.hanzo_rag_top_k ?? 5), 10);
|
||||
return {
|
||||
endpoint: String(result.hanzo_rag_endpoint || '').trim(),
|
||||
apiKey: String(result.hanzo_rag_api_key || '').trim(),
|
||||
knowledgeBase: String(result.hanzo_rag_kb || '').trim(),
|
||||
topK: Number.isFinite(topKParsed) ? Math.max(1, Math.min(topKParsed, 20)) : 5,
|
||||
includeTabContext: result.hanzo_rag_include_tab_context !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRagSnippets(raw: any): RagSnippet[] {
|
||||
if (!raw) return [];
|
||||
|
||||
const candidates: any[] = [];
|
||||
if (Array.isArray(raw)) candidates.push(...raw);
|
||||
if (Array.isArray(raw?.snippets)) candidates.push(...raw.snippets);
|
||||
if (Array.isArray(raw?.documents)) candidates.push(...raw.documents);
|
||||
if (Array.isArray(raw?.items)) candidates.push(...raw.items);
|
||||
if (Array.isArray(raw?.results)) candidates.push(...raw.results);
|
||||
|
||||
if (!candidates.length && typeof raw?.content === 'string') {
|
||||
candidates.push({ content: raw.content, source: raw.source || 'rag-endpoint' });
|
||||
}
|
||||
|
||||
return candidates
|
||||
.map((item) => {
|
||||
const content = String(item?.content ?? item?.text ?? item?.snippet ?? item?.body ?? '').trim();
|
||||
if (!content) return null;
|
||||
return {
|
||||
content,
|
||||
title: item?.title ? String(item.title) : undefined,
|
||||
source: item?.source ? String(item.source) : undefined,
|
||||
score: typeof item?.score === 'number' ? item.score : undefined,
|
||||
url: item?.url ? String(item.url) : undefined,
|
||||
} as RagSnippet;
|
||||
})
|
||||
.filter((item): item is RagSnippet => !!item)
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
async function queryRagEndpoint(params: {
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
query: string;
|
||||
topK: number;
|
||||
knowledgeBase: string;
|
||||
includeTabContext: boolean;
|
||||
pageContext?: { url?: string; title?: string };
|
||||
}): Promise<RagSnippet[]> {
|
||||
if (!params.endpoint) return [];
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (params.apiKey) headers.Authorization = `Bearer ${params.apiKey}`;
|
||||
|
||||
const response = await fetch(params.endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
query: params.query,
|
||||
top_k: params.topK,
|
||||
knowledge_base: params.knowledgeBase || undefined,
|
||||
page_context: params.includeTabContext ? params.pageContext : undefined,
|
||||
source: 'hanzo-browser-extension-firefox',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`RAG endpoint error ${response.status}: ${text || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const raw = await response.json();
|
||||
return normalizeRagSnippets(raw).map((snippet) => ({
|
||||
...snippet,
|
||||
source: snippet.source || 'rag-endpoint',
|
||||
}));
|
||||
}
|
||||
|
||||
async function firefoxLogin(): Promise<any> {
|
||||
const codeVerifier = generateRandomString(64);
|
||||
const codeChallenge = base64UrlEncode(await sha256(codeVerifier));
|
||||
const state = generateRandomString(32);
|
||||
const redirectUri = browser.identity.getRedirectURL();
|
||||
const redirectUri = 'https://hanzo.ai/callback';
|
||||
|
||||
const authorizeUrl = new URL(`${IAM_BASE}/login/oauth/authorize`);
|
||||
const authorizeUrl = new URL(`${IAM_LOGIN}/oauth/authorize`);
|
||||
authorizeUrl.searchParams.set('client_id', CLIENT_ID);
|
||||
authorizeUrl.searchParams.set('response_type', 'code');
|
||||
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
|
||||
@@ -362,37 +536,98 @@ async function firefoxLogin(): Promise<any> {
|
||||
authorizeUrl.searchParams.set('scope', SCOPES);
|
||||
authorizeUrl.searchParams.set('state', state);
|
||||
|
||||
const callbackUrl = await browser.identity.launchWebAuthFlow({
|
||||
url: authorizeUrl.toString(),
|
||||
interactive: true,
|
||||
// Tab-based auth: open a real browser tab for login
|
||||
const callbackUrl = await new Promise<string>((resolve, reject) => {
|
||||
let authTabId: number | undefined;
|
||||
const timeout = setTimeout(() => { cleanup(); reject(new Error('Login timed out')); }, 300_000);
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timeout);
|
||||
browser.tabs.onUpdated.removeListener(onUpdated);
|
||||
browser.tabs.onRemoved.removeListener(onRemoved);
|
||||
}
|
||||
function onUpdated(tabId: number, changeInfo: any) {
|
||||
if (tabId !== authTabId || !changeInfo.url) return;
|
||||
if (changeInfo.url.startsWith(redirectUri)) {
|
||||
cleanup();
|
||||
browser.tabs.remove(tabId).catch(() => {});
|
||||
resolve(changeInfo.url);
|
||||
}
|
||||
}
|
||||
function onRemoved(tabId: number) {
|
||||
if (tabId !== authTabId) return;
|
||||
cleanup();
|
||||
reject(new Error('Login cancelled'));
|
||||
}
|
||||
|
||||
browser.tabs.onUpdated.addListener(onUpdated);
|
||||
browser.tabs.onRemoved.addListener(onRemoved);
|
||||
browser.tabs.create({ url: authorizeUrl.toString() }).then((tab: any) => {
|
||||
authTabId = tab.id;
|
||||
});
|
||||
});
|
||||
|
||||
if (!callbackUrl) throw new Error('Authentication cancelled');
|
||||
const url = new URL(callbackUrl);
|
||||
if (url.searchParams.get('state') !== state) throw new Error('State mismatch');
|
||||
|
||||
// Handle both code flow and implicit token flow responses
|
||||
const code = url.searchParams.get('code');
|
||||
if (!code) throw new Error(url.searchParams.get('error_description') || 'No code');
|
||||
const directToken = url.searchParams.get('access_token');
|
||||
|
||||
const tokenResponse = await fetch(`${IAM_BASE}/api/login/oauth/access_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 (directToken) {
|
||||
// Implicit flow — tokens returned directly in redirect URL
|
||||
const data: any = { [STORAGE_KEYS.accessToken]: directToken };
|
||||
const refreshToken = url.searchParams.get('refresh_token');
|
||||
if (refreshToken) data[STORAGE_KEYS.refreshToken] = refreshToken;
|
||||
await browser.storage.local.set(data);
|
||||
} else if (code) {
|
||||
// Authorization code flow — exchange code for tokens via PKCE
|
||||
const tokenResponse = await fetch(`${IAM_API}/oauth/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) throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
|
||||
const tokens = await tokenResponse.json();
|
||||
if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
const data: any = { [STORAGE_KEYS.accessToken]: tokens.access_token };
|
||||
if (tokens.refresh_token) data[STORAGE_KEYS.refreshToken] = tokens.refresh_token;
|
||||
if (tokens.id_token) data[STORAGE_KEYS.idToken] = tokens.id_token;
|
||||
if (tokens.expires_in) data[STORAGE_KEYS.expiresAt] = Date.now() + tokens.expires_in * 1000;
|
||||
await browser.storage.local.set(data);
|
||||
const data: any = { [STORAGE_KEYS.accessToken]: tokens.access_token };
|
||||
if (tokens.refresh_token) data[STORAGE_KEYS.refreshToken] = tokens.refresh_token;
|
||||
if (tokens.id_token) data[STORAGE_KEYS.idToken] = tokens.id_token;
|
||||
if (tokens.expires_in) data[STORAGE_KEYS.expiresAt] = Date.now() + tokens.expires_in * 1000;
|
||||
await browser.storage.local.set(data);
|
||||
} else {
|
||||
const error = url.searchParams.get('error_description') || url.searchParams.get('error') || 'No authorization code or token';
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
const userResp = await fetch(`${IAM_BASE}/api/userinfo`, { headers: { Authorization: `Bearer ${tokens.access_token}` } });
|
||||
const user = userResp.ok ? await userResp.json() : {};
|
||||
// Fetch user info
|
||||
const stored = await browser.storage.local.get([STORAGE_KEYS.accessToken]);
|
||||
const accessToken = stored[STORAGE_KEYS.accessToken];
|
||||
if (!accessToken) throw new Error('Login succeeded but no token was stored');
|
||||
|
||||
// /api/userinfo only returns sub/iss/aud; /api/get-account has full profile
|
||||
let user: any = {};
|
||||
try {
|
||||
const acctResp = await fetch(`${IAM_API}/api/get-account`, { headers: { Authorization: `Bearer ${accessToken}` } });
|
||||
if (acctResp.ok) {
|
||||
const acctJson = await acctResp.json();
|
||||
const acct = acctJson.data || acctJson;
|
||||
if (acct.name || acct.email) {
|
||||
user = { sub: acct.id || acct.sub, name: acct.displayName || acct.name, email: acct.email, picture: acct.avatar || undefined };
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
if (!user.name && !user.email) {
|
||||
const userResp = await fetch(`${IAM_API}/api/userinfo`, { headers: { Authorization: `Bearer ${accessToken}` } });
|
||||
if (userResp.ok) user = await userResp.json();
|
||||
}
|
||||
await browser.storage.local.set({ [STORAGE_KEYS.user]: user });
|
||||
return user;
|
||||
}
|
||||
@@ -409,6 +644,15 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
|
||||
}
|
||||
break;
|
||||
|
||||
case 'auth.signup':
|
||||
try {
|
||||
await browser.tabs.create({ url: `${IAM_LOGIN}/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 });
|
||||
@@ -433,14 +677,119 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
|
||||
case 'chat.listModels': {
|
||||
const t = await browser.storage.local.get([STORAGE_KEYS.accessToken]);
|
||||
if (!t[STORAGE_KEYS.accessToken]) { sendResponse({ success: false, error: 'Not authenticated' }); break; }
|
||||
const resp = await fetch(`${API_BASE}/api/models`, { headers: { Authorization: `Bearer ${t[STORAGE_KEYS.accessToken]}` } });
|
||||
const resp = await fetch(`${API_BASE}/v1/models`, { headers: { Authorization: `Bearer ${t[STORAGE_KEYS.accessToken]}` } });
|
||||
const data = await resp.json();
|
||||
sendResponse({ success: true, models: data.data || data.models || data || [] });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'checkWebGPU': {
|
||||
try {
|
||||
if (!navigator.gpu) {
|
||||
sendResponse({ available: false });
|
||||
break;
|
||||
}
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
sendResponse({
|
||||
available: !!adapter,
|
||||
adapter: (adapter as any)?.name || 'Unknown GPU',
|
||||
models: [],
|
||||
});
|
||||
} catch {
|
||||
sendResponse({ available: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'listAgents':
|
||||
sendResponse({ success: true, agents: [] });
|
||||
break;
|
||||
|
||||
case 'stopAgent':
|
||||
sendResponse({ success: false, error: 'Agent workers are not available in Firefox background mode yet' });
|
||||
break;
|
||||
|
||||
case 'launchAIWorker':
|
||||
sendResponse({ success: false, error: 'launchAIWorker is not implemented in Firefox background mode yet' });
|
||||
break;
|
||||
|
||||
case 'rag.query': {
|
||||
try {
|
||||
const config = await getRagConfig();
|
||||
const topKInput = Number.parseInt(String(request.topK ?? config.topK), 10);
|
||||
const params = {
|
||||
endpoint: String(request.endpoint ?? config.endpoint ?? '').trim(),
|
||||
apiKey: String(request.apiKey ?? config.apiKey ?? '').trim(),
|
||||
query: String(request.query || '').trim(),
|
||||
topK: Number.isFinite(topKInput) ? Math.max(1, Math.min(topKInput, 20)) : 5,
|
||||
knowledgeBase: String(request.knowledgeBase ?? config.knowledgeBase ?? '').trim(),
|
||||
includeTabContext: request.includeTabContext !== undefined
|
||||
? !!request.includeTabContext
|
||||
: config.includeTabContext,
|
||||
pageContext: request.pageContext && typeof request.pageContext === 'object'
|
||||
? {
|
||||
url: request.pageContext.url ? String(request.pageContext.url) : undefined,
|
||||
title: request.pageContext.title ? String(request.pageContext.title) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (!params.query) {
|
||||
sendResponse({ success: false, error: 'Query is required' });
|
||||
break;
|
||||
}
|
||||
|
||||
if (!params.endpoint) {
|
||||
sendResponse({ success: true, source: 'none', snippets: [] });
|
||||
break;
|
||||
}
|
||||
|
||||
const snippets = await queryRagEndpoint(params);
|
||||
sendResponse({ success: true, source: snippets.length ? 'rag-endpoint' : 'none', snippets });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// --- AI Control Overlay (forwarded to content script) ---
|
||||
case 'ai.control.start':
|
||||
case 'ai.control.cursor':
|
||||
case 'ai.control.highlight':
|
||||
case 'ai.control.status': {
|
||||
const targetTabId = request.tabId || controlSession.tabId || await hanzoExtension.getActiveTabId();
|
||||
if (!targetTabId) {
|
||||
sendResponse({ success: false, error: 'No target tab found' });
|
||||
break;
|
||||
}
|
||||
|
||||
if (request.action !== 'ai.control.start' && controlSession.active && controlSession.tabId && targetTabId !== controlSession.tabId) {
|
||||
sendResponse({ success: false, error: `Control locked to tab ${controlSession.tabId}` });
|
||||
break;
|
||||
}
|
||||
|
||||
if (request.action === 'ai.control.start') {
|
||||
await hanzoExtension.startControlSession(targetTabId, request.task);
|
||||
} else {
|
||||
browser.tabs.sendMessage(targetTabId, request).catch(() => {});
|
||||
}
|
||||
sendResponse({ success: true, tabId: targetTabId });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ai.control.stop':
|
||||
await hanzoExtension.stopControlSession();
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
|
||||
case 'ai.control.cancel':
|
||||
// User clicked Stop — end local control session
|
||||
await hanzoExtension.stopControlSession();
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
|
||||
default:
|
||||
// Not an auth/chat message — ignore
|
||||
sendResponse({ success: false, error: `Unknown action: ${request.action}` });
|
||||
break;
|
||||
}
|
||||
})();
|
||||
@@ -449,4 +798,12 @@ browser.runtime.onMessage.addListener((request: any, sender: any, sendResponse:
|
||||
|
||||
// Initialize extension (no export - Firefox doesn't support ES modules in background)
|
||||
const hanzoExtension = new HanzoFirefoxExtension();
|
||||
|
||||
// End active session when the controlled tab closes.
|
||||
browser.tabs.onRemoved.addListener((tabId) => {
|
||||
if (controlSession.active && controlSession.tabId === tabId) {
|
||||
void hanzoExtension.stopControlSession();
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Hanzo] Firefox background script loaded');
|
||||
|
||||
@@ -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();
|
||||
@@ -23,18 +36,67 @@ interface ZapState {
|
||||
extensionId: string;
|
||||
}
|
||||
|
||||
interface ControlSession {
|
||||
active: boolean;
|
||||
tabId: number | null;
|
||||
task: string | null;
|
||||
startedAt: number | null;
|
||||
}
|
||||
|
||||
interface RagSnippet {
|
||||
content: string;
|
||||
title?: string;
|
||||
source?: string;
|
||||
score?: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface RagQueryParams {
|
||||
query: string;
|
||||
topK: number;
|
||||
knowledgeBase: string;
|
||||
includeTabContext: boolean;
|
||||
useZapMemory: boolean;
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
mcpId?: string;
|
||||
pageContext?: {
|
||||
url?: string;
|
||||
title?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const zapState: ZapState = {
|
||||
connected: false,
|
||||
mcps: [],
|
||||
extensionId: `hanzo-ext-${Date.now().toString(36)}`,
|
||||
};
|
||||
|
||||
const controlSession: ControlSession = {
|
||||
active: false,
|
||||
tabId: null,
|
||||
task: null,
|
||||
startedAt: null,
|
||||
};
|
||||
|
||||
// Default ports (overridable via chrome.storage.local settings)
|
||||
const DEFAULT_ZAP_PORTS = [9999, 9998, 9997, 9996, 9995];
|
||||
const DEFAULT_MCP_PORT = 3001;
|
||||
const DEFAULT_CDP_PORT = 9223;
|
||||
const ZAP_RECONNECT_DELAY = 3000;
|
||||
const ZAP_DISCOVERY_TIMEOUT = 2000;
|
||||
const DEFAULT_RAG_TOP_K = 5;
|
||||
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 }> {
|
||||
@@ -49,6 +111,77 @@ async function getPortConfig(): Promise<{ zapPorts: number[]; mcpPort: number; c
|
||||
});
|
||||
}
|
||||
|
||||
async function getRagConfig(): Promise<{
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
knowledgeBase: string;
|
||||
topK: number;
|
||||
includeTabContext: boolean;
|
||||
useZapMemory: boolean;
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
chrome.storage.local.get([
|
||||
'hanzo_rag_endpoint',
|
||||
'hanzo_rag_api_key',
|
||||
'hanzo_rag_kb',
|
||||
'hanzo_rag_top_k',
|
||||
'hanzo_rag_include_tab_context',
|
||||
'hanzo_rag_use_zap',
|
||||
], (result) => {
|
||||
const parsedTopK = Number.parseInt(String(result.hanzo_rag_top_k ?? DEFAULT_RAG_TOP_K), 10);
|
||||
resolve({
|
||||
endpoint: String(result.hanzo_rag_endpoint || '').trim(),
|
||||
apiKey: String(result.hanzo_rag_api_key || '').trim(),
|
||||
knowledgeBase: String(result.hanzo_rag_kb || '').trim(),
|
||||
topK: Number.isFinite(parsedTopK) ? Math.max(1, Math.min(parsedTopK, 20)) : DEFAULT_RAG_TOP_K,
|
||||
includeTabContext: result.hanzo_rag_include_tab_context !== false,
|
||||
useZapMemory: result.hanzo_rag_use_zap !== false,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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>();
|
||||
|
||||
@@ -178,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;
|
||||
}
|
||||
@@ -209,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);
|
||||
};
|
||||
|
||||
@@ -265,22 +398,142 @@ async function zapCallTool(name: string, args: Record<string, unknown> = {}, tar
|
||||
throw new Error(`Tool not found on any ZAP-connected MCP: ${name}`);
|
||||
}
|
||||
|
||||
function hasZapTool(name: string): boolean {
|
||||
return zapState.mcps.some((mcp) => mcp.tools.includes(name));
|
||||
}
|
||||
|
||||
function normalizeRagSnippets(raw: any): RagSnippet[] {
|
||||
if (!raw) return [];
|
||||
|
||||
const candidates: any[] = [];
|
||||
if (Array.isArray(raw)) candidates.push(...raw);
|
||||
if (Array.isArray(raw?.snippets)) candidates.push(...raw.snippets);
|
||||
if (Array.isArray(raw?.documents)) candidates.push(...raw.documents);
|
||||
if (Array.isArray(raw?.items)) candidates.push(...raw.items);
|
||||
if (Array.isArray(raw?.results)) candidates.push(...raw.results);
|
||||
if (Array.isArray(raw?.memories)) candidates.push(...raw.memories);
|
||||
if (Array.isArray(raw?.matches)) candidates.push(...raw.matches);
|
||||
|
||||
if (!candidates.length && typeof raw?.content === 'string') {
|
||||
candidates.push({ content: raw.content, source: raw.source || 'memory' });
|
||||
}
|
||||
|
||||
const snippets = candidates
|
||||
.map((item) => {
|
||||
const content = String(
|
||||
item?.content ??
|
||||
item?.text ??
|
||||
item?.snippet ??
|
||||
item?.body ??
|
||||
item?.value ??
|
||||
'',
|
||||
).trim();
|
||||
if (!content) return null;
|
||||
|
||||
return {
|
||||
content,
|
||||
title: item?.title ? String(item.title) : undefined,
|
||||
source: item?.source ? String(item.source) : undefined,
|
||||
score: typeof item?.score === 'number' ? item.score : undefined,
|
||||
url: item?.url ? String(item.url) : undefined,
|
||||
} as RagSnippet;
|
||||
})
|
||||
.filter((item): item is RagSnippet => !!item);
|
||||
|
||||
return snippets.slice(0, 20);
|
||||
}
|
||||
|
||||
async function queryRagFromZap(params: RagQueryParams): Promise<RagSnippet[]> {
|
||||
if (!params.useZapMemory || !zapState.connected || !hasZapTool('memory')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const payloads: Record<string, unknown>[] = [
|
||||
{
|
||||
action: 'query',
|
||||
query: params.query,
|
||||
top_k: params.topK,
|
||||
limit: params.topK,
|
||||
kb: params.knowledgeBase || undefined,
|
||||
page_context: params.pageContext,
|
||||
},
|
||||
{
|
||||
query: params.query,
|
||||
topK: params.topK,
|
||||
limit: params.topK,
|
||||
knowledge_base: params.knowledgeBase || undefined,
|
||||
context: params.pageContext,
|
||||
},
|
||||
];
|
||||
|
||||
for (const payload of payloads) {
|
||||
try {
|
||||
const raw = await zapCallTool('memory', payload, params.mcpId);
|
||||
const snippets = normalizeRagSnippets(raw);
|
||||
if (snippets.length) {
|
||||
return snippets.map((snippet) => ({
|
||||
...snippet,
|
||||
source: snippet.source || 'zap-memory',
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// Try alternative payload shape before giving up.
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function queryRagFromEndpoint(params: RagQueryParams): Promise<RagSnippet[]> {
|
||||
if (!params.endpoint) return [];
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (params.apiKey) {
|
||||
headers.Authorization = `Bearer ${params.apiKey}`;
|
||||
}
|
||||
|
||||
const response = await fetch(params.endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
query: params.query,
|
||||
top_k: params.topK,
|
||||
knowledge_base: params.knowledgeBase || undefined,
|
||||
page_context: params.includeTabContext ? params.pageContext : undefined,
|
||||
source: 'hanzo-browser-extension',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`RAG endpoint error ${response.status}: ${text || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const raw = await response.json();
|
||||
return normalizeRagSnippets(raw).map((snippet) => ({
|
||||
...snippet,
|
||||
source: snippet.source || 'rag-endpoint',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and connect to ZAP servers on startup
|
||||
*/
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -294,17 +547,79 @@ function detectBrowser(): string {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function sendControlMessageToTab(tabId: number, message: Record<string, unknown>): void {
|
||||
chrome.tabs.sendMessage(tabId, message, () => {
|
||||
if (chrome.runtime.lastError) {
|
||||
// Content script may not be injected on privileged pages.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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) => {
|
||||
resolve(tabs[0]?.id ?? null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveControlTabId(tabId?: number): Promise<number | null> {
|
||||
if (typeof tabId === 'number') return tabId;
|
||||
if (controlSession.active && controlSession.tabId !== null) return controlSession.tabId;
|
||||
return getActiveTabId();
|
||||
}
|
||||
|
||||
async function startControlSession(tabId: number, task?: string): Promise<void> {
|
||||
if (controlSession.active && controlSession.tabId !== null && controlSession.tabId !== tabId) {
|
||||
sendControlMessageToTab(controlSession.tabId, { action: 'ai.control.stop' });
|
||||
}
|
||||
|
||||
controlSession.active = true;
|
||||
controlSession.tabId = tabId;
|
||||
controlSession.task = task || 'AI is controlling this page';
|
||||
controlSession.startedAt = Date.now();
|
||||
|
||||
sendControlMessageToTab(tabId, {
|
||||
action: 'ai.control.start',
|
||||
task: controlSession.task,
|
||||
});
|
||||
}
|
||||
|
||||
async function stopControlSession(): Promise<void> {
|
||||
if (controlSession.active && controlSession.tabId !== null) {
|
||||
sendControlMessageToTab(controlSession.tabId, { action: 'ai.control.stop' });
|
||||
}
|
||||
controlSession.active = false;
|
||||
controlSession.tabId = null;
|
||||
controlSession.task = null;
|
||||
controlSession.startedAt = null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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',
|
||||
@@ -369,10 +684,43 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
}
|
||||
break;
|
||||
|
||||
case 'checkWebGPU': {
|
||||
try {
|
||||
if (!navigator.gpu) {
|
||||
sendResponse({ available: false });
|
||||
break;
|
||||
}
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
sendResponse({
|
||||
available: !!adapter,
|
||||
adapter: (adapter as any)?.name || 'Unknown GPU',
|
||||
models: webgpuAI.getStatus().models,
|
||||
});
|
||||
} catch {
|
||||
sendResponse({ available: false });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'listAgents':
|
||||
sendResponse({ success: true, agents: browserControl.getAgents() });
|
||||
break;
|
||||
|
||||
case 'stopAgent':
|
||||
sendResponse({ success: browserControl.stopAgent(request.agentId) });
|
||||
break;
|
||||
|
||||
case 'launchAIWorker':
|
||||
if (sender.tab?.id) {
|
||||
await browserControl.launchAIWorker(sender.tab.id, request.model);
|
||||
sendResponse({ success: true });
|
||||
try {
|
||||
const targetTabId = request.tabId || sender.tab?.id;
|
||||
if (!targetTabId) {
|
||||
sendResponse({ success: false, error: 'No target tab found' });
|
||||
break;
|
||||
}
|
||||
const agentId = await browserControl.launchAIWorker(targetTabId, request.model || 'browser-control');
|
||||
sendResponse({ success: true, agentId });
|
||||
} catch (error: any) {
|
||||
sendResponse({ success: false, error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -473,6 +821,109 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
}
|
||||
break;
|
||||
|
||||
// --- AI Control Overlay ---
|
||||
case 'ai.control.start': {
|
||||
const tabId = await resolveControlTabId(request.tabId);
|
||||
if (tabId === null) {
|
||||
sendResponse({ success: false, error: 'No target tab found' });
|
||||
break;
|
||||
}
|
||||
await startControlSession(tabId, request.task);
|
||||
sendResponse({
|
||||
success: true,
|
||||
session: {
|
||||
active: controlSession.active,
|
||||
tabId: controlSession.tabId,
|
||||
task: controlSession.task,
|
||||
startedAt: controlSession.startedAt,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ai.control.stop': {
|
||||
await stopControlSession();
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'ai.control.cursor':
|
||||
case 'ai.control.highlight':
|
||||
case 'ai.control.status': {
|
||||
const tabId = await resolveControlTabId(request.tabId);
|
||||
if (tabId === null) {
|
||||
sendResponse({ success: false, error: 'No target tab found' });
|
||||
break;
|
||||
}
|
||||
if (controlSession.active && controlSession.tabId !== null && tabId !== controlSession.tabId) {
|
||||
sendResponse({
|
||||
success: false,
|
||||
error: `Control locked to tab ${controlSession.tabId}`,
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
case 'ai.control.cancel':
|
||||
// User clicked Stop — notify ZAP/MCP to cancel current task
|
||||
await stopControlSession();
|
||||
if (zapState.connected) {
|
||||
for (const [, conn] of zapConnections) {
|
||||
if (conn.readyState === WebSocket.OPEN) {
|
||||
conn.send(encodeZapMessage(MSG_REQUEST, {
|
||||
id: `cancel-${++requestIdCounter}`,
|
||||
method: 'notifications/controlCancelled',
|
||||
params: {},
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -483,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();
|
||||
@@ -510,6 +970,54 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
}
|
||||
break;
|
||||
|
||||
case 'rag.query':
|
||||
try {
|
||||
const config = await getRagConfig();
|
||||
const topKInput = Number.parseInt(String(request.topK ?? config.topK), 10);
|
||||
const params: RagQueryParams = {
|
||||
query: String(request.query || '').trim(),
|
||||
topK: Number.isFinite(topKInput) ? Math.max(1, Math.min(topKInput, 20)) : DEFAULT_RAG_TOP_K,
|
||||
knowledgeBase: String(request.knowledgeBase ?? config.knowledgeBase ?? '').trim(),
|
||||
includeTabContext: request.includeTabContext !== undefined
|
||||
? !!request.includeTabContext
|
||||
: config.includeTabContext,
|
||||
useZapMemory: request.useZapMemory !== undefined
|
||||
? !!request.useZapMemory
|
||||
: config.useZapMemory,
|
||||
endpoint: String(request.endpoint ?? config.endpoint ?? '').trim(),
|
||||
apiKey: String(request.apiKey ?? config.apiKey ?? '').trim(),
|
||||
mcpId: request.mcpId ? String(request.mcpId) : undefined,
|
||||
pageContext: request.pageContext && typeof request.pageContext === 'object'
|
||||
? {
|
||||
url: request.pageContext.url ? String(request.pageContext.url) : undefined,
|
||||
title: request.pageContext.title ? String(request.pageContext.title) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (!params.query) {
|
||||
sendResponse({ success: false, error: 'Query is required' });
|
||||
break;
|
||||
}
|
||||
|
||||
let snippets = await queryRagFromZap(params);
|
||||
let source = snippets.length ? 'zap-memory' : 'none';
|
||||
|
||||
if (!snippets.length && params.endpoint) {
|
||||
snippets = await queryRagFromEndpoint(params);
|
||||
source = snippets.length ? 'rag-endpoint' : source;
|
||||
}
|
||||
|
||||
sendResponse({
|
||||
success: true,
|
||||
source,
|
||||
snippets: snippets.slice(0, params.topK),
|
||||
});
|
||||
} catch (error: any) {
|
||||
sendResponse({ success: false, error: error.message });
|
||||
}
|
||||
break;
|
||||
|
||||
// --- Cloud Chat ---
|
||||
case 'chat.listModels':
|
||||
try {
|
||||
@@ -518,13 +1026,194 @@ 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;
|
||||
}
|
||||
|
||||
case 'settings.sync': {
|
||||
// Sync all settings to ~/.hanzo/extension/config.json via CDP bridge
|
||||
if (request.settings && typeof request.settings === 'object') {
|
||||
for (const [key, value] of Object.entries(request.settings)) {
|
||||
cdpBridge.sendConfig(key, value);
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -545,6 +1234,7 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
}
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -559,7 +1249,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) => {
|
||||
@@ -597,6 +1287,13 @@ if (chrome.sidePanel) {
|
||||
chrome.sidePanel.setOptions({ path: 'sidebar.html', enabled: true });
|
||||
}
|
||||
|
||||
// End active session when the controlled tab closes.
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
if (controlSession.active && controlSession.tabId === tabId) {
|
||||
void stopControlSession();
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Primary: Discover ZAP servers (high-performance binary protocol)
|
||||
discoverZapServers();
|
||||
|
||||
@@ -607,7 +1304,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 });
|
||||
@@ -56,6 +80,36 @@ async function build() {
|
||||
format: 'esm'
|
||||
});
|
||||
|
||||
// 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,
|
||||
outfile: 'dist/browser-extension/sidebar.js',
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'firefox91', 'safari14'],
|
||||
sourcemap: 'inline',
|
||||
alias: sidebarAliases,
|
||||
});
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [fs.existsSync('src/popup.ts') ? 'src/popup.ts' : 'src/popup.js'],
|
||||
bundle: true,
|
||||
outfile: 'dist/browser-extension/popup.js',
|
||||
platform: 'browser',
|
||||
target: ['chrome90', 'firefox91', 'safari14'],
|
||||
sourcemap: 'inline',
|
||||
});
|
||||
|
||||
// Build browser control module
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/browser-control.ts'],
|
||||
@@ -160,14 +214,18 @@ async function build() {
|
||||
);
|
||||
}
|
||||
|
||||
// Copy popup + sidebar HTML/CSS/JS
|
||||
for (const f of ['popup.html', 'popup.css', 'popup.js', 'sidebar.html', 'sidebar.css', 'sidebar.js', 'ai-worker.js']) {
|
||||
// Copy popup + sidebar HTML/CSS
|
||||
for (const f of ['popup.html', 'popup.css', 'sidebar.html', 'sidebar.css', 'callback.html', 'ai-worker.js']) {
|
||||
const src = path.join('src', f);
|
||||
if (fs.existsSync(src)) {
|
||||
fs.copyFileSync(src, path.join(dir, f));
|
||||
}
|
||||
}
|
||||
|
||||
// Copy compiled popup/sidebar scripts
|
||||
fs.copyFileSync('dist/browser-extension/popup.js', path.join(dir, 'popup.js'));
|
||||
fs.copyFileSync('dist/browser-extension/sidebar.js', path.join(dir, 'sidebar.js'));
|
||||
|
||||
// Copy icons into each browser directory
|
||||
for (const icon of ['icon16.png', 'icon48.png', 'icon128.png']) {
|
||||
const iconPath = path.join('images', icon);
|
||||
@@ -263,4 +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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Hanzo AI — Signing in...</title>
|
||||
<style>
|
||||
body { background: #000; color: #fff; font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||
.msg { text-align: center; opacity: 0.7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="msg">
|
||||
<p>Signing in to Hanzo AI...</p>
|
||||
<p><small>This tab will close automatically.</small></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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) {
|
||||
@@ -328,60 +340,90 @@ export class CDPBridge {
|
||||
userAgent: navigator.userAgent
|
||||
};
|
||||
break;
|
||||
|
||||
|
||||
case 'Target.getTargets':
|
||||
result = await this.getTargets();
|
||||
break;
|
||||
|
||||
|
||||
case 'Page.navigate':
|
||||
// Background workers do not have a DOM/window; use a safe default cursor hint.
|
||||
this.notifyControlOverlay(tabId, 'ai.control.cursor', { x: 24, y: 24 });
|
||||
this.notifyControlOverlay(tabId, 'ai.control.status', { text: `Navigating to ${params.url}` });
|
||||
await this.navigate(tabId, params.url);
|
||||
result = { frameId: 'main' };
|
||||
break;
|
||||
|
||||
|
||||
case 'Page.captureScreenshot':
|
||||
const screenshot = await this.screenshot(tabId, params);
|
||||
result = { data: screenshot };
|
||||
break;
|
||||
|
||||
|
||||
case 'Input.dispatchMouseEvent':
|
||||
if (params?.x !== undefined && params?.y !== undefined) {
|
||||
this.notifyControlOverlay(tabId, 'ai.control.cursor', { x: params.x, y: params.y });
|
||||
}
|
||||
await this.send(tabId, method, params);
|
||||
result = {};
|
||||
break;
|
||||
|
||||
|
||||
case 'Input.dispatchKeyEvent':
|
||||
await this.send(tabId, method, params);
|
||||
result = {};
|
||||
break;
|
||||
|
||||
|
||||
case 'Runtime.evaluate':
|
||||
result = await this.send(tabId, method, params);
|
||||
break;
|
||||
|
||||
|
||||
case 'DOM.getDocument':
|
||||
result = await this.getDocument(tabId);
|
||||
break;
|
||||
|
||||
|
||||
case 'DOM.querySelector':
|
||||
const nodeId = await this.querySelector(tabId, params.selector);
|
||||
result = { nodeId };
|
||||
break;
|
||||
|
||||
|
||||
// High-level commands for hanzo-mcp
|
||||
case 'hanzo.click':
|
||||
this.notifyControlOverlay(tabId, 'ai.control.highlight', { selector: params.selector });
|
||||
this.notifyControlOverlay(tabId, 'ai.control.status', { text: `Clicking ${params.selector}` });
|
||||
const clicked = await this.clickSelector(tabId, params.selector);
|
||||
result = { success: clicked };
|
||||
break;
|
||||
|
||||
|
||||
case 'hanzo.fill':
|
||||
this.notifyControlOverlay(tabId, 'ai.control.highlight', { selector: params.selector });
|
||||
this.notifyControlOverlay(tabId, 'ai.control.status', { text: `Filling ${params.selector}` });
|
||||
const filled = await this.fillSelector(tabId, params.selector, params.value);
|
||||
result = { success: filled };
|
||||
break;
|
||||
|
||||
|
||||
case 'hanzo.screenshot':
|
||||
const screenshotData = await this.screenshot(tabId, params);
|
||||
result = { data: screenshotData };
|
||||
break;
|
||||
|
||||
|
||||
// Control overlay management
|
||||
case 'hanzo.control.start':
|
||||
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);
|
||||
@@ -424,6 +466,49 @@ 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)
|
||||
if (chrome.runtime.lastError) { /* noop */ }
|
||||
});
|
||||
} 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',
|
||||
@@ -431,7 +516,7 @@ export class CDPBridge {
|
||||
method,
|
||||
params
|
||||
});
|
||||
|
||||
|
||||
if (this.wsServer?.readyState === WebSocket.OPEN) {
|
||||
this.wsServer.send(event);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface ChatDelta {
|
||||
* List available models from Hanzo Cloud.
|
||||
*/
|
||||
export async function listModels(token: string): Promise<ChatModel[]> {
|
||||
const response = await fetch(`${API_BASE}/api/models`, {
|
||||
const response = await fetch(`${API_BASE}/v1/models`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ export function chatCompletionStream(
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/chat/completions`, {
|
||||
const response = await fetch(`${API_BASE}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -150,7 +150,7 @@ export async function chatCompletion(
|
||||
token: string,
|
||||
options: ChatCompletionOptions,
|
||||
): Promise<string> {
|
||||
const response = await fetch(`${API_BASE}/api/chat/completions`, {
|
||||
const response = await fetch(`${API_BASE}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -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 />);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 243 B After Width: | Height: | Size: 956 B |
|
Before Width: | Height: | Size: 345 B After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 576 B After Width: | Height: | Size: 1.6 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.14",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
@@ -17,17 +17,23 @@
|
||||
"<all_urls>"
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' ws://localhost:* http://localhost:* https://api.hanzo.ai https://iam.hanzo.ai https://hanzo.id;"
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content-script.js"],
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"content-script.js"
|
||||
],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"scripts": ["background.js"],
|
||||
"scripts": [
|
||||
"background.js"
|
||||
],
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
@@ -48,8 +54,13 @@
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["ai-worker.js", "models/*"],
|
||||
"matches": ["<all_urls>"]
|
||||
"resources": [
|
||||
"ai-worker.js",
|
||||
"models/*"
|
||||
],
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
]
|
||||
}
|
||||
],
|
||||
"browser_specific_settings": {
|
||||
@@ -60,7 +71,11 @@
|
||||
"gecko_android": {}
|
||||
},
|
||||
"data_collection_permissions": {
|
||||
"purposes": ["functionality"],
|
||||
"data_categories": ["technical_data"]
|
||||
"purposes": [
|
||||
"functionality"
|
||||
],
|
||||
"data_categories": [
|
||||
"technical_data"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Hanzo AI",
|
||||
"version": "1.6.1",
|
||||
"version": "1.7.15",
|
||||
"description": "AI-powered dev assistant — click-to-code navigation, source maps, WebGPU AI, and MCP integration for Claude Code.",
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
@@ -19,12 +19,16 @@
|
||||
"<all_urls>"
|
||||
],
|
||||
"content_security_policy": {
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; connect-src 'self' ws://localhost:* http://localhost:* https://api.hanzo.ai https://iam.hanzo.ai https://hanzo.id;"
|
||||
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content-script.js"],
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"js": [
|
||||
"content-script.js"
|
||||
],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
@@ -50,8 +54,13 @@
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["ai-worker.js", "models/*"],
|
||||
"matches": ["<all_urls>"]
|
||||
"resources": [
|
||||
"ai-worker.js",
|
||||
"models/*"
|
||||
],
|
||||
"matches": [
|
||||
"<all_urls>"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -31,11 +31,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// --- Auth via background ---
|
||||
function checkAuth() {
|
||||
// Always show main section and status (tools work without login)
|
||||
mainSection.classList.remove('hidden');
|
||||
refreshStatus();
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'auth.status' }, (response) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (response?.success && response.authenticated) {
|
||||
loginSection.classList.add('hidden');
|
||||
mainSection.classList.remove('hidden');
|
||||
if (response.user) {
|
||||
const avatar = document.getElementById('user-avatar');
|
||||
const name = document.getElementById('user-name');
|
||||
@@ -44,10 +47,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
name.textContent = response.user.name || response.user.displayName || 'Hanzo User';
|
||||
email.textContent = response.user.email || '';
|
||||
}
|
||||
refreshStatus();
|
||||
} else {
|
||||
loginSection.classList.remove('hidden');
|
||||
mainSection.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
// Hanzo AI — Popup Script
|
||||
// Uses background message passing for auth (not direct storage access).
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const loginSection = document.getElementById('login-section');
|
||||
const mainSection = document.getElementById('main-section');
|
||||
const settingsSection = document.getElementById('settings-section');
|
||||
|
||||
// Auth
|
||||
const loginBtn = document.getElementById('login-btn');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
|
||||
// Open panel
|
||||
const openPanel = document.getElementById('open-panel');
|
||||
const openPageOverlay = document.getElementById('open-page-overlay');
|
||||
|
||||
// Status dots
|
||||
const zapStatus = document.getElementById('zap-status');
|
||||
const zapDetail = document.getElementById('zap-detail');
|
||||
const mcpStatus = document.getElementById('mcp-status');
|
||||
const mcpPort = document.getElementById('mcp-port');
|
||||
const cdpStatus = document.getElementById('cdp-status');
|
||||
const cdpDetail = document.getElementById('cdp-detail');
|
||||
const gpuStatus = document.getElementById('gpu-status');
|
||||
const gpuDetail = document.getElementById('gpu-detail');
|
||||
|
||||
// Settings
|
||||
const openSettings = document.getElementById('open-settings');
|
||||
const backBtn = document.getElementById('back-btn');
|
||||
const saveSettings = document.getElementById('save-settings');
|
||||
const testConnection = document.getElementById('test-connection');
|
||||
|
||||
// --- Auth via background ---
|
||||
function checkAuth() {
|
||||
// Always show main section and status (tools work without login)
|
||||
mainSection.classList.remove('hidden');
|
||||
refreshStatus();
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'auth.status' }, (response) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (response?.success && response.authenticated) {
|
||||
loginSection.classList.add('hidden');
|
||||
if (response.user) {
|
||||
const avatar = document.getElementById('user-avatar');
|
||||
const name = document.getElementById('user-name');
|
||||
const email = document.getElementById('user-email');
|
||||
avatar.src = response.user.picture || response.user.avatar || 'icon48.png';
|
||||
name.textContent = response.user.name || response.user.displayName || 'Hanzo User';
|
||||
email.textContent = response.user.email || '';
|
||||
}
|
||||
} else {
|
||||
loginSection.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loginBtn?.addEventListener('click', () => {
|
||||
loginBtn.disabled = true;
|
||||
loginBtn.textContent = 'Signing in...';
|
||||
chrome.runtime.sendMessage({ action: 'auth.login' }, (response) => {
|
||||
loginBtn.disabled = false;
|
||||
loginBtn.innerHTML = '<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4M10 17l5-5-5-5M15 12H3"/></svg> Sign in with Hanzo';
|
||||
if (response?.success) {
|
||||
checkAuth();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
logoutBtn?.addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ action: 'auth.logout' }, () => {
|
||||
mainSection.classList.add('hidden');
|
||||
loginSection.classList.remove('hidden');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Open side panel ---
|
||||
openPanel?.addEventListener('click', () => {
|
||||
if (chrome.sidePanel) {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
if (tabs[0]?.id) {
|
||||
chrome.sidePanel.open({ tabId: tabs[0].id });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Firefox: sidebar_action is automatic
|
||||
window.close();
|
||||
}
|
||||
});
|
||||
|
||||
function isInjectableTab(tab: chrome.tabs.Tab | undefined): boolean {
|
||||
if (!tab || !tab.id) return false;
|
||||
const url = tab.url || '';
|
||||
if (!url) return false;
|
||||
if (url.startsWith('chrome://')) return false;
|
||||
if (url.startsWith('chrome-extension://')) return false;
|
||||
if (url.startsWith('devtools://')) return false;
|
||||
if (url.startsWith('edge://')) return false;
|
||||
if (url.startsWith('about:')) return false;
|
||||
if (url.startsWith('view-source:')) return false;
|
||||
if (url.startsWith('moz-extension://')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
openPageOverlay?.addEventListener('click', () => {
|
||||
openPageOverlay.textContent = 'Opening...';
|
||||
openPageOverlay.setAttribute('disabled', 'true');
|
||||
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const activeTab = tabs[0];
|
||||
let targetTab = isInjectableTab(activeTab) ? activeTab : null;
|
||||
|
||||
if (!targetTab) {
|
||||
chrome.tabs.query({ currentWindow: true }, (allTabs) => {
|
||||
targetTab = allTabs.find((tab) => isInjectableTab(tab)) || null;
|
||||
if (!targetTab?.id) {
|
||||
openPageOverlay.textContent = 'No valid page tab';
|
||||
setTimeout(() => {
|
||||
openPageOverlay.textContent = 'Toggle On-Page Overlay';
|
||||
openPageOverlay.removeAttribute('disabled');
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'page.overlay.toggle', tabId: targetTab.id }, (response) => {
|
||||
if (chrome.runtime.lastError || !response?.success) {
|
||||
openPageOverlay.textContent = 'Unavailable on this page';
|
||||
} else {
|
||||
const opened = !!response?.open;
|
||||
openPageOverlay.textContent = opened ? 'Overlay Opened' : 'Overlay Hidden';
|
||||
}
|
||||
setTimeout(() => {
|
||||
openPageOverlay.textContent = 'Toggle On-Page Overlay';
|
||||
openPageOverlay.removeAttribute('disabled');
|
||||
}, 900);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetTab?.id) {
|
||||
openPageOverlay.textContent = 'No Active Tab';
|
||||
setTimeout(() => {
|
||||
openPageOverlay.textContent = 'Toggle On-Page Overlay';
|
||||
openPageOverlay.removeAttribute('disabled');
|
||||
}, 1200);
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'page.overlay.toggle', tabId: targetTab.id }, (response) => {
|
||||
if (chrome.runtime.lastError || !response?.success) {
|
||||
openPageOverlay.textContent = 'Unavailable on this page';
|
||||
} else {
|
||||
const opened = !!response?.open;
|
||||
openPageOverlay.textContent = opened ? 'Overlay Opened' : 'Overlay Hidden';
|
||||
}
|
||||
setTimeout(() => {
|
||||
openPageOverlay.textContent = 'Toggle On-Page Overlay';
|
||||
openPageOverlay.removeAttribute('disabled');
|
||||
}, 900);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Status ---
|
||||
function refreshStatus() {
|
||||
// ZAP status
|
||||
chrome.runtime.sendMessage({ action: 'zap.status' }, (response) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (response?.success) {
|
||||
const zap = response.zap;
|
||||
if (zap.connected) {
|
||||
zapStatus.classList.add('connected');
|
||||
zapStatus.classList.remove('disconnected');
|
||||
const mcpCount = zap.mcps?.length || 0;
|
||||
const toolCount = zap.mcps?.reduce((sum, m) => sum + (m.tools?.length || 0), 0) || 0;
|
||||
zapDetail.textContent = `${mcpCount} MCP(s), ${toolCount} tools`;
|
||||
} else {
|
||||
zapStatus.classList.remove('connected');
|
||||
zapStatus.classList.add('disconnected');
|
||||
zapDetail.textContent = 'Not connected';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GPU status
|
||||
chrome.runtime.sendMessage({ action: 'runLocalAI', prompt: '' }, (response) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
gpuStatus.classList.add(response?.success ? 'connected' : 'disconnected');
|
||||
gpuStatus.classList.remove(response?.success ? 'disconnected' : 'connected');
|
||||
gpuDetail.textContent = response?.success ? 'Available' : 'Not available';
|
||||
});
|
||||
|
||||
// MCP/CDP — set as active for now (checked via ZAP)
|
||||
mcpStatus.classList.add('connected');
|
||||
mcpPort.textContent = 'Fallback';
|
||||
cdpStatus.classList.add('connected');
|
||||
cdpDetail.textContent = 'Active';
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
openSettings?.addEventListener('click', () => {
|
||||
mainSection.classList.add('hidden');
|
||||
settingsSection.classList.remove('hidden');
|
||||
loadSettings();
|
||||
});
|
||||
|
||||
backBtn?.addEventListener('click', () => {
|
||||
settingsSection.classList.add('hidden');
|
||||
mainSection.classList.remove('hidden');
|
||||
});
|
||||
|
||||
function loadSettings() {
|
||||
chrome.storage.local.get(['mcpPort', 'cdpPort', 'zapPorts'], (result) => {
|
||||
const mcpPortInput = document.getElementById('mcp-port-setting');
|
||||
const cdpPortInput = document.getElementById('cdp-port-setting');
|
||||
const zapPortsInput = document.getElementById('zap-ports-setting');
|
||||
|
||||
if (mcpPortInput) mcpPortInput.value = result.mcpPort || 3001;
|
||||
if (cdpPortInput) cdpPortInput.value = result.cdpPort || 9223;
|
||||
if (zapPortsInput) zapPortsInput.value = (result.zapPorts || [9999, 9998, 9997, 9996, 9995]).join(',');
|
||||
});
|
||||
}
|
||||
|
||||
saveSettings?.addEventListener('click', () => {
|
||||
const mcpPortVal = parseInt(document.getElementById('mcp-port-setting')?.value) || 3001;
|
||||
const cdpPortVal = parseInt(document.getElementById('cdp-port-setting')?.value) || 9223;
|
||||
const zapPortsVal = (document.getElementById('zap-ports-setting')?.value || '')
|
||||
.split(',').map(p => parseInt(p.trim())).filter(p => p > 0 && p < 65536);
|
||||
|
||||
chrome.storage.local.set({
|
||||
mcpPort: mcpPortVal,
|
||||
cdpPort: cdpPortVal,
|
||||
zapPorts: zapPortsVal.length ? zapPortsVal : [9999, 9998, 9997, 9996, 9995],
|
||||
}, () => {
|
||||
saveSettings.textContent = 'Saved!';
|
||||
setTimeout(() => { saveSettings.textContent = 'Save Settings'; }, 1500);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Test Connection ---
|
||||
testConnection?.addEventListener('click', () => {
|
||||
testConnection.textContent = 'Testing...';
|
||||
testConnection.disabled = true;
|
||||
|
||||
chrome.runtime.sendMessage({ action: 'zap.discover' }, () => {
|
||||
refreshStatus();
|
||||
testConnection.textContent = 'Test Connection';
|
||||
testConnection.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Feature toggles
|
||||
['source-maps', 'webgpu-ai', 'browser-control', 'tab-filesystem'].forEach(id => {
|
||||
const checkbox = document.getElementById(id);
|
||||
if (checkbox) {
|
||||
chrome.storage.local.get([id], (result) => {
|
||||
checkbox.checked = result[id] !== false; // default on
|
||||
});
|
||||
checkbox.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ [id]: checkbox.checked });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Browser Backend Picker ---
|
||||
const backendSelect = document.getElementById('browser-backend') as HTMLSelectElement | null;
|
||||
const bridgeStatus = document.getElementById('bridge-status');
|
||||
const bridgeDetail = document.getElementById('bridge-detail');
|
||||
|
||||
function loadBackendPreference() {
|
||||
chrome.storage.sync.get(['browserBackend'], (result) => {
|
||||
if (backendSelect && result.browserBackend) {
|
||||
backendSelect.value = result.browserBackend;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshBridgeStatus() {
|
||||
chrome.runtime.sendMessage({ action: 'bridge.status' }, (response) => {
|
||||
if (chrome.runtime.lastError) return;
|
||||
if (response?.success && response.connected) {
|
||||
bridgeStatus?.classList.add('connected');
|
||||
bridgeStatus?.classList.remove('disconnected');
|
||||
const browsers = response.browsers || [];
|
||||
bridgeDetail.textContent = browsers.length
|
||||
? `Connected: ${browsers.join(', ')}`
|
||||
: 'Connected';
|
||||
} else {
|
||||
bridgeStatus?.classList.remove('connected');
|
||||
bridgeStatus?.classList.add('disconnected');
|
||||
bridgeDetail.textContent = 'Not connected';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
backendSelect?.addEventListener('change', () => {
|
||||
const value = backendSelect.value;
|
||||
|
||||
// Save to chrome.storage.sync (cross-device)
|
||||
chrome.storage.sync.set({ browserBackend: value });
|
||||
|
||||
// Forward to background to persist via CDP bridge + sync to IAM
|
||||
chrome.runtime.sendMessage({
|
||||
action: 'config.save',
|
||||
key: 'browserBackend',
|
||||
value,
|
||||
});
|
||||
});
|
||||
|
||||
loadBackendPreference();
|
||||
refreshBridgeStatus();
|
||||
|
||||
// Init
|
||||
checkAuth();
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -8,75 +8,62 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="sidebar-container">
|
||||
<!-- Header -->
|
||||
<header class="sidebar-header">
|
||||
<div class="brand">
|
||||
<svg class="logo" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span>Hanzo AI</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div id="user-badge" class="user-badge hidden" title="Account">
|
||||
<img id="header-avatar" class="header-avatar" src="" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Auth section (hidden — login prompt is inside Chat tab) -->
|
||||
<section id="auth-section" class="hidden"></section>
|
||||
|
||||
<!-- Auth Gate (shown when not logged in) -->
|
||||
<section id="auth-section" class="auth-section">
|
||||
<div class="auth-card">
|
||||
<svg class="auth-logo" viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<h2>Hanzo AI</h2>
|
||||
<p>Sign in to access AI chat, tools, and browser automation.</p>
|
||||
<button id="auth-btn" class="primary-btn">Sign in with Hanzo</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tab Bar (shown after auth) -->
|
||||
<!-- Tab Bar (always visible) -->
|
||||
<nav id="tab-bar" class="tab-bar hidden">
|
||||
<button class="tab active" data-tab="chat">Chat</button>
|
||||
<button class="tab" data-tab="tools">Tools</button>
|
||||
<button class="tab" data-tab="settings">
|
||||
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" width="14" height="14">
|
||||
<path d="M8 10a2 2 0 100-4 2 2 0 000 4z" stroke-width="1.5"/>
|
||||
<path d="M13 8a5 5 0 01-.4 2l1 1.7a7 7 0 01-1.3.8l-1-1.7a5 5 0 01-2 .4v2a7 7 0 01-1.5 0v-2a5 5 0 01-2-.4l-1 1.7a7 7 0 01-1.3-.8l1-1.7A5 5 0 013 8a5 5 0 01.4-2l-1-1.7a7 7 0 011.3-.8l1 1.7a5 5 0 012-.4V2.8a7 7 0 011.5 0v2a5 5 0 012 .4l1-1.7a7 7 0 011.3.8l-1 1.7A5 5 0 0113 8z" stroke-width="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="tab" data-tab="settings">Settings</button>
|
||||
</nav>
|
||||
|
||||
<!-- Chat Tab -->
|
||||
<div id="tab-chat" class="tab-content hidden">
|
||||
<!-- Login prompt (shown when not authenticated) -->
|
||||
<div id="chat-login-prompt" class="chat-login-prompt hidden">
|
||||
<div class="auth-card compact">
|
||||
<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>
|
||||
<div id="chat-messages" class="chat-messages">
|
||||
<div class="chat-welcome">
|
||||
<p>Ask anything. Powered by Hanzo Cloud.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input-area">
|
||||
<div class="model-selector">
|
||||
<select id="model-select">
|
||||
<option value="gpt-4o">GPT-4o</option>
|
||||
<option value="claude-sonnet-4-20250514">Claude Sonnet</option>
|
||||
<option value="claude-opus-4-20250514">Claude Opus</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<div id="chat-composer" class="composer">
|
||||
<div class="composer-box">
|
||||
<textarea id="chat-input" placeholder="Ask anything..." rows="1"></textarea>
|
||||
<button id="send-btn" class="send-btn" title="Send">
|
||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor">
|
||||
<path d="M4 10l12-6-6 12-2-6-4-2z" stroke-width="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="composer-bar">
|
||||
<div class="composer-controls">
|
||||
<select id="model-select" class="composer-model">
|
||||
<option value="auto">Auto</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>
|
||||
<option value="zen-coder-flash">Zen Coder Flash</option>
|
||||
<option value="zen-max">Zen Max</option>
|
||||
</select>
|
||||
<label class="composer-toggle" title="RAG context">
|
||||
<input type="checkbox" id="rag-enabled" checked>
|
||||
<span>RAG</span>
|
||||
</label>
|
||||
<label class="composer-toggle" title="Include active tab">
|
||||
<input type="checkbox" id="tab-context-enabled" checked>
|
||||
<span>Tab</span>
|
||||
</label>
|
||||
<span id="rag-status" class="composer-status">Ready</span>
|
||||
</div>
|
||||
<button id="send-btn" class="composer-send" title="Send (Enter)">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<path d="M3 13V3l10 5-10 5z" fill="currentColor"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,14 +71,28 @@
|
||||
<!-- Tools Tab -->
|
||||
<div id="tab-tools" class="tab-content hidden">
|
||||
<div class="tools-scroll">
|
||||
<!-- Connection Status -->
|
||||
<!-- Hanzo Services -->
|
||||
<div class="panel">
|
||||
<h3>Connections</h3>
|
||||
<h3>Hanzo Services</h3>
|
||||
<div class="mcp-status">
|
||||
<div class="status-row">
|
||||
<span>Hanzo Node</span>
|
||||
<span id="hanzo-node-status" class="status-value">
|
||||
<span class="status-indicator disconnected"></span>
|
||||
Checking...
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span>Hanzo Bot</span>
|
||||
<span id="hanzo-mcp-status" class="status-value">
|
||||
<span class="status-indicator disconnected"></span>
|
||||
Checking...
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span>ZAP Protocol</span>
|
||||
<span id="mcp-status" class="status-value">
|
||||
<span class="status-indicator connected"></span>
|
||||
<span class="status-indicator disconnected"></span>
|
||||
Discovering...
|
||||
</span>
|
||||
</div>
|
||||
@@ -100,6 +101,68 @@
|
||||
<span id="mcp-tools" class="status-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
<button class="action-btn small" id="start-hanzo-node" title="Start hanzo node">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
||||
<path d="M4 2l10 6-10 6V2z"/>
|
||||
</svg>
|
||||
Start Node
|
||||
</button>
|
||||
<button class="action-btn small" id="start-hanzo-mcp" title="Start hanzo bot">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12">
|
||||
<path d="M4 2l10 6-10 6V2z"/>
|
||||
</svg>
|
||||
Start Bot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connected MCP Servers -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>MCP Servers</h3>
|
||||
<span id="mcp-server-count" class="badge">0</span>
|
||||
</div>
|
||||
<div id="mcp-server-list" class="mcp-server-list">
|
||||
<div class="empty-state">No MCP servers connected</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Built-in + Discovered Tools -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>Available Tools</h3>
|
||||
<span id="tool-count-badge" class="badge">0</span>
|
||||
</div>
|
||||
<div id="builtin-tool-list" class="tool-list"></div>
|
||||
<div id="mcp-tool-list" class="tool-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Usage -->
|
||||
<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 -->
|
||||
@@ -126,24 +189,6 @@
|
||||
<div id="tab-fs" class="tab-filesystem"></div>
|
||||
</div>
|
||||
|
||||
<!-- WebGPU Status -->
|
||||
<div class="panel">
|
||||
<h3>WebGPU AI</h3>
|
||||
<div class="gpu-status">
|
||||
<div class="status-row">
|
||||
<span>Status</span>
|
||||
<span id="gpu-status" class="status-value">
|
||||
<span class="status-indicator warning"></span>
|
||||
Checking...
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span>Model</span>
|
||||
<span id="gpu-model" class="status-value mono">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<button id="launch-agent" class="action-btn">
|
||||
@@ -159,8 +204,8 @@
|
||||
<!-- Settings Tab -->
|
||||
<div id="tab-settings" class="tab-content hidden">
|
||||
<div class="settings-scroll">
|
||||
<!-- Account -->
|
||||
<div class="panel" id="account-panel">
|
||||
<!-- Account (shown when authenticated) -->
|
||||
<div class="panel hidden" id="account-panel">
|
||||
<h3>Account</h3>
|
||||
<div class="user-profile">
|
||||
<img id="user-avatar" class="user-avatar" src="" alt="">
|
||||
@@ -187,6 +232,10 @@
|
||||
<span>ZAP Ports</span>
|
||||
<input type="text" id="zap-ports-setting" value="9999,9998,9997,9996,9995" placeholder="Comma-separated">
|
||||
</label>
|
||||
<label class="setting-item">
|
||||
<span>Node Port</span>
|
||||
<input type="number" id="node-port-setting" value="52415" min="1024" max="65535">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- AI Settings -->
|
||||
@@ -195,9 +244,12 @@
|
||||
<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>
|
||||
<option value="claude-sonnet-4-20250514">Claude Sonnet</option>
|
||||
<option value="claude-opus-4-20250514">Claude Opus</option>
|
||||
<option value="zen-coder-flash">Zen Coder Flash</option>
|
||||
<option value="zen-max">Zen Max</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="setting-item">
|
||||
@@ -219,6 +271,35 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- RAG Settings -->
|
||||
<div class="panel">
|
||||
<h3>RAG Integration</h3>
|
||||
<label class="setting-item">
|
||||
<span>Use ZAP Memory</span>
|
||||
<input type="checkbox" id="rag-use-zap" checked>
|
||||
</label>
|
||||
<label class="setting-item">
|
||||
<span>Include Tab Context</span>
|
||||
<input type="checkbox" id="rag-include-tab-context" checked>
|
||||
</label>
|
||||
<label class="setting-item">
|
||||
<span>Top K</span>
|
||||
<input type="number" id="rag-top-k" value="5" min="1" max="20">
|
||||
</label>
|
||||
<label class="setting-item setting-item-column">
|
||||
<span>Knowledge Base</span>
|
||||
<input type="text" id="rag-kb" placeholder="e.g. engineering-docs">
|
||||
</label>
|
||||
<label class="setting-item setting-item-column">
|
||||
<span>RAG Endpoint</span>
|
||||
<input type="text" id="rag-endpoint" placeholder="https://your-rag.company.com/query">
|
||||
</label>
|
||||
<label class="setting-item setting-item-column">
|
||||
<span>RAG API Key</span>
|
||||
<input type="password" id="rag-api-key" placeholder="Optional bearer token">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button id="save-settings" class="primary-btn">Save Settings</button>
|
||||
|
||||
<!-- Links -->
|
||||
@@ -229,6 +310,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="sidebar-footer">
|
||||
<a href="https://hanzo.ai" target="_blank" class="footer-brand" title="hanzo.ai">
|
||||
<svg viewBox="0 0 67 67" xmlns="http://www.w3.org/2000/svg" width="14" height="14">
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z" fill="currentColor"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="currentColor"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z" fill="currentColor"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="currentColor"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span>hanzo.ai</span>
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="sidebar.js"></script>
|
||||
|
||||
@@ -7,6 +7,8 @@ class HanzoSidebar {
|
||||
this.messages = [];
|
||||
this.streaming = false;
|
||||
this.streamController = null;
|
||||
this.authenticated = false;
|
||||
this.ragContext = null;
|
||||
|
||||
this.initializeUI();
|
||||
this.setupEventListeners();
|
||||
@@ -28,11 +30,15 @@ class HanzoSidebar {
|
||||
chatInput: document.getElementById('chat-input'),
|
||||
sendBtn: document.getElementById('send-btn'),
|
||||
modelSelect: document.getElementById('model-select'),
|
||||
ragEnabled: document.getElementById('rag-enabled'),
|
||||
tabContextEnabled: document.getElementById('tab-context-enabled'),
|
||||
ragStatus: document.getElementById('rag-status'),
|
||||
|
||||
// Tools
|
||||
tabTools: document.getElementById('tab-tools'),
|
||||
mcpStatus: document.getElementById('mcp-status'),
|
||||
mcpTools: document.getElementById('mcp-tools'),
|
||||
mcpToolList: document.getElementById('mcp-tool-list'),
|
||||
agentCount: document.getElementById('agent-count'),
|
||||
agentList: document.getElementById('agent-list'),
|
||||
tabFs: document.getElementById('tab-fs'),
|
||||
@@ -49,6 +55,12 @@ class HanzoSidebar {
|
||||
logoutBtn: document.getElementById('logout-btn'),
|
||||
saveSettings: document.getElementById('save-settings'),
|
||||
defaultModel: document.getElementById('default-model'),
|
||||
ragUseZap: document.getElementById('rag-use-zap'),
|
||||
ragIncludeTabContext: document.getElementById('rag-include-tab-context'),
|
||||
ragTopK: document.getElementById('rag-top-k'),
|
||||
ragKb: document.getElementById('rag-kb'),
|
||||
ragEndpoint: document.getElementById('rag-endpoint'),
|
||||
ragApiKey: document.getElementById('rag-api-key'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,6 +82,13 @@ class HanzoSidebar {
|
||||
}
|
||||
});
|
||||
this.el.chatInput.addEventListener('input', () => this.autoResize());
|
||||
this.el.ragEnabled?.addEventListener('change', () => {
|
||||
this.setRagStatus(this.el.ragEnabled.checked ? 'RAG enabled' : 'RAG disabled');
|
||||
});
|
||||
this.el.tabContextEnabled?.addEventListener('change', () => {
|
||||
if (!this.el.ragEnabled?.checked) return;
|
||||
this.setRagStatus(this.el.tabContextEnabled.checked ? 'Tab context on' : 'Tab context off');
|
||||
});
|
||||
|
||||
// Tools
|
||||
this.el.refreshTabs.addEventListener('click', () => this.refreshTabFilesystem());
|
||||
@@ -91,13 +110,29 @@ class HanzoSidebar {
|
||||
// ===========================================================================
|
||||
|
||||
async checkAuth() {
|
||||
// Always show tabs and initialize tools — auth only gates chat
|
||||
this.el.tabBar.classList.remove('hidden');
|
||||
this.el.authSection.classList.add('hidden');
|
||||
this.switchTab('tools'); // Default to tools (always works)
|
||||
|
||||
// Initialize features that work without auth
|
||||
this.connectToMCP();
|
||||
this.refreshTabFilesystem();
|
||||
this.checkWebGPU();
|
||||
this.loadSettings();
|
||||
this.startMonitoring();
|
||||
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({ action: 'auth.status' });
|
||||
if (response?.success && response.authenticated) {
|
||||
this.showApp(response.user);
|
||||
this.setUser(response.user);
|
||||
this.switchTab('chat');
|
||||
} else {
|
||||
this.showChatLoginPrompt();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
this.showChatLoginPrompt();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +143,8 @@ class HanzoSidebar {
|
||||
try {
|
||||
const response = await chrome.runtime.sendMessage({ action: 'auth.login' });
|
||||
if (response?.success) {
|
||||
this.showApp(response.user);
|
||||
this.setUser(response.user);
|
||||
this.hideChatLoginPrompt();
|
||||
} else {
|
||||
this.showError(response?.error || 'Sign in failed');
|
||||
}
|
||||
@@ -122,19 +158,15 @@ class HanzoSidebar {
|
||||
|
||||
async logout() {
|
||||
await chrome.runtime.sendMessage({ action: 'auth.logout' });
|
||||
this.el.authSection.classList.remove('hidden');
|
||||
this.el.tabBar.classList.add('hidden');
|
||||
this.el.userBadge.classList.add('hidden');
|
||||
document.querySelectorAll('.tab-content').forEach(t => t.classList.add('hidden'));
|
||||
this.el.userName.textContent = '';
|
||||
this.el.userEmail.textContent = '';
|
||||
this.showChatLoginPrompt();
|
||||
this.switchTab('tools');
|
||||
}
|
||||
|
||||
showApp(user) {
|
||||
// Hide auth, show tabs
|
||||
this.el.authSection.classList.add('hidden');
|
||||
this.el.tabBar.classList.remove('hidden');
|
||||
this.switchTab('chat');
|
||||
|
||||
// Set user info
|
||||
setUser(user) {
|
||||
this.authenticated = true;
|
||||
if (user) {
|
||||
const avatar = user.picture || user.avatar || '';
|
||||
if (avatar) {
|
||||
@@ -145,15 +177,25 @@ class HanzoSidebar {
|
||||
this.el.userName.textContent = user.name || user.displayName || 'User';
|
||||
this.el.userEmail.textContent = user.email || '';
|
||||
}
|
||||
|
||||
// Init data
|
||||
this.loadModels();
|
||||
this.loadConversation();
|
||||
this.connectToMCP();
|
||||
this.refreshTabFilesystem();
|
||||
this.checkWebGPU();
|
||||
this.loadSettings();
|
||||
this.startMonitoring();
|
||||
}
|
||||
|
||||
showChatLoginPrompt() {
|
||||
this.authenticated = false;
|
||||
// Show login prompt in chat area instead of blocking entire sidebar
|
||||
const chatLogin = document.getElementById('chat-login-prompt');
|
||||
if (chatLogin) chatLogin.classList.remove('hidden');
|
||||
const chatComposer = document.getElementById('chat-composer');
|
||||
if (chatComposer) chatComposer.classList.add('hidden');
|
||||
}
|
||||
|
||||
hideChatLoginPrompt() {
|
||||
this.authenticated = true;
|
||||
const chatLogin = document.getElementById('chat-login-prompt');
|
||||
if (chatLogin) chatLogin.classList.add('hidden');
|
||||
const chatComposer = document.getElementById('chat-composer');
|
||||
if (chatComposer) chatComposer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
@@ -201,6 +243,10 @@ class HanzoSidebar {
|
||||
async sendMessage() {
|
||||
const text = this.el.chatInput.value.trim();
|
||||
if (!text || this.streaming) return;
|
||||
if (!this.authenticated) {
|
||||
this.showError('Please sign in to use chat');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get token
|
||||
const tokenResp = await chrome.runtime.sendMessage({ action: 'auth.getToken' });
|
||||
@@ -209,8 +255,32 @@ class HanzoSidebar {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
this.messages.push({ role: 'user', content: text });
|
||||
const userMessage = { role: 'user', content: text };
|
||||
const model = this.el.modelSelect.value;
|
||||
let requestMessages = [...this.messages, userMessage];
|
||||
|
||||
this.ragContext = null;
|
||||
if (this.el.ragEnabled?.checked) {
|
||||
this.setRagStatus('Retrieving context...');
|
||||
const ragResp = await this.requestRagContext(text);
|
||||
if (ragResp?.success && Array.isArray(ragResp.snippets) && ragResp.snippets.length) {
|
||||
this.ragContext = ragResp;
|
||||
requestMessages = [
|
||||
{ role: 'system', content: this.buildRagSystemPrompt(ragResp) },
|
||||
...requestMessages,
|
||||
];
|
||||
this.setRagStatus(`RAG: ${ragResp.snippets.length} snippet(s) from ${ragResp.source || 'context'}`);
|
||||
} else if (ragResp?.error) {
|
||||
this.setRagStatus(`RAG unavailable (${ragResp.error})`);
|
||||
} else {
|
||||
this.setRagStatus('No context found');
|
||||
}
|
||||
} else {
|
||||
this.setRagStatus('RAG disabled');
|
||||
}
|
||||
|
||||
// Add user message to transcript
|
||||
this.messages.push(userMessage);
|
||||
this.appendMessage('user', text);
|
||||
|
||||
// Clear input
|
||||
@@ -228,8 +298,7 @@ class HanzoSidebar {
|
||||
|
||||
try {
|
||||
// Stream via direct fetch (sidebar has access via CSP)
|
||||
const model = this.el.modelSelect.value;
|
||||
const response = await fetch('https://api.hanzo.ai/api/chat/completions', {
|
||||
const response = await fetch('https://api.hanzo.ai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -237,7 +306,7 @@ class HanzoSidebar {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: this.messages,
|
||||
messages: requestMessages,
|
||||
stream: true,
|
||||
}),
|
||||
signal: (this.streamController = new AbortController()).signal,
|
||||
@@ -280,13 +349,18 @@ class HanzoSidebar {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.ragContext?.snippets?.length) {
|
||||
fullContent += this.buildRagCitationBlock(this.ragContext);
|
||||
assistantEl.innerHTML = this.renderMarkdown(fullContent);
|
||||
}
|
||||
|
||||
this.messages.push({ role: 'assistant', content: fullContent });
|
||||
this.saveConversation();
|
||||
} catch (error) {
|
||||
typingEl.remove();
|
||||
if (error.name !== 'AbortError') {
|
||||
if (error?.name !== 'AbortError') {
|
||||
assistantEl.remove();
|
||||
this.appendError(error.message);
|
||||
this.appendError(error?.message || 'Chat failed');
|
||||
}
|
||||
} finally {
|
||||
this.streaming = false;
|
||||
@@ -295,6 +369,77 @@ class HanzoSidebar {
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveTabContext() {
|
||||
try {
|
||||
const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!activeTab) return null;
|
||||
return {
|
||||
url: activeTab.url || '',
|
||||
title: activeTab.title || '',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async requestRagContext(query) {
|
||||
try {
|
||||
const pageContext = this.el.tabContextEnabled?.checked ? await this.getActiveTabContext() : null;
|
||||
const topK = Math.max(1, Math.min(parseInt(this.el.ragTopK?.value || '5', 10) || 5, 20));
|
||||
|
||||
return await chrome.runtime.sendMessage({
|
||||
action: 'rag.query',
|
||||
query,
|
||||
topK,
|
||||
includeTabContext: !!this.el.tabContextEnabled?.checked,
|
||||
useZapMemory: !!this.el.ragUseZap?.checked,
|
||||
knowledgeBase: this.el.ragKb?.value || '',
|
||||
endpoint: this.el.ragEndpoint?.value || '',
|
||||
apiKey: this.el.ragApiKey?.value || '',
|
||||
pageContext: pageContext || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
return { success: false, error: error?.message || 'Failed to query RAG' };
|
||||
}
|
||||
}
|
||||
|
||||
buildRagSystemPrompt(ragResponse) {
|
||||
const snippets = (ragResponse?.snippets || []).slice(0, 8);
|
||||
const lines = snippets.map((snippet, index) => {
|
||||
const title = snippet.title || snippet.source || `Snippet ${index + 1}`;
|
||||
const score = typeof snippet.score === 'number' ? ` (score ${snippet.score.toFixed(3)})` : '';
|
||||
const sourceLine = snippet.url ? `${title}${score} — ${snippet.url}` : `${title}${score}`;
|
||||
return `[${index + 1}] ${sourceLine}\n${snippet.content}`;
|
||||
});
|
||||
|
||||
return [
|
||||
'You are assisting inside Hanzo Browser Extension.',
|
||||
'Use the retrieved context below when relevant, and state uncertainty if context is insufficient.',
|
||||
'Retrieved Context:',
|
||||
...lines,
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
buildRagCitationBlock(ragResponse) {
|
||||
const snippets = (ragResponse?.snippets || []).slice(0, 5);
|
||||
if (!snippets.length) return '';
|
||||
|
||||
const rows = snippets.map((snippet, index) => {
|
||||
const title = snippet.title || snippet.source || `Snippet ${index + 1}`;
|
||||
if (snippet.url) {
|
||||
return `- ${title}: ${snippet.url}`;
|
||||
}
|
||||
return `- ${title}`;
|
||||
});
|
||||
|
||||
return `\n\n---\nContext Sources:\n${rows.join('\n')}`;
|
||||
}
|
||||
|
||||
setRagStatus(text) {
|
||||
if (!this.el.ragStatus) return;
|
||||
this.el.ragStatus.textContent = text || 'Ready';
|
||||
}
|
||||
|
||||
appendMessage(role, content) {
|
||||
// Remove welcome message
|
||||
const welcome = this.el.chatMessages.querySelector('.chat-welcome');
|
||||
@@ -400,11 +545,19 @@ class HanzoSidebar {
|
||||
const zap = zapResponse.zap;
|
||||
const toolCount = zap.mcps?.reduce((sum, m) => sum + (m.tools?.length || 0), 0) || 0;
|
||||
this.updateMCPStatus(zap.connected, toolCount, zap.mcps?.length || 0);
|
||||
if (zap.connected) {
|
||||
const toolsResp = await chrome.runtime.sendMessage({ action: 'zap.listTools' });
|
||||
this.updateMCPToolList(toolsResp?.success ? toolsResp.tools : []);
|
||||
} else {
|
||||
this.updateMCPToolList([]);
|
||||
}
|
||||
} else {
|
||||
this.updateMCPStatus(false);
|
||||
this.updateMCPToolList([]);
|
||||
}
|
||||
} catch {
|
||||
this.updateMCPStatus(false);
|
||||
this.updateMCPToolList([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,6 +572,19 @@ class HanzoSidebar {
|
||||
this.el.mcpTools.textContent = toolCount;
|
||||
}
|
||||
|
||||
updateMCPToolList(tools) {
|
||||
if (!this.el.mcpToolList) return;
|
||||
if (!tools || !tools.length) {
|
||||
this.el.mcpToolList.textContent = 'No tools discovered yet.';
|
||||
return;
|
||||
}
|
||||
|
||||
const names = Array.from(new Set(tools.map((tool) => tool.name))).sort();
|
||||
const preview = names.slice(0, 20);
|
||||
const suffix = names.length > preview.length ? `\n... +${names.length - preview.length} more` : '';
|
||||
this.el.mcpToolList.textContent = `${preview.join('\n')}${suffix}`;
|
||||
}
|
||||
|
||||
async refreshTabFilesystem() {
|
||||
try {
|
||||
const tabs = await chrome.tabs.query({});
|
||||
@@ -577,6 +743,7 @@ class HanzoSidebar {
|
||||
}
|
||||
|
||||
const agentType = typeSelect.value;
|
||||
const agentInstructions = instructions.value?.trim() || '';
|
||||
overlay.remove();
|
||||
|
||||
try {
|
||||
@@ -584,6 +751,7 @@ class HanzoSidebar {
|
||||
action: 'launchAIWorker',
|
||||
tabId,
|
||||
model: agentType,
|
||||
instructions: agentInstructions,
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
@@ -610,7 +778,20 @@ class HanzoSidebar {
|
||||
// ===========================================================================
|
||||
|
||||
loadSettings() {
|
||||
chrome.storage.local.get(['mcpPort', 'cdpPort', 'zapPorts', 'hanzo_default_model'], (result) => {
|
||||
chrome.storage.local.get([
|
||||
'mcpPort',
|
||||
'cdpPort',
|
||||
'zapPorts',
|
||||
'hanzo_default_model',
|
||||
'hanzo_rag_endpoint',
|
||||
'hanzo_rag_api_key',
|
||||
'hanzo_rag_kb',
|
||||
'hanzo_rag_top_k',
|
||||
'hanzo_rag_include_tab_context',
|
||||
'hanzo_rag_use_zap',
|
||||
'hanzo_chat_rag_enabled',
|
||||
'hanzo_chat_tab_context_enabled',
|
||||
], (result) => {
|
||||
const mcpPort = document.getElementById('mcp-port-setting');
|
||||
const cdpPort = document.getElementById('cdp-port-setting');
|
||||
const zapPorts = document.getElementById('zap-ports-setting');
|
||||
@@ -621,6 +802,16 @@ class HanzoSidebar {
|
||||
if (result.hanzo_default_model && this.el.defaultModel) {
|
||||
this.el.defaultModel.value = result.hanzo_default_model;
|
||||
}
|
||||
|
||||
if (this.el.ragEndpoint) this.el.ragEndpoint.value = result.hanzo_rag_endpoint || '';
|
||||
if (this.el.ragApiKey) this.el.ragApiKey.value = result.hanzo_rag_api_key || '';
|
||||
if (this.el.ragKb) this.el.ragKb.value = result.hanzo_rag_kb || '';
|
||||
if (this.el.ragTopK) this.el.ragTopK.value = String(result.hanzo_rag_top_k || 5);
|
||||
if (this.el.ragIncludeTabContext) this.el.ragIncludeTabContext.checked = result.hanzo_rag_include_tab_context !== false;
|
||||
if (this.el.ragUseZap) this.el.ragUseZap.checked = result.hanzo_rag_use_zap !== false;
|
||||
if (this.el.ragEnabled) this.el.ragEnabled.checked = result.hanzo_chat_rag_enabled !== false;
|
||||
if (this.el.tabContextEnabled) this.el.tabContextEnabled.checked = result.hanzo_chat_tab_context_enabled !== false;
|
||||
this.setRagStatus(this.el.ragEnabled?.checked ? 'Ready' : 'RAG disabled');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -638,6 +829,14 @@ class HanzoSidebar {
|
||||
'safe-mode': document.getElementById('safe-mode')?.checked,
|
||||
'enable-webgpu': document.getElementById('enable-webgpu')?.checked,
|
||||
maxAgents: parseInt(document.getElementById('max-agents')?.value) || 3,
|
||||
hanzo_rag_endpoint: this.el.ragEndpoint?.value?.trim() || '',
|
||||
hanzo_rag_api_key: this.el.ragApiKey?.value?.trim() || '',
|
||||
hanzo_rag_kb: this.el.ragKb?.value?.trim() || '',
|
||||
hanzo_rag_top_k: Math.max(1, Math.min(parseInt(this.el.ragTopK?.value || '5', 10) || 5, 20)),
|
||||
hanzo_rag_include_tab_context: !!this.el.ragIncludeTabContext?.checked,
|
||||
hanzo_rag_use_zap: !!this.el.ragUseZap?.checked,
|
||||
hanzo_chat_rag_enabled: !!this.el.ragEnabled?.checked,
|
||||
hanzo_chat_tab_context_enabled: !!this.el.tabContextEnabled?.checked,
|
||||
});
|
||||
|
||||
this.showNotification('Settings saved');
|
||||
|
||||
@@ -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.14",
|
||||
"description": "Hanzo AI DXT Bundle",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
@@ -11,4 +11,4 @@
|
||||
"archiver": "^7.0.1"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.11
|
||||
|
||||
# Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
|
||||
pluginSinceBuild = 233
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@hanzo/mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "Hanzo MCP Server - Model Context Protocol tools for AI development",
|
||||
"version": "2.2.2",
|
||||
"description": "Hanzo MCP Server - Unified tool surface with AI, cloud control, vector search, and multi-framework UI",
|
||||
"main": "dist/index.js",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -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.14",
|
||||
"description": "Hanzo AI CLI Tools Integration",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
@@ -34,4 +34,4 @@
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import * as crypto from 'crypto';
|
||||
|
||||
export interface HanzoAuthConfig {
|
||||
apiUrl: string;
|
||||
iamUrl: string;
|
||||
iamUrl: string; // Casdoor API (iam.hanzo.ai)
|
||||
iamLoginUrl: string; // Login UI (hanzo.id)
|
||||
clientId: string;
|
||||
scope: string;
|
||||
configPath?: string;
|
||||
@@ -44,6 +45,7 @@ export class HanzoAuth extends EventEmitter {
|
||||
this.config = {
|
||||
apiUrl: config.apiUrl || 'https://api.hanzo.ai',
|
||||
iamUrl: config.iamUrl || 'https://iam.hanzo.ai',
|
||||
iamLoginUrl: config.iamLoginUrl || 'https://hanzo.id',
|
||||
clientId: config.clientId || 'hanzo-dev-cli',
|
||||
scope: config.scope || 'api:access tools:manage',
|
||||
configPath: config.configPath || path.join(os.homedir(), '.hanzo')
|
||||
@@ -100,9 +102,8 @@ export class HanzoAuth extends EventEmitter {
|
||||
const url = new URL(req.url!, `http://localhost:${port}`);
|
||||
|
||||
if (url.pathname === '/callback') {
|
||||
const code = url.searchParams.get('code');
|
||||
const returnedState = url.searchParams.get('state');
|
||||
|
||||
|
||||
if (returnedState !== state) {
|
||||
res.writeHead(400);
|
||||
res.end('Invalid state parameter');
|
||||
@@ -110,55 +111,69 @@ export class HanzoAuth extends EventEmitter {
|
||||
reject(new Error('Invalid state parameter'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (code) {
|
||||
// Exchange code for tokens
|
||||
try {
|
||||
await this.exchangeCodeForTokens(code, codeVerifier);
|
||||
|
||||
// Success response
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Hanzo Dev - Login Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #1a1a1a; color: white; }
|
||||
.container { text-align: center; }
|
||||
.success { color: #4ade80; font-size: 48px; margin-bottom: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="success">✓</div>
|
||||
<h1>Login Successful!</h1>
|
||||
<p>You can now close this window and return to your terminal.</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
this.server?.close();
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
res.writeHead(500);
|
||||
res.end('Failed to exchange code for tokens');
|
||||
this.server?.close();
|
||||
reject(error);
|
||||
}
|
||||
|
||||
// Implicit flow: access_token comes directly in URL params
|
||||
const accessToken = url.searchParams.get('access_token');
|
||||
// Code flow fallback
|
||||
const code = url.searchParams.get('code');
|
||||
|
||||
const successHtml = `<html><head><title>Hanzo Dev - Login Successful</title>
|
||||
<style>body{font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#1a1a1a;color:white}.container{text-align:center}.success{color:#4ade80;font-size:48px;margin-bottom:20px}</style>
|
||||
</head><body><div class="container"><div class="success">✓</div><h1>Login Successful!</h1>
|
||||
<p>You can now close this window.</p></div>
|
||||
<script>setTimeout(()=>window.close(),3000)</script></body></html>`;
|
||||
|
||||
if (accessToken) {
|
||||
// Implicit flow — token received directly
|
||||
const refreshToken = url.searchParams.get('refresh_token');
|
||||
const expiresIn = url.searchParams.get('expires_in');
|
||||
|
||||
this.credentials = {
|
||||
accessToken,
|
||||
refreshToken: refreshToken || undefined,
|
||||
expiresAt: expiresIn ? Date.now() + parseInt(expiresIn, 10) * 1000 : Date.now() + 168 * 3600 * 1000,
|
||||
...this.credentials,
|
||||
};
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(successHtml);
|
||||
this.server?.close();
|
||||
|
||||
// Fetch user info and save
|
||||
this.fetchUserInfo().then(() => this.syncAPIKeys()).then(() => {
|
||||
this.saveCredentials();
|
||||
this.emit('login:success', this.credentials);
|
||||
}).catch(() => {});
|
||||
|
||||
resolve(true);
|
||||
} else if (code) {
|
||||
// Authorization code flow fallback
|
||||
(async () => {
|
||||
try {
|
||||
await this.exchangeCodeForTokens(code, codeVerifier);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(successHtml);
|
||||
this.server?.close();
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
res.writeHead(500);
|
||||
res.end('Failed to exchange code for tokens');
|
||||
this.server?.close();
|
||||
reject(error);
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
res.writeHead(400);
|
||||
res.end('No authorization code received');
|
||||
res.end('No token or authorization code received');
|
||||
this.server?.close();
|
||||
reject(new Error('No authorization code received'));
|
||||
reject(new Error('No token received'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(port, () => {
|
||||
// Build authorization URL
|
||||
const authUrl = new URL('/oauth/authorize', this.config.iamUrl);
|
||||
// Authorization Code + PKCE (OAuth 2.1 standard)
|
||||
const authUrl = new URL('/oauth/authorize', this.config.iamLoginUrl);
|
||||
authUrl.searchParams.set('client_id', this.config.clientId);
|
||||
authUrl.searchParams.set('response_type', 'code');
|
||||
authUrl.searchParams.set('redirect_uri', `http://localhost:${port}/callback`);
|
||||
@@ -224,16 +239,26 @@ export class HanzoAuth extends EventEmitter {
|
||||
|
||||
private async fetchUserInfo(): Promise<void> {
|
||||
if (!this.credentials?.accessToken) return;
|
||||
|
||||
const response = await fetch(`${this.config.iamUrl}/api/user`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.credentials.accessToken}`
|
||||
const headers = { 'Authorization': `Bearer ${this.credentials.accessToken}` };
|
||||
|
||||
// /api/get-account returns full profile; /api/userinfo only returns sub
|
||||
try {
|
||||
const acctResp = await fetch(`${this.config.iamUrl}/api/get-account`, { headers });
|
||||
if (acctResp.ok) {
|
||||
const acctJson = await acctResp.json() as any;
|
||||
const acct = acctJson.data || acctJson;
|
||||
if (acct.name || acct.email) {
|
||||
this.credentials.userId = acct.id || acct.sub;
|
||||
this.credentials.email = acct.email;
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch { /* fall through */ }
|
||||
|
||||
const response = await fetch(`${this.config.iamUrl}/api/userinfo`, { headers });
|
||||
if (response.ok) {
|
||||
const user = await response.json() as any;
|
||||
this.credentials.userId = user.id;
|
||||
this.credentials.userId = user.id || user.sub;
|
||||
this.credentials.email = user.email;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/index.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
@@ -1,399 +1,399 @@
|
||||
{
|
||||
"name": "@hanzo/extension",
|
||||
"displayName": "Hanzo AI",
|
||||
"description": "The ultimate meta AI development platform. Manage and run all LLMs and CLI tools (Claude, Codex, Gemini, OpenHands, Aider) in one unified interface. Features MCP integration, async execution, git worktrees, and universal context sync.",
|
||||
"version": "1.5.8",
|
||||
"publisher": "hanzo-ai",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/dev.git"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"keywords": [
|
||||
"productivity",
|
||||
"AI",
|
||||
"IDE",
|
||||
"project management",
|
||||
"context management",
|
||||
"change tracking",
|
||||
"knowledge base",
|
||||
"documentation",
|
||||
"specification",
|
||||
"analysis"
|
||||
],
|
||||
"icon": "images/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#C80000",
|
||||
"theme": "dark"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onStartupFinished",
|
||||
"onCommand:hanzo.openManager",
|
||||
"onCommand:hanzo.openWelcomeGuide",
|
||||
"onCommand:hanzo.reanalyzeProject",
|
||||
"onCommand:hanzo.triggerReminder",
|
||||
"onCommand:hanzo.login",
|
||||
"onCommand:hanzo.logout",
|
||||
"onCommand:hanzo.debug.authState",
|
||||
"onCommand:hanzo.debug.clearAuth",
|
||||
"onCommand:hanzo.checkMetrics",
|
||||
"onCommand:hanzo.resetMetrics"
|
||||
],
|
||||
"main": "./out/extension.js",
|
||||
"contributes": {
|
||||
"chatParticipants": [
|
||||
{
|
||||
"id": "hanzo",
|
||||
"name": "Hanzo",
|
||||
"description": "Ultimate AI engineering toolkit: 200+ LLMs, 4000+ MCP servers, 53+ dev tools",
|
||||
"isSticky": true
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"command": "hanzo.openManager",
|
||||
"title": "Hanzo: Open Project Manager"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.openWelcomeGuide",
|
||||
"title": "Hanzo: View Getting Started Guide"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.reanalyzeProject",
|
||||
"title": "Hanzo: Analyze Project",
|
||||
"icon": "$(refresh)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.triggerReminder",
|
||||
"title": "Hanzo: Show Reminder",
|
||||
"icon": "$(bell)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.checkMetrics",
|
||||
"title": "Hanzo Debug: Check Impact Metrics",
|
||||
"icon": "$(graph)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.resetMetrics",
|
||||
"title": "Hanzo Debug: Reset Impact Metrics",
|
||||
"icon": "$(trash)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.login",
|
||||
"title": "Hanzo: Login"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.logout",
|
||||
"title": "Hanzo: Logout"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.debug.authState",
|
||||
"title": "Hanzo Debug: Check Auth State",
|
||||
"enablement": "isDevelopment"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.debug.clearAuth",
|
||||
"title": "Hanzo Debug: Clear Auth Data",
|
||||
"enablement": "isDevelopment"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "Hanzo AI Context Manager",
|
||||
"properties": {
|
||||
"hanzo.ide": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cursor",
|
||||
"copilot",
|
||||
"continue",
|
||||
"codium"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Using Cursor IDE (.cursorrules)",
|
||||
"Using GitHub Copilot (.github/copilot-instructions.md)",
|
||||
"Using Continue (.continuerules)",
|
||||
"Using Codium (.windsurfrules)"
|
||||
],
|
||||
"default": "cursor",
|
||||
"description": "Select which IDE to generate rules for"
|
||||
},
|
||||
"hanzo.mcp.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable Model Context Protocol (MCP) server integration"
|
||||
},
|
||||
"hanzo.mcp.transport": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stdio",
|
||||
"tcp"
|
||||
],
|
||||
"default": "stdio",
|
||||
"description": "Transport method for MCP server communication"
|
||||
},
|
||||
"hanzo.mcp.port": {
|
||||
"type": "number",
|
||||
"default": 3000,
|
||||
"description": "Port for MCP server when using TCP transport"
|
||||
},
|
||||
"hanzo.mcp.backend": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"python",
|
||||
"typescript",
|
||||
"rust",
|
||||
"local-node"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Auto-detect: prefer Python (uvx) if available, fallback to TypeScript",
|
||||
"Python MCP via uvx (recommended, most comprehensive tools)",
|
||||
"Built-in TypeScript MCP server",
|
||||
"Rust MCP binary (hanzo-mcp)",
|
||||
"Connect to local hanzod node"
|
||||
],
|
||||
"default": "auto",
|
||||
"description": "MCP backend to use. Python via uvx is recommended for the most comprehensive tool support."
|
||||
},
|
||||
"hanzo.mcp.pythonCommand": {
|
||||
"type": "string",
|
||||
"default": "uvx hanzo-mcp",
|
||||
"description": "Command to run Python MCP server (e.g., 'uvx hanzo-mcp' or 'python -m hanzo_mcp')"
|
||||
},
|
||||
"hanzo.mcp.rustBinary": {
|
||||
"type": "string",
|
||||
"default": "hanzo-mcp",
|
||||
"description": "Path to Rust MCP binary (when backend is 'rust')"
|
||||
},
|
||||
"hanzo.mcp.localNodeUrl": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:8080",
|
||||
"description": "URL of local hanzod node (when backend is 'local-node')"
|
||||
},
|
||||
"hanzo.mcp.allowedPaths": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [],
|
||||
"description": "Paths that MCP tools are allowed to access"
|
||||
},
|
||||
"hanzo.mcp.disableWriteTools": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Disable all write operations in MCP tools"
|
||||
},
|
||||
"hanzo.mcp.disableSearchTools": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Disable all search operations in MCP tools"
|
||||
},
|
||||
"hanzo.mcp.disableBrowserTool": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Disable the built-in browser tool (useful when using Antigravity's native browser)"
|
||||
},
|
||||
"hanzo.mcp.enabledTools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [],
|
||||
"description": "List of explicitly enabled MCP tools"
|
||||
},
|
||||
"hanzo.mcp.disabledTools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [],
|
||||
"description": "List of explicitly disabled MCP tools"
|
||||
},
|
||||
"hanzo.api.endpoint": {
|
||||
"type": "string",
|
||||
"default": "https://api.hanzo.ai/ext/v1",
|
||||
"description": "API endpoint for Hanzo services"
|
||||
},
|
||||
"hanzo.debug": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable debug logging"
|
||||
},
|
||||
"hanzo.llm.provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"hanzo",
|
||||
"lmstudio",
|
||||
"ollama",
|
||||
"openai",
|
||||
"anthropic"
|
||||
],
|
||||
"default": "hanzo",
|
||||
"description": "LLM provider to use for AI features"
|
||||
},
|
||||
"hanzo.llm.hanzo.apiKey": {
|
||||
"type": "string",
|
||||
"description": "API key for Hanzo AI Gateway"
|
||||
},
|
||||
"hanzo.llm.lmstudio.endpoint": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:1234/v1",
|
||||
"description": "LM Studio API endpoint"
|
||||
},
|
||||
"hanzo.llm.lmstudio.model": {
|
||||
"type": "string",
|
||||
"description": "Model to use in LM Studio"
|
||||
},
|
||||
"hanzo.llm.ollama.endpoint": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:11434",
|
||||
"description": "Ollama API endpoint"
|
||||
},
|
||||
"hanzo.llm.ollama.model": {
|
||||
"type": "string",
|
||||
"default": "llama2",
|
||||
"description": "Model to use in Ollama"
|
||||
},
|
||||
"hanzo.llm.openai.apiKey": {
|
||||
"type": "string",
|
||||
"description": "OpenAI API key"
|
||||
},
|
||||
"hanzo.llm.openai.model": {
|
||||
"type": "string",
|
||||
"default": "gpt-4",
|
||||
"description": "OpenAI model to use"
|
||||
},
|
||||
"hanzo.llm.anthropic.apiKey": {
|
||||
"type": "string",
|
||||
"description": "Anthropic API key"
|
||||
},
|
||||
"hanzo.llm.anthropic.model": {
|
||||
"type": "string",
|
||||
"default": "claude-3-opus-20240229",
|
||||
"description": "Anthropic model to use"
|
||||
}
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "hanzo.reanalyzeProject",
|
||||
"group": "navigation",
|
||||
"when": "resourceScheme != extension"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "node scripts/compile-main.js || true && npm run build:mcp",
|
||||
"compile": "tsc -p ./",
|
||||
"watch": "tsc -watch -p ./",
|
||||
"pretest": "npm run compile && npm run lint",
|
||||
"lint": "eslint .",
|
||||
"test": "node ./out/test/runTest.js",
|
||||
"test:simple": "node test-simple.js",
|
||||
"test:pm": "cross-env MOCHA_TEST_FILE=\"ProjectManager Test Suite\" node ./out/test/runTest.js",
|
||||
"test:auth": "cross-env MOCHA_TEST_FILE=\"Auth and API Test Suite\" node ./out/test/runTest.js",
|
||||
"test:file-collection": "cross-env MOCHA_TEST_FILE=\"FileCollectionService Test Suite\" node ./out/test/runTest.js",
|
||||
"test:mcp": "cross-env MOCHA_TEST_FILE=\"MCP.*Tool Test Suite\" node ./out/test/runTest.js",
|
||||
"test:mcp-proxy": "cross-env MOCHA_TEST_FILE=\"MCP.*Proxy Test\" node ./out/test/runTest.js",
|
||||
"test:mcp-integration": "cross-env MOCHA_TEST_FILE=\"MCP Proxy Integration\" node ./out/test/runTest.js",
|
||||
"test:coverage": "c8 npm test",
|
||||
"test:vitest": "vitest",
|
||||
"test:vitest:coverage": "vitest --coverage",
|
||||
"test:vitest:run": "vitest run --coverage",
|
||||
"build:mcp": "node scripts/build-mcp-standalone.js",
|
||||
"build:claude-desktop": "npm run build:mcp && node scripts/build-claude-desktop.js",
|
||||
"build": "tsc -p ./",
|
||||
"build:dxt": "node scripts/build-dxt.js",
|
||||
"build:npm": "node scripts/build-mcp-npm.js",
|
||||
"build:cursor": "node scripts/build-cursor.js",
|
||||
"build:windsurf": "node scripts/build-windsurf.js",
|
||||
"build:all": "node scripts/build-all-platforms.js",
|
||||
"build:browser-extension": "node src/browser-extension/build.js",
|
||||
"package": "npm run build:all && sed -i '' 's/\"name\": \"@hanzo\\/extension\"/\"name\": \"hanzo-extension\"/' package.json && vsce package --no-dependencies; sed -i '' 's/\"name\": \"hanzo-extension\"/\"name\": \"@hanzo\\/extension\"/' package.json",
|
||||
"package:claude": "npm run build:claude-desktop",
|
||||
"package:dxt": "npm run build:dxt",
|
||||
"publish": "npm run package && npx vsce publish && ovsx publish",
|
||||
"publish:npm": "npm run build:claude-desktop && cd dist/claude-desktop && npm publish",
|
||||
"dev": "cross-env VSCODE_ENV=development npm run watch",
|
||||
"dev:mcp": "cross-env MCP_TRANSPORT=stdio node ./out/mcp-server-standalone.js",
|
||||
"start:prod": "cross-env VSCODE_ENV=production npm run watch",
|
||||
"preview": "npx serve . -p 3000",
|
||||
"deploy": "vercel --prod",
|
||||
"deploy:preview": "vercel"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/inquirer": "^9.0.8",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"@types/sinon": "^17.0.4",
|
||||
"@types/sinonjs__fake-timers": "^8.1.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitest/coverage-v8": "^0.34.6",
|
||||
"@vscode/test-cli": "^0.0.11",
|
||||
"@vscode/test-electron": "^2.5.2",
|
||||
"@vscode/vsce": "^2.24.0",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"c8": "^10.1.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.25.6",
|
||||
"eslint": "^8.26.0",
|
||||
"glob": "^10.3.10",
|
||||
"jsdom": "^26.1.0",
|
||||
"mocha": "^11.0.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"sinon": "^21.0.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^0.34.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lancedb/lancedb": "^0.21.0",
|
||||
"@modelcontextprotocol/sdk": "^1.14.0",
|
||||
"@supabase/supabase-js": "^2.48.1",
|
||||
"@typescript-eslint/typescript-estree": "^8.35.1",
|
||||
"axios": "^1.6.2",
|
||||
"ignore": "^7.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"minimatch": "^10.0.1",
|
||||
"rxdb": "^16.15.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter-javascript": "^0.23.1",
|
||||
"tree-sitter-typescript": "^0.23.2",
|
||||
"zod": "^3.25.71"
|
||||
},
|
||||
"__metadata": {
|
||||
"id": "fd0ca99f-75f0-4318-af8c-660c5e883c16",
|
||||
"publisherId": "c3a25647-333f-4954-a3a7-a5487b656f72",
|
||||
"publisherDisplayName": "hanzo-ai",
|
||||
"targetPlatform": "undefined",
|
||||
"isApplicationScoped": false,
|
||||
"isPreReleaseVersion": false,
|
||||
"hasPreReleaseVersion": false,
|
||||
"installedTimestamp": 1743812550788,
|
||||
"pinned": false,
|
||||
"preRelease": false,
|
||||
"source": "gallery",
|
||||
"size": 15245886
|
||||
}
|
||||
}
|
||||
"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.14",
|
||||
"publisher": "hanzo-ai",
|
||||
"private": false,
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/dev.git"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.85.0"
|
||||
},
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"keywords": [
|
||||
"productivity",
|
||||
"AI",
|
||||
"IDE",
|
||||
"project management",
|
||||
"context management",
|
||||
"change tracking",
|
||||
"knowledge base",
|
||||
"documentation",
|
||||
"specification",
|
||||
"analysis"
|
||||
],
|
||||
"icon": "images/icon.png",
|
||||
"galleryBanner": {
|
||||
"color": "#C80000",
|
||||
"theme": "dark"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onStartupFinished",
|
||||
"onCommand:hanzo.openManager",
|
||||
"onCommand:hanzo.openWelcomeGuide",
|
||||
"onCommand:hanzo.reanalyzeProject",
|
||||
"onCommand:hanzo.triggerReminder",
|
||||
"onCommand:hanzo.login",
|
||||
"onCommand:hanzo.logout",
|
||||
"onCommand:hanzo.debug.authState",
|
||||
"onCommand:hanzo.debug.clearAuth",
|
||||
"onCommand:hanzo.checkMetrics",
|
||||
"onCommand:hanzo.resetMetrics"
|
||||
],
|
||||
"main": "./out/extension.js",
|
||||
"contributes": {
|
||||
"chatParticipants": [
|
||||
{
|
||||
"id": "hanzo",
|
||||
"name": "Hanzo",
|
||||
"description": "Ultimate AI engineering toolkit: 200+ LLMs, 4000+ MCP servers, 53+ dev tools",
|
||||
"isSticky": true
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"command": "hanzo.openManager",
|
||||
"title": "Hanzo: Open Project Manager"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.openWelcomeGuide",
|
||||
"title": "Hanzo: View Getting Started Guide"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.reanalyzeProject",
|
||||
"title": "Hanzo: Analyze Project",
|
||||
"icon": "$(refresh)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.triggerReminder",
|
||||
"title": "Hanzo: Show Reminder",
|
||||
"icon": "$(bell)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.checkMetrics",
|
||||
"title": "Hanzo Debug: Check Impact Metrics",
|
||||
"icon": "$(graph)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.resetMetrics",
|
||||
"title": "Hanzo Debug: Reset Impact Metrics",
|
||||
"icon": "$(trash)"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.login",
|
||||
"title": "Hanzo: Login"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.logout",
|
||||
"title": "Hanzo: Logout"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.debug.authState",
|
||||
"title": "Hanzo Debug: Check Auth State",
|
||||
"enablement": "isDevelopment"
|
||||
},
|
||||
{
|
||||
"command": "hanzo.debug.clearAuth",
|
||||
"title": "Hanzo Debug: Clear Auth Data",
|
||||
"enablement": "isDevelopment"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "Hanzo AI Context Manager",
|
||||
"properties": {
|
||||
"hanzo.ide": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"cursor",
|
||||
"copilot",
|
||||
"continue",
|
||||
"codium"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Using Cursor IDE (.cursorrules)",
|
||||
"Using GitHub Copilot (.github/copilot-instructions.md)",
|
||||
"Using Continue (.continuerules)",
|
||||
"Using Codium (.windsurfrules)"
|
||||
],
|
||||
"default": "cursor",
|
||||
"description": "Select which IDE to generate rules for"
|
||||
},
|
||||
"hanzo.mcp.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable Model Context Protocol (MCP) server integration"
|
||||
},
|
||||
"hanzo.mcp.transport": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stdio",
|
||||
"tcp"
|
||||
],
|
||||
"default": "stdio",
|
||||
"description": "Transport method for MCP server communication"
|
||||
},
|
||||
"hanzo.mcp.port": {
|
||||
"type": "number",
|
||||
"default": 3000,
|
||||
"description": "Port for MCP server when using TCP transport"
|
||||
},
|
||||
"hanzo.mcp.backend": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"auto",
|
||||
"python",
|
||||
"typescript",
|
||||
"rust",
|
||||
"local-node"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Auto-detect: prefer Python (uvx) if available, fallback to TypeScript",
|
||||
"Python MCP via uvx (recommended, most comprehensive tools)",
|
||||
"Built-in TypeScript MCP server",
|
||||
"Rust MCP binary (hanzo-mcp)",
|
||||
"Connect to local hanzod node"
|
||||
],
|
||||
"default": "auto",
|
||||
"description": "MCP backend to use. Python via uvx is recommended for the most comprehensive tool support."
|
||||
},
|
||||
"hanzo.mcp.pythonCommand": {
|
||||
"type": "string",
|
||||
"default": "uvx hanzo-mcp",
|
||||
"description": "Command to run Python MCP server (e.g., 'uvx hanzo-mcp' or 'python -m hanzo_mcp')"
|
||||
},
|
||||
"hanzo.mcp.rustBinary": {
|
||||
"type": "string",
|
||||
"default": "hanzo-mcp",
|
||||
"description": "Path to Rust MCP binary (when backend is 'rust')"
|
||||
},
|
||||
"hanzo.mcp.localNodeUrl": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:8080",
|
||||
"description": "URL of local hanzod node (when backend is 'local-node')"
|
||||
},
|
||||
"hanzo.mcp.allowedPaths": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [],
|
||||
"description": "Paths that MCP tools are allowed to access"
|
||||
},
|
||||
"hanzo.mcp.disableWriteTools": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Disable all write operations in MCP tools"
|
||||
},
|
||||
"hanzo.mcp.disableSearchTools": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Disable all search operations in MCP tools"
|
||||
},
|
||||
"hanzo.mcp.disableBrowserTool": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Disable the built-in browser tool (useful when using Antigravity's native browser)"
|
||||
},
|
||||
"hanzo.mcp.enabledTools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [],
|
||||
"description": "List of explicitly enabled MCP tools"
|
||||
},
|
||||
"hanzo.mcp.disabledTools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": [],
|
||||
"description": "List of explicitly disabled MCP tools"
|
||||
},
|
||||
"hanzo.api.endpoint": {
|
||||
"type": "string",
|
||||
"default": "https://api.hanzo.ai/ext/v1",
|
||||
"description": "API endpoint for Hanzo services"
|
||||
},
|
||||
"hanzo.debug": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable debug logging"
|
||||
},
|
||||
"hanzo.llm.provider": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"hanzo",
|
||||
"lmstudio",
|
||||
"ollama",
|
||||
"openai",
|
||||
"anthropic"
|
||||
],
|
||||
"default": "hanzo",
|
||||
"description": "LLM provider to use for AI features"
|
||||
},
|
||||
"hanzo.llm.hanzo.apiKey": {
|
||||
"type": "string",
|
||||
"description": "API key for Hanzo AI Gateway"
|
||||
},
|
||||
"hanzo.llm.lmstudio.endpoint": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:1234/v1",
|
||||
"description": "LM Studio API endpoint"
|
||||
},
|
||||
"hanzo.llm.lmstudio.model": {
|
||||
"type": "string",
|
||||
"description": "Model to use in LM Studio"
|
||||
},
|
||||
"hanzo.llm.ollama.endpoint": {
|
||||
"type": "string",
|
||||
"default": "http://localhost:11434",
|
||||
"description": "Ollama API endpoint"
|
||||
},
|
||||
"hanzo.llm.ollama.model": {
|
||||
"type": "string",
|
||||
"default": "llama2",
|
||||
"description": "Model to use in Ollama"
|
||||
},
|
||||
"hanzo.llm.openai.apiKey": {
|
||||
"type": "string",
|
||||
"description": "OpenAI API key"
|
||||
},
|
||||
"hanzo.llm.openai.model": {
|
||||
"type": "string",
|
||||
"default": "gpt-4",
|
||||
"description": "OpenAI model to use"
|
||||
},
|
||||
"hanzo.llm.anthropic.apiKey": {
|
||||
"type": "string",
|
||||
"description": "Anthropic API key"
|
||||
},
|
||||
"hanzo.llm.anthropic.model": {
|
||||
"type": "string",
|
||||
"default": "claude-3-opus-20240229",
|
||||
"description": "Anthropic model to use"
|
||||
}
|
||||
}
|
||||
},
|
||||
"menus": {
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "hanzo.reanalyzeProject",
|
||||
"group": "navigation",
|
||||
"when": "resourceScheme != extension"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "node scripts/compile-main.js || true && npm run build:mcp",
|
||||
"compile": "tsc -p ./",
|
||||
"watch": "tsc -watch -p ./",
|
||||
"pretest": "npm run compile && npm run lint",
|
||||
"lint": "eslint .",
|
||||
"test": "node ./out/test/runTest.js",
|
||||
"test:simple": "node test-simple.js",
|
||||
"test:pm": "cross-env MOCHA_TEST_FILE=\"ProjectManager Test Suite\" node ./out/test/runTest.js",
|
||||
"test:auth": "cross-env MOCHA_TEST_FILE=\"Auth and API Test Suite\" node ./out/test/runTest.js",
|
||||
"test:file-collection": "cross-env MOCHA_TEST_FILE=\"FileCollectionService Test Suite\" node ./out/test/runTest.js",
|
||||
"test:mcp": "cross-env MOCHA_TEST_FILE=\"MCP.*Tool Test Suite\" node ./out/test/runTest.js",
|
||||
"test:mcp-proxy": "cross-env MOCHA_TEST_FILE=\"MCP.*Proxy Test\" node ./out/test/runTest.js",
|
||||
"test:mcp-integration": "cross-env MOCHA_TEST_FILE=\"MCP Proxy Integration\" node ./out/test/runTest.js",
|
||||
"test:coverage": "c8 npm test",
|
||||
"test:vitest": "vitest",
|
||||
"test:vitest:coverage": "vitest --coverage",
|
||||
"test:vitest:run": "vitest run --coverage",
|
||||
"build:mcp": "node scripts/build-mcp-standalone.js",
|
||||
"build:claude-desktop": "npm run build:mcp && node scripts/build-claude-desktop.js",
|
||||
"build": "tsc -p ./",
|
||||
"build:dxt": "node scripts/build-dxt.js",
|
||||
"build:npm": "node scripts/build-mcp-npm.js",
|
||||
"build:cursor": "node scripts/build-cursor.js",
|
||||
"build:windsurf": "node scripts/build-windsurf.js",
|
||||
"build:all": "node scripts/build-all-platforms.js",
|
||||
"build:browser-extension": "node src/browser-extension/build.js",
|
||||
"package": "npm run build:all && sed -i '' 's/\"name\": \"@hanzo\\/extension\"/\"name\": \"hanzo-extension\"/' package.json && vsce package --no-dependencies; sed -i '' 's/\"name\": \"hanzo-extension\"/\"name\": \"@hanzo\\/extension\"/' package.json",
|
||||
"package:claude": "npm run build:claude-desktop",
|
||||
"package:dxt": "npm run build:dxt",
|
||||
"publish": "npm run package && npx vsce publish && ovsx publish",
|
||||
"publish:npm": "npm run build:claude-desktop && cd dist/claude-desktop && npm publish",
|
||||
"dev": "cross-env VSCODE_ENV=development npm run watch",
|
||||
"dev:mcp": "cross-env MCP_TRANSPORT=stdio node ./out/mcp-server-standalone.js",
|
||||
"start:prod": "cross-env VSCODE_ENV=production npm run watch",
|
||||
"preview": "npx serve . -p 3000",
|
||||
"deploy": "vercel --prod",
|
||||
"deploy:preview": "vercel"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/inquirer": "^9.0.8",
|
||||
"@types/js-yaml": "^4.0.5",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"@types/sinon": "^17.0.4",
|
||||
"@types/sinonjs__fake-timers": "^8.1.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/vscode": "^1.85.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vitest/coverage-v8": "^0.34.6",
|
||||
"@vscode/test-cli": "^0.0.11",
|
||||
"@vscode/test-electron": "^2.5.2",
|
||||
"@vscode/vsce": "^2.24.0",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"c8": "^10.1.3",
|
||||
"cross-env": "^7.0.3",
|
||||
"esbuild": "^0.25.6",
|
||||
"eslint": "^8.26.0",
|
||||
"glob": "^10.3.10",
|
||||
"jsdom": "^26.1.0",
|
||||
"mocha": "^11.0.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"sinon": "^21.0.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vitest": "^0.34.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lancedb/lancedb": "^0.21.0",
|
||||
"@modelcontextprotocol/sdk": "^1.14.0",
|
||||
"@supabase/supabase-js": "^2.48.1",
|
||||
"@typescript-eslint/typescript-estree": "^8.35.1",
|
||||
"axios": "^1.6.2",
|
||||
"ignore": "^7.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"minimatch": "^10.0.1",
|
||||
"rxdb": "^16.15.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"tree-sitter": "^0.21.1",
|
||||
"tree-sitter-javascript": "^0.23.1",
|
||||
"tree-sitter-typescript": "^0.23.2",
|
||||
"zod": "^3.25.71"
|
||||
},
|
||||
"__metadata": {
|
||||
"id": "fd0ca99f-75f0-4318-af8c-660c5e883c16",
|
||||
"publisherId": "c3a25647-333f-4954-a3a7-a5487b656f72",
|
||||
"publisherDisplayName": "hanzo-ai",
|
||||
"targetPlatform": "undefined",
|
||||
"isApplicationScoped": false,
|
||||
"isPreReleaseVersion": false,
|
||||
"hasPreReleaseVersion": false,
|
||||
"installedTimestamp": 1743812550788,
|
||||
"pinned": false,
|
||||
"preRelease": false,
|
||||
"source": "gallery",
|
||||
"size": 15245886
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ interface AuthToken {
|
||||
}
|
||||
|
||||
interface CasdoorConfig {
|
||||
endpoint: string;
|
||||
endpoint: string; // Casdoor API (iam.hanzo.ai)
|
||||
loginUrl: string; // Login UI (hanzo.id)
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
applicationName: string;
|
||||
@@ -38,9 +39,10 @@ export class StandaloneAuthManager {
|
||||
this.tokenFile = path.join(this.configDir, 'auth.json');
|
||||
this.deviceIdFile = path.join(this.configDir, 'device.json');
|
||||
|
||||
// Casdoor configuration for iam.hanzo.ai
|
||||
// Casdoor configuration: login UI on hanzo.id, API on iam.hanzo.ai
|
||||
this.casdoorConfig = {
|
||||
endpoint: process.env.HANZO_IAM_ENDPOINT || 'https://iam.hanzo.ai',
|
||||
loginUrl: process.env.HANZO_IAM_LOGIN_URL || 'https://hanzo.id',
|
||||
clientId: process.env.HANZO_IAM_CLIENT_ID || 'hanzo-mcp',
|
||||
clientSecret: process.env.HANZO_IAM_CLIENT_SECRET,
|
||||
applicationName: 'hanzo-mcp',
|
||||
@@ -102,7 +104,7 @@ export class StandaloneAuthManager {
|
||||
|
||||
private async refreshToken(refreshToken: string): Promise<AuthToken | null> {
|
||||
try {
|
||||
const response = await axios.post(`${this.casdoorConfig.endpoint}/api/refresh-token`, {
|
||||
const response = await axios.post(`${this.casdoorConfig.endpoint}/oauth/token`, {
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: this.casdoorConfig.clientId,
|
||||
@@ -160,50 +162,44 @@ export class StandaloneAuthManager {
|
||||
const deviceId = this.getDeviceId();
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Build OAuth URL for Casdoor
|
||||
const authUrl = new URL(`${this.casdoorConfig.endpoint}/login/oauth/authorize`);
|
||||
// Build OAuth URL — Authorization Code + PKCE (OAuth 2.1 standard)
|
||||
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
||||
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
|
||||
const authUrl = new URL(`${this.casdoorConfig.loginUrl}/oauth/authorize`);
|
||||
authUrl.searchParams.append('client_id', this.casdoorConfig.clientId);
|
||||
authUrl.searchParams.append('response_type', 'code');
|
||||
authUrl.searchParams.append('redirect_uri', 'http://localhost:8765/callback');
|
||||
authUrl.searchParams.append('scope', 'read write');
|
||||
authUrl.searchParams.append('state', state);
|
||||
authUrl.searchParams.append('code_challenge', codeChallenge);
|
||||
authUrl.searchParams.append('code_challenge_method', 'S256');
|
||||
authUrl.searchParams.append('device_id', deviceId);
|
||||
|
||||
// Start local callback server
|
||||
const callbackServer = await this.startCallbackServer(state);
|
||||
|
||||
const callbackResult = await this.startCallbackServer(state, codeVerifier);
|
||||
|
||||
// Open browser
|
||||
console.log('Opening browser for authentication...');
|
||||
this.openBrowser(authUrl.toString());
|
||||
|
||||
// Wait for callback
|
||||
const authCode = await callbackServer;
|
||||
|
||||
if (!authCode) {
|
||||
console.error('Authentication failed: No authorization code received');
|
||||
const result = await callbackResult;
|
||||
|
||||
if (!result) {
|
||||
console.error('Authentication failed: No token received');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
const tokenResponse = await axios.post(`${this.casdoorConfig.endpoint}/api/login/oauth/access_token`, {
|
||||
grant_type: 'authorization_code',
|
||||
code: authCode,
|
||||
client_id: this.casdoorConfig.clientId,
|
||||
client_secret: this.casdoorConfig.clientSecret,
|
||||
redirect_uri: 'http://localhost:8765/callback'
|
||||
});
|
||||
const token: AuthToken = {
|
||||
token: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
expiresAt: result.expiresAt || Date.now() + 168 * 3600 * 1000,
|
||||
};
|
||||
|
||||
if (tokenResponse.data?.access_token) {
|
||||
const token: AuthToken = {
|
||||
token: tokenResponse.data.access_token,
|
||||
refreshToken: tokenResponse.data.refresh_token,
|
||||
expiresAt: Date.now() + (tokenResponse.data.expires_in || 3600) * 1000
|
||||
};
|
||||
|
||||
await this.storeToken(token);
|
||||
console.log('\n✅ Authentication successful!\n');
|
||||
return true;
|
||||
}
|
||||
await this.storeToken(token);
|
||||
console.log('\n✅ Authentication successful!\n');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Authentication failed:', error);
|
||||
}
|
||||
@@ -211,54 +207,85 @@ export class StandaloneAuthManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
private async startCallbackServer(expectedState: string): Promise<string | null> {
|
||||
private async startCallbackServer(expectedState: string, codeVerifier?: string): Promise<{ accessToken: string; refreshToken?: string; expiresAt?: number } | null> {
|
||||
const endpoint = this.casdoorConfig.endpoint;
|
||||
const clientId = this.casdoorConfig.clientId;
|
||||
const clientSecret = this.casdoorConfig.clientSecret;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const http = require('http');
|
||||
const url = require('url');
|
||||
|
||||
const server = http.createServer((req: any, res: any) => {
|
||||
|
||||
const successHtml = `<html><body style="font-family:system-ui;text-align:center;padding:50px">
|
||||
<h1 style="color:#10b981">Authentication Successful!</h1>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(()=>window.close(),3000)</script></body></html>`;
|
||||
|
||||
const server = http.createServer(async (req: any, res: any) => {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
|
||||
if (parsedUrl.pathname === '/callback') {
|
||||
const code = parsedUrl.query.code;
|
||||
const state = parsedUrl.query.state;
|
||||
|
||||
if (state === expectedState && code) {
|
||||
if (parsedUrl.pathname !== '/callback') return;
|
||||
|
||||
const state = parsedUrl.query.state;
|
||||
if (state !== expectedState) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end('<h1>Authentication Failed</h1><p>Invalid state.</p>');
|
||||
server.close();
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = parsedUrl.query.code;
|
||||
const directToken = parsedUrl.query.access_token;
|
||||
|
||||
if (code && codeVerifier) {
|
||||
// Authorization Code + PKCE exchange
|
||||
try {
|
||||
const body: Record<string, string> = {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: clientId,
|
||||
code: code as string,
|
||||
redirect_uri: 'http://localhost:8765/callback',
|
||||
code_verifier: codeVerifier,
|
||||
};
|
||||
if (clientSecret) body.client_secret = clientSecret;
|
||||
|
||||
const tokenResp = await axios.post(`${endpoint}/oauth/token`, body);
|
||||
const tokens = tokenResp.data;
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Successful</title>
|
||||
<style>
|
||||
body { font-family: system-ui; text-align: center; padding: 50px; }
|
||||
h1 { color: #10b981; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>✅ Authentication Successful!</h1>
|
||||
<p>You can now close this window and return to your terminal.</p>
|
||||
<script>window.setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
res.end(successHtml);
|
||||
server.close();
|
||||
resolve(code as string);
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end('<h1>Authentication Failed</h1><p>Invalid state or missing code.</p>');
|
||||
resolve({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1000 : undefined,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.writeHead(500, { 'Content-Type': 'text/html' });
|
||||
res.end(`<h1>Token Exchange Failed</h1><p>${err.message}</p>`);
|
||||
server.close();
|
||||
resolve(null);
|
||||
}
|
||||
} else if (directToken) {
|
||||
// Implicit flow fallback
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(successHtml);
|
||||
server.close();
|
||||
const expiresIn = parsedUrl.query.expires_in ? parseInt(parsedUrl.query.expires_in, 10) : undefined;
|
||||
resolve({
|
||||
accessToken: directToken as string,
|
||||
refreshToken: parsedUrl.query.refresh_token as string | undefined,
|
||||
expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : undefined,
|
||||
});
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end('<h1>Authentication Failed</h1><p>No code or token received.</p>');
|
||||
server.close();
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
server.listen(8765, 'localhost');
|
||||
|
||||
// Timeout after 5 minutes
|
||||
setTimeout(() => {
|
||||
server.close();
|
||||
resolve(null);
|
||||
}, 300000);
|
||||
setTimeout(() => { server.close(); resolve(null); }, 300000);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ export class BrowserExtensionServer extends EventEmitter {
|
||||
return null;
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.wss.close();
|
||||
public close(callback?: () => void) {
|
||||
this.wss.close(callback);
|
||||
}
|
||||
}
|
||||
@@ -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: {}
|
||||
|
||||