feat: pluggable AI backend with Ollama, LM Studio, Hanzo Desktop, and custom endpoints
- Add ollama-client.ts: full Ollama API client (discover, chat, stream, pull, embed, delete) - Add ai-provider.ts: pluggable provider registry with interface for cloud and local backends - HanzoCloudProvider: llm.hanzo.ai with auth token - OllamaNativeProvider: Ollama native API with port scanning - OpenAICompatibleProvider: LM Studio, Hanzo Desktop, any custom endpoint - AIProviderRegistry: discover all, list models, auto-route chat - Add NextJS-specific audit (meta tags, OG, Twitter Card, JSON-LD, sitemap, robots.txt) - Add debugger mode (collect errors, network failures, performance issues, recommendations) - Add background handlers: ollama.*, local.*, provider.*, models.list, audit.nextjs, debugger.mode - Add CDP bridge server actions: nextjs_audit, debugger_mode, ollama_*, local_* - Auto-discover Ollama on extension install - Support custom LLM endpoints saved in chrome.storage
This commit is contained in:
@@ -0,0 +1,474 @@
|
||||
// AI Provider — Pluggable backend for chat/completion/embeddings
|
||||
// Supports: Hanzo Cloud, Ollama, LM Studio, Hanzo Desktop, custom OpenAI-compatible endpoints
|
||||
|
||||
export interface AIMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AICompletionOptions {
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
max_tokens?: number;
|
||||
stop?: string[];
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export interface AIModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
size?: number;
|
||||
context_length?: number;
|
||||
capabilities?: string[];
|
||||
}
|
||||
|
||||
export interface AIProviderStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'cloud' | 'local';
|
||||
available: boolean;
|
||||
url?: string;
|
||||
models: AIModel[];
|
||||
requiresAuth: boolean;
|
||||
authenticated?: boolean;
|
||||
}
|
||||
|
||||
export interface AIProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'cloud' | 'local';
|
||||
|
||||
discover(): Promise<boolean>;
|
||||
listModels(): Promise<AIModel[]>;
|
||||
chat(model: string, messages: AIMessage[], options?: AICompletionOptions): Promise<string>;
|
||||
chatStream?(model: string, messages: AIMessage[], onToken: (token: string) => void, options?: AICompletionOptions): Promise<string>;
|
||||
embed?(model: string, input: string): Promise<number[]>;
|
||||
getStatus(): AIProviderStatus;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hanzo Cloud Provider (api.hanzo.ai / llm.hanzo.ai)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class HanzoCloudProvider implements AIProvider {
|
||||
id = 'hanzo-cloud';
|
||||
name = 'Hanzo Cloud';
|
||||
type = 'cloud' as const;
|
||||
private baseUrl = 'https://llm.hanzo.ai/v1';
|
||||
private token: string | null = null;
|
||||
private models: AIModel[] = [];
|
||||
private available = false;
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
async discover(): Promise<boolean> {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||
const resp = await fetch(`${this.baseUrl}/models`, { headers, signal: AbortSignal.timeout(5000) });
|
||||
this.available = resp.ok;
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
this.models = (data.data || []).map((m: any) => ({
|
||||
id: m.id,
|
||||
name: m.id,
|
||||
provider: this.id,
|
||||
}));
|
||||
}
|
||||
return this.available;
|
||||
} catch {
|
||||
this.available = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async listModels(): Promise<AIModel[]> {
|
||||
if (!this.models.length) await this.discover();
|
||||
return this.models;
|
||||
}
|
||||
|
||||
async chat(model: string, messages: AIMessage[], options?: AICompletionOptions): Promise<string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||
const body: any = { model, messages, stream: false };
|
||||
if (options?.temperature !== undefined) body.temperature = options.temperature;
|
||||
if (options?.max_tokens !== undefined) body.max_tokens = options.max_tokens;
|
||||
if (options?.top_p !== undefined) body.top_p = options.top_p;
|
||||
if (options?.stop) body.stop = options.stop;
|
||||
|
||||
const resp = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST', headers, body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`Hanzo Cloud: ${resp.status} ${text}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
return data.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
async embed(model: string, input: string): Promise<number[]> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||
const resp = await fetch(`${this.baseUrl}/embeddings`, {
|
||||
method: 'POST', headers,
|
||||
body: JSON.stringify({ model, input }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Hanzo Cloud embed: ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
return data.data?.[0]?.embedding || [];
|
||||
}
|
||||
|
||||
getStatus(): AIProviderStatus {
|
||||
return {
|
||||
id: this.id, name: this.name, type: this.type,
|
||||
available: this.available, url: this.baseUrl,
|
||||
models: this.models, requiresAuth: true, authenticated: !!this.token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI-Compatible Local Provider (Ollama, LM Studio, Hanzo Desktop, custom)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class OpenAICompatibleProvider implements AIProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
type = 'local' as const;
|
||||
private baseUrl: string;
|
||||
private apiKey: string | null;
|
||||
private models: AIModel[] = [];
|
||||
private available = false;
|
||||
|
||||
constructor(id: string, name: string, baseUrl: string, apiKey?: string) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.apiKey = apiKey || null;
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
const h: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.apiKey) h['Authorization'] = `Bearer ${this.apiKey}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
async discover(): Promise<boolean> {
|
||||
try {
|
||||
const resp = await fetch(`${this.baseUrl}/models`, {
|
||||
headers: this.headers(),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
});
|
||||
this.available = resp.ok;
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
this.models = (data.data || data.models || []).map((m: any) => ({
|
||||
id: m.id || m.name || m.model,
|
||||
name: m.id || m.name || m.model,
|
||||
provider: this.id,
|
||||
size: m.size,
|
||||
}));
|
||||
}
|
||||
return this.available;
|
||||
} catch {
|
||||
this.available = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async listModels(): Promise<AIModel[]> {
|
||||
if (!this.models.length) await this.discover();
|
||||
return this.models;
|
||||
}
|
||||
|
||||
async chat(model: string, messages: AIMessage[], options?: AICompletionOptions): Promise<string> {
|
||||
const body: any = { model, messages, stream: false };
|
||||
if (options?.temperature !== undefined) body.temperature = options.temperature;
|
||||
if (options?.max_tokens !== undefined) body.max_tokens = options.max_tokens;
|
||||
if (options?.top_p !== undefined) body.top_p = options.top_p;
|
||||
if (options?.stop) body.stop = options.stop;
|
||||
|
||||
const resp = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST', headers: this.headers(), body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`${this.name}: ${resp.status} ${text}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
return data.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
async chatStream(model: string, messages: AIMessage[], onToken: (token: string) => void, options?: AICompletionOptions): Promise<string> {
|
||||
const body: any = { model, messages, stream: true };
|
||||
if (options?.temperature !== undefined) body.temperature = options.temperature;
|
||||
if (options?.max_tokens !== undefined) body.max_tokens = options.max_tokens;
|
||||
|
||||
const resp = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST', headers: this.headers(), body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`${this.name}: ${resp.status}`);
|
||||
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let full = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
|
||||
try {
|
||||
const json = JSON.parse(line.slice(6));
|
||||
const content = json.choices?.[0]?.delta?.content;
|
||||
if (content) { onToken(content); full += content; }
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
async embed(model: string, input: string): Promise<number[]> {
|
||||
const resp = await fetch(`${this.baseUrl}/embeddings`, {
|
||||
method: 'POST', headers: this.headers(),
|
||||
body: JSON.stringify({ model, input }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`${this.name} embed: ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
return data.data?.[0]?.embedding || [];
|
||||
}
|
||||
|
||||
getStatus(): AIProviderStatus {
|
||||
return {
|
||||
id: this.id, name: this.name, type: this.type,
|
||||
available: this.available, url: this.baseUrl,
|
||||
models: this.models, requiresAuth: !!this.apiKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ollama-Native Provider (uses Ollama-specific API, not OpenAI compat)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class OllamaNativeProvider implements AIProvider {
|
||||
id = 'ollama';
|
||||
name = 'Ollama';
|
||||
type = 'local' as const;
|
||||
private baseUrl = 'http://localhost:11434';
|
||||
private models: AIModel[] = [];
|
||||
private available = false;
|
||||
|
||||
async discover(): Promise<boolean> {
|
||||
const ports = [11434, 11435, 11436, 11437, 11438, 11439, 11440];
|
||||
for (const port of ports) {
|
||||
try {
|
||||
const resp = await fetch(`http://localhost:${port}`, { signal: AbortSignal.timeout(2000) });
|
||||
if (resp.ok) {
|
||||
const text = await resp.text();
|
||||
if (text.includes('Ollama')) {
|
||||
this.baseUrl = `http://localhost:${port}`;
|
||||
this.available = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch { /* next */ }
|
||||
}
|
||||
this.available = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
async listModels(): Promise<AIModel[]> {
|
||||
try {
|
||||
const resp = await fetch(`${this.baseUrl}/api/tags`, { signal: AbortSignal.timeout(5000) });
|
||||
if (!resp.ok) return [];
|
||||
const data = await resp.json();
|
||||
this.models = (data.models || []).map((m: any) => ({
|
||||
id: m.name, name: m.name, provider: this.id,
|
||||
size: m.size,
|
||||
capabilities: m.details?.families || [],
|
||||
}));
|
||||
return this.models;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async chat(model: string, messages: AIMessage[], options?: AICompletionOptions): Promise<string> {
|
||||
const body: any = { model, messages, stream: false };
|
||||
if (options?.temperature !== undefined) body.options = { ...body.options, temperature: options.temperature };
|
||||
if (options?.max_tokens !== undefined) body.options = { ...body.options, num_predict: options.max_tokens };
|
||||
if (options?.top_p !== undefined) body.options = { ...body.options, top_p: options.top_p };
|
||||
|
||||
const resp = await fetch(`${this.baseUrl}/api/chat`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body), signal: AbortSignal.timeout(120000),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Ollama: ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
return data.message?.content || '';
|
||||
}
|
||||
|
||||
async chatStream(model: string, messages: AIMessage[], onToken: (token: string) => void, options?: AICompletionOptions): Promise<string> {
|
||||
const body: any = { model, messages, stream: true };
|
||||
if (options?.temperature !== undefined) body.options = { ...body.options, temperature: options.temperature };
|
||||
if (options?.max_tokens !== undefined) body.options = { ...body.options, num_predict: options.max_tokens };
|
||||
|
||||
const resp = await fetch(`${this.baseUrl}/api/chat`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Ollama stream: ${resp.status}`);
|
||||
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) throw new Error('No body');
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '', full = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.message?.content) { onToken(json.message.content); full += json.message.content; }
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
return full;
|
||||
}
|
||||
|
||||
async embed(model: string, input: string): Promise<number[]> {
|
||||
const resp = await fetch(`${this.baseUrl}/api/embeddings`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, prompt: input }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Ollama embed: ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
return data.embedding || [];
|
||||
}
|
||||
|
||||
getStatus(): AIProviderStatus {
|
||||
return {
|
||||
id: this.id, name: this.name, type: this.type,
|
||||
available: this.available, url: this.baseUrl,
|
||||
models: this.models, requiresAuth: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class AIProviderRegistry {
|
||||
private providers: Map<string, AIProvider> = new Map();
|
||||
|
||||
register(provider: AIProvider): void {
|
||||
this.providers.set(provider.id, provider);
|
||||
}
|
||||
|
||||
get(id: string): AIProvider | undefined {
|
||||
return this.providers.get(id);
|
||||
}
|
||||
|
||||
getAll(): AIProvider[] {
|
||||
return Array.from(this.providers.values());
|
||||
}
|
||||
|
||||
async discoverAll(): Promise<AIProviderStatus[]> {
|
||||
const results = await Promise.allSettled(
|
||||
this.getAll().map(async (p) => {
|
||||
await p.discover();
|
||||
return p.getStatus();
|
||||
})
|
||||
);
|
||||
return results
|
||||
.filter((r): r is PromiseFulfilledResult<AIProviderStatus> => r.status === 'fulfilled')
|
||||
.map(r => r.value);
|
||||
}
|
||||
|
||||
async listAllModels(): Promise<AIModel[]> {
|
||||
const results = await Promise.allSettled(
|
||||
this.getAll().map(p => p.listModels())
|
||||
);
|
||||
return results
|
||||
.filter((r): r is PromiseFulfilledResult<AIModel[]> => r.status === 'fulfilled')
|
||||
.flatMap(r => r.value);
|
||||
}
|
||||
|
||||
async chat(providerId: string, model: string, messages: AIMessage[], options?: AICompletionOptions): Promise<string> {
|
||||
const provider = this.providers.get(providerId);
|
||||
if (!provider) throw new Error(`Provider "${providerId}" not found`);
|
||||
return provider.chat(model, messages, options);
|
||||
}
|
||||
|
||||
// Auto-select: try preferred provider, fall back through available ones
|
||||
async autoChat(model: string, messages: AIMessage[], preferredProvider?: string, options?: AICompletionOptions): Promise<{ content: string; provider: string }> {
|
||||
// Try preferred first
|
||||
if (preferredProvider) {
|
||||
const p = this.providers.get(preferredProvider);
|
||||
if (p) {
|
||||
const status = p.getStatus();
|
||||
if (status.available) {
|
||||
const content = await p.chat(model, messages, options);
|
||||
return { content, provider: p.id };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try each available provider
|
||||
for (const p of this.getAll()) {
|
||||
const status = p.getStatus();
|
||||
if (!status.available) continue;
|
||||
const hasModel = status.models.some(m => m.id === model || m.name === model);
|
||||
if (!hasModel && status.models.length > 0) continue;
|
||||
try {
|
||||
const content = await p.chat(model, messages, options);
|
||||
return { content, provider: p.id };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
throw new Error(`No available provider for model "${model}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default registry with built-in providers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function createDefaultRegistry(): AIProviderRegistry {
|
||||
const registry = new AIProviderRegistry();
|
||||
|
||||
// Cloud
|
||||
registry.register(new HanzoCloudProvider());
|
||||
|
||||
// Local
|
||||
registry.register(new OllamaNativeProvider());
|
||||
registry.register(new OpenAICompatibleProvider('lmstudio', 'LM Studio', 'http://localhost:1234/v1'));
|
||||
registry.register(new OpenAICompatibleProvider('hanzo-desktop', 'Hanzo Desktop', 'http://localhost:11435/v1'));
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
export const defaultRegistry = createDefaultRegistry();
|
||||
@@ -340,6 +340,233 @@ export async function runBestPracticesAudit(tabId: number): Promise<AuditResult>
|
||||
}
|
||||
}
|
||||
|
||||
// --- Next.js Audit ---
|
||||
|
||||
export async function runNextJSAudit(tabId: number): Promise<AuditResult> {
|
||||
const url = await evaluate(tabId, 'window.location.href')
|
||||
const issues: AuditIssue[] = []
|
||||
|
||||
// Detect Next.js
|
||||
const isNextJS = await evaluate(
|
||||
tabId,
|
||||
`!!(window.__NEXT_DATA__ || document.querySelector('script[src*="/_next/"]') || document.querySelector('script#__NEXT_DATA__'))`
|
||||
)
|
||||
|
||||
if (!isNextJS) {
|
||||
return {
|
||||
score: -1,
|
||||
category: 'nextjs',
|
||||
timestamp: Date.now(),
|
||||
url,
|
||||
issues: [],
|
||||
summary: 'Not a Next.js application',
|
||||
}
|
||||
}
|
||||
|
||||
// Next.js metadata
|
||||
const nextData = await evaluate(
|
||||
tabId,
|
||||
`(()=>{const nd=window.__NEXT_DATA__;if(!nd)return null;return{page:nd.page,buildId:nd.buildId,runtimeConfig:!!nd.runtimeConfig,locale:nd.locale,props:!!nd.props}})()`,
|
||||
)
|
||||
|
||||
// Full meta tag check
|
||||
const meta = await evaluate(
|
||||
tabId,
|
||||
`(()=>{const gm=(n,a)=>{const s=a||'name';const e=document.querySelector('meta['+s+'="'+n+'"]');return e?e.getAttribute('content'):null};return{title:document.title,titleLen:document.title?.length||0,desc:gm('description'),descLen:(gm('description')||'').length,keywords:gm('keywords'),robots:gm('robots'),viewport:gm('viewport'),charset:!!document.querySelector('meta[charset]'),ogSiteName:gm('og:site_name','property'),ogLocale:gm('og:locale','property'),ogTitle:gm('og:title','property'),ogDesc:gm('og:description','property'),ogImage:gm('og:image','property'),ogUrl:gm('og:url','property'),ogType:gm('og:type','property'),twCard:gm('twitter:card'),twTitle:gm('twitter:title'),twDesc:gm('twitter:description'),twImage:gm('twitter:image'),canonical:document.querySelector('link[rel="canonical"]')?.href||null,jsonld:Array.from(document.querySelectorAll('script[type="application/ld+json"]')).map(s=>{try{return JSON.parse(s.textContent)}catch{return null}}).filter(Boolean)}})()`
|
||||
)
|
||||
|
||||
// Title
|
||||
if (!meta.title) issues.push({ severity: 'critical', message: 'Missing page title', recommendation: 'Add title via next/head or metadata API' })
|
||||
else if (meta.titleLen < 10) issues.push({ severity: 'moderate', message: `Title too short (${meta.titleLen} chars)`, recommendation: 'Use 10-60 characters' })
|
||||
else if (meta.titleLen > 60) issues.push({ severity: 'minor', message: `Title long (${meta.titleLen} chars)`, recommendation: 'Keep under 60 chars' })
|
||||
|
||||
// Description
|
||||
if (!meta.desc) issues.push({ severity: 'serious', message: 'Missing meta description', recommendation: 'Add description via metadata API or next/head' })
|
||||
else if (meta.descLen < 50) issues.push({ severity: 'moderate', message: `Description short (${meta.descLen} chars)`, recommendation: '50-160 characters recommended' })
|
||||
else if (meta.descLen > 160) issues.push({ severity: 'minor', message: `Description long (${meta.descLen} chars)`, recommendation: 'Keep under 160 chars' })
|
||||
|
||||
if (!meta.keywords) issues.push({ severity: 'minor', message: 'Missing meta keywords', recommendation: 'Add keywords meta tag' })
|
||||
if (!meta.robots) issues.push({ severity: 'moderate', message: 'Missing robots meta', recommendation: 'Add robots meta tag (index, follow)' })
|
||||
if (!meta.viewport) issues.push({ severity: 'serious', message: 'Missing viewport meta', recommendation: 'Add viewport meta tag' })
|
||||
if (!meta.charset) issues.push({ severity: 'moderate', message: 'Missing charset', recommendation: 'Add <meta charset="UTF-8">' })
|
||||
|
||||
// Open Graph
|
||||
if (!meta.ogTitle) issues.push({ severity: 'moderate', message: 'Missing og:title', recommendation: 'Add Open Graph title' })
|
||||
if (!meta.ogDesc) issues.push({ severity: 'moderate', message: 'Missing og:description', recommendation: 'Add OG description' })
|
||||
if (!meta.ogImage) issues.push({ severity: 'moderate', message: 'Missing og:image', recommendation: 'Add OG image (1200x630 recommended)' })
|
||||
if (!meta.ogUrl) issues.push({ severity: 'minor', message: 'Missing og:url', recommendation: 'Add og:url for canonical sharing URL' })
|
||||
if (!meta.ogType) issues.push({ severity: 'minor', message: 'Missing og:type', recommendation: 'Add og:type (website, article, etc.)' })
|
||||
if (!meta.ogSiteName) issues.push({ severity: 'minor', message: 'Missing og:site_name', recommendation: 'Add site name for branding' })
|
||||
if (!meta.ogLocale) issues.push({ severity: 'minor', message: 'Missing og:locale', recommendation: 'Add og:locale (e.g., en_US)' })
|
||||
|
||||
// Twitter Card
|
||||
if (!meta.twCard) issues.push({ severity: 'moderate', message: 'Missing twitter:card', recommendation: 'Add twitter:card (summary_large_image)' })
|
||||
if (!meta.twTitle) issues.push({ severity: 'minor', message: 'Missing twitter:title', recommendation: 'Add twitter:title' })
|
||||
if (!meta.twDesc) issues.push({ severity: 'minor', message: 'Missing twitter:description', recommendation: 'Add twitter:description' })
|
||||
if (!meta.twImage) issues.push({ severity: 'minor', message: 'Missing twitter:image', recommendation: 'Add twitter:image' })
|
||||
|
||||
// Canonical
|
||||
if (!meta.canonical) issues.push({ severity: 'moderate', message: 'Missing canonical URL', recommendation: 'Add <link rel="canonical"> or use metadata API' })
|
||||
|
||||
// JSON-LD
|
||||
if (!meta.jsonld || !meta.jsonld.length) {
|
||||
issues.push({ severity: 'moderate', message: 'No JSON-LD structured data', recommendation: 'Add structured data for rich search results' })
|
||||
}
|
||||
|
||||
// Sitemap check (try /sitemap.xml)
|
||||
const origin = await evaluate(tabId, 'window.location.origin')
|
||||
let hasSitemap = false
|
||||
let hasRobots = false
|
||||
try {
|
||||
const sitemapResp = await fetch(`${origin}/sitemap.xml`, { signal: AbortSignal.timeout(3000) })
|
||||
hasSitemap = sitemapResp.ok && (sitemapResp.headers.get('content-type') || '').includes('xml')
|
||||
} catch { /* unreachable from extension */ }
|
||||
if (!hasSitemap) {
|
||||
// Check via page eval
|
||||
hasSitemap = await evaluate(tabId,
|
||||
`fetch('/sitemap.xml',{method:'HEAD'}).then(r=>r.ok).catch(()=>false)`
|
||||
).catch(() => false)
|
||||
}
|
||||
if (!hasSitemap) issues.push({ severity: 'moderate', message: 'No sitemap.xml found', recommendation: 'Generate sitemap via next-sitemap or metadata API' })
|
||||
|
||||
try {
|
||||
hasRobots = await evaluate(tabId,
|
||||
`fetch('/robots.txt',{method:'HEAD'}).then(r=>r.ok).catch(()=>false)`
|
||||
).catch(() => false)
|
||||
} catch { /* */ }
|
||||
if (!hasRobots) issues.push({ severity: 'moderate', message: 'No robots.txt found', recommendation: 'Add robots.txt in public/ directory' })
|
||||
|
||||
const score = scoreFromIssues(issues)
|
||||
return {
|
||||
score,
|
||||
category: 'nextjs',
|
||||
timestamp: Date.now(),
|
||||
url,
|
||||
issues,
|
||||
metrics: {
|
||||
...meta,
|
||||
nextData,
|
||||
hasSitemap,
|
||||
hasRobots,
|
||||
},
|
||||
summary: `Next.js SEO: ${score}/100. ${issues.length} issue(s). Page: ${nextData?.page || 'unknown'}`,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Debugger Mode ---
|
||||
|
||||
export interface DebugReport {
|
||||
possibleSources: string[]
|
||||
consoleErrors: { level: string; text: string; url?: string }[]
|
||||
networkErrors: { url: string; status: number; method: string; error?: string }[]
|
||||
performanceIssues: string[]
|
||||
recommendations: string[]
|
||||
timestamp: number
|
||||
pageUrl: string
|
||||
}
|
||||
|
||||
export async function runDebuggerMode(tabId: number): Promise<DebugReport> {
|
||||
const pageUrl = await evaluate(tabId, 'window.location.href')
|
||||
|
||||
// Collect console errors via Runtime
|
||||
const consoleErrors: DebugReport['consoleErrors'] = []
|
||||
const jsErrors = await evaluate(
|
||||
tabId,
|
||||
`(()=>{const e=[];const orig=console.error;return e})()` // Can't retroactively get console - use page error events
|
||||
)
|
||||
// Check for JS errors via error event and page errors
|
||||
const pageErrors = await evaluate(
|
||||
tabId,
|
||||
`(()=>{const r=[];for(const e of performance.getEntriesByType('resource'))if(e.transferSize===0&&e.duration>0)r.push({url:e.name.slice(0,200),type:'failed-resource'});return r.slice(0,20)})()`
|
||||
)
|
||||
|
||||
// Check for uncaught errors
|
||||
const errorElements = await evaluate(
|
||||
tabId,
|
||||
`(()=>{const errors=[];const ow=document.querySelector('#__next-error,#__next-error-overlay,.nextjs-container-errors-header,[data-nextjs-error]');if(ow)errors.push({level:'error',text:'Next.js error overlay detected'});const re=document.querySelector('[class*="error"],[class*="Error"],[id*="error"]');if(re&&re.textContent)errors.push({level:'error',text:re.textContent.slice(0,200)});return errors})()`
|
||||
)
|
||||
consoleErrors.push(...(errorElements || []))
|
||||
|
||||
// Network errors - check for failed resources
|
||||
const networkErrors: DebugReport['networkErrors'] = []
|
||||
const failedResources = await evaluate(
|
||||
tabId,
|
||||
`(()=>{const r=[];for(const e of performance.getEntriesByType('resource')){if(e.responseStatus&&e.responseStatus>=400)r.push({url:e.name.slice(0,200),status:e.responseStatus,method:'GET'})}return r.slice(0,20)})()`
|
||||
)
|
||||
networkErrors.push(...(failedResources || []))
|
||||
|
||||
// Failed fetch requests visible in page
|
||||
for (const pe of pageErrors || []) {
|
||||
if (pe.type === 'failed-resource') {
|
||||
networkErrors.push({ url: pe.url, status: 0, method: 'GET', error: 'Transfer failed' })
|
||||
}
|
||||
}
|
||||
|
||||
// Performance issues
|
||||
const performanceIssues: string[] = []
|
||||
const perfData = await evaluate(
|
||||
tabId,
|
||||
`(()=>{const n=performance.getEntriesByType('navigation')[0];const r={};if(n){if(n.domContentLoadedEventEnd-n.startTime>3000)r.slowDCL=Math.round(n.domContentLoadedEventEnd-n.startTime)+'ms';if(n.loadEventEnd-n.startTime>5000)r.slowLoad=Math.round(n.loadEventEnd-n.startTime)+'ms';if(n.responseStart-n.requestStart>800)r.slowTTFB=Math.round(n.responseStart-n.requestStart)+'ms'}const res=performance.getEntriesByType('resource');const slow=res.filter(e=>e.duration>2000).length;if(slow>0)r.slowResources=slow;const large=res.filter(e=>e.transferSize>500000).length;if(large>0)r.largeResources=large;r.totalResources=res.length;r.domNodes=document.querySelectorAll('*').length;return r})()`
|
||||
)
|
||||
if (perfData?.slowDCL) performanceIssues.push(`Slow DOM Content Loaded: ${perfData.slowDCL}`)
|
||||
if (perfData?.slowLoad) performanceIssues.push(`Slow page load: ${perfData.slowLoad}`)
|
||||
if (perfData?.slowTTFB) performanceIssues.push(`Slow TTFB: ${perfData.slowTTFB}`)
|
||||
if (perfData?.slowResources) performanceIssues.push(`${perfData.slowResources} resources took > 2s to load`)
|
||||
if (perfData?.largeResources) performanceIssues.push(`${perfData.largeResources} resources > 500KB`)
|
||||
if (perfData?.domNodes > 2000) performanceIssues.push(`Large DOM: ${perfData.domNodes} nodes`)
|
||||
|
||||
// Build possible sources based on collected data
|
||||
const possibleSources: string[] = []
|
||||
if (consoleErrors.length) possibleSources.push(`JavaScript errors detected (${consoleErrors.length} found)`)
|
||||
if (networkErrors.length) possibleSources.push(`Failed network requests (${networkErrors.length} found)`)
|
||||
if (performanceIssues.length) possibleSources.push(`Performance bottlenecks (${performanceIssues.length} found)`)
|
||||
|
||||
// Check for common issues
|
||||
const commonIssues = await evaluate(
|
||||
tabId,
|
||||
`(()=>{const r=[];if(!document.doctype)r.push('Missing DOCTYPE');if(!document.querySelector('meta[charset]'))r.push('Missing charset');if(document.querySelectorAll('script:not([async]):not([defer]):not([type="module"])[src]').length>2)r.push('Multiple render-blocking scripts');if(document.querySelectorAll('link[rel="stylesheet"]').length>5)r.push('Many external stylesheets ('+document.querySelectorAll('link[rel="stylesheet"]').length+')');const mixed=document.querySelectorAll('img[src^="http:"],script[src^="http:"]').length;if(mixed>0)r.push('Mixed content ('+mixed+' HTTP resources on HTTPS)');if(!document.querySelector('meta[name="viewport"]'))r.push('Missing viewport meta tag');return r})()`
|
||||
)
|
||||
possibleSources.push(...(commonIssues || []))
|
||||
|
||||
// Pad to at least 5 suggestions
|
||||
const generic = [
|
||||
'Check browser console for runtime errors',
|
||||
'Inspect network tab for failed API calls',
|
||||
'Look for CORS issues in cross-origin requests',
|
||||
'Check for unhandled promise rejections',
|
||||
'Verify API endpoints return expected data',
|
||||
'Check for CSS layout issues causing visual bugs',
|
||||
'Look for event listener conflicts or race conditions',
|
||||
]
|
||||
while (possibleSources.length < 5) {
|
||||
possibleSources.push(generic[possibleSources.length] || 'Review application logs')
|
||||
}
|
||||
|
||||
// Recommendations
|
||||
const recommendations: string[] = []
|
||||
if (consoleErrors.length) recommendations.push('Fix JavaScript errors first — they often cascade into other failures')
|
||||
if (networkErrors.length) recommendations.push('Investigate failed network requests — check API endpoints, CORS config, and auth tokens')
|
||||
if (performanceIssues.length) recommendations.push('Address performance issues — slow loads compound debugging difficulty')
|
||||
recommendations.push('Add targeted console.log statements near suspected problem areas')
|
||||
recommendations.push('Use browser DevTools Network tab to trace request/response cycles')
|
||||
if (networkErrors.some(e => e.status === 401 || e.status === 403)) {
|
||||
recommendations.push('Auth errors detected — verify tokens and session state')
|
||||
}
|
||||
if (networkErrors.some(e => e.status >= 500)) {
|
||||
recommendations.push('Server errors detected — check server logs for stack traces')
|
||||
}
|
||||
|
||||
return {
|
||||
possibleSources: possibleSources.slice(0, 7),
|
||||
consoleErrors,
|
||||
networkErrors,
|
||||
performanceIssues,
|
||||
recommendations,
|
||||
timestamp: Date.now(),
|
||||
pageUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Full Audit ---
|
||||
|
||||
export async function runFullAudit(
|
||||
@@ -352,10 +579,26 @@ export async function runFullAudit(
|
||||
runBestPracticesAudit(tabId),
|
||||
])
|
||||
|
||||
const overall = Math.round((a11y.score + perf.score + seo.score + bp.score) / 4)
|
||||
const audits = [a11y, perf, seo, bp]
|
||||
let scoreSum = a11y.score + perf.score + seo.score + bp.score
|
||||
let count = 4
|
||||
|
||||
// Include Next.js audit if applicable
|
||||
try {
|
||||
const nextjs = await runNextJSAudit(tabId)
|
||||
if (nextjs.score >= 0) {
|
||||
audits.push(nextjs)
|
||||
scoreSum += nextjs.score
|
||||
count++
|
||||
}
|
||||
} catch {
|
||||
// Not a Next.js app or audit failed
|
||||
}
|
||||
|
||||
const overall = Math.round(scoreSum / count)
|
||||
return {
|
||||
overall,
|
||||
audits: [a11y, perf, seo, bp],
|
||||
summary: `Overall: ${overall}/100 | A11y: ${a11y.score} | Perf: ${perf.score} | SEO: ${seo.score} | Best Practices: ${bp.score}`,
|
||||
audits,
|
||||
summary: audits.map(a => `${a.category}: ${a.score}`).join(' | ') + ` | Overall: ${overall}/100`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
runSEOAudit,
|
||||
runBestPracticesAudit,
|
||||
runFullAudit,
|
||||
runNextJSAudit,
|
||||
runDebuggerMode,
|
||||
} from './audit-runner';
|
||||
import { ollamaClient } from './ollama-client';
|
||||
import {
|
||||
ActionRateLimiter,
|
||||
CHAT_BUDGETS,
|
||||
@@ -643,13 +646,14 @@ async function stopControlSession(): Promise<void> {
|
||||
|
||||
void loadDebugFlagFromStorage();
|
||||
|
||||
// Initialize WebGPU and CDP on install
|
||||
// Initialize WebGPU, Ollama, and CDP on install
|
||||
chrome.runtime.onInstalled.addListener(async () => {
|
||||
debugLog('[Hanzo] Extension installed, initializing...');
|
||||
|
||||
// WebGPU init
|
||||
const gpuAvailable = await webgpuAI.initialize();
|
||||
if (gpuAvailable) {
|
||||
debugLog('[Hanzo] WebGPU available, loading local models...');
|
||||
debugLog('[Hanzo] WebGPU available');
|
||||
try {
|
||||
await webgpuAI.loadModel({
|
||||
name: 'hanzo-browser-control',
|
||||
@@ -658,9 +662,20 @@ chrome.runtime.onInstalled.addListener(async () => {
|
||||
maxTokens: 512
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Hanzo] Failed to load local model:', error);
|
||||
// Model file not bundled yet — will use Ollama/LM Studio/cloud instead
|
||||
debugLog('[Hanzo] Local model not bundled, using remote providers');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-discover local LLM providers (Ollama, LM Studio, Hanzo Desktop)
|
||||
ollamaClient.discover().then((found) => {
|
||||
if (found) debugLog('[Hanzo] Ollama discovered: ' + ollamaClient.getStatus().url);
|
||||
}).catch(() => {});
|
||||
|
||||
// Restore custom Ollama URL from storage
|
||||
chrome.storage.local.get(['ollamaUrl'], (result) => {
|
||||
if (result.ollamaUrl) ollamaClient.setBaseUrl(result.ollamaUrl);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle messages from content scripts and popup
|
||||
@@ -1384,6 +1399,351 @@ async function handleMessage(request: any, sender: chrome.runtime.MessageSender,
|
||||
break;
|
||||
}
|
||||
|
||||
case 'audit.nextjs': {
|
||||
const auditTabId = request.tabId || sender.tab?.id;
|
||||
if (!auditTabId) { sendResponse({ success: false, error: 'No tab' }); break; }
|
||||
try {
|
||||
const result = await runNextJSAudit(auditTabId);
|
||||
sendResponse({ success: true, result });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'audit.debugger':
|
||||
case 'debugger.mode': {
|
||||
const auditTabId = request.tabId || sender.tab?.id;
|
||||
if (!auditTabId) { sendResponse({ success: false, error: 'No tab' }); break; }
|
||||
try {
|
||||
const result = await runDebuggerMode(auditTabId);
|
||||
sendResponse({ success: true, result });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Ollama / Local LLM ---
|
||||
case 'ollama.discover':
|
||||
try {
|
||||
const found = await ollamaClient.discover();
|
||||
sendResponse({ success: true, connected: found, ...ollamaClient.getStatus() });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ollama.health':
|
||||
try {
|
||||
const health = await ollamaClient.healthCheck();
|
||||
sendResponse({ success: true, ...health });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ollama.models':
|
||||
try {
|
||||
const models = await ollamaClient.listModels();
|
||||
sendResponse({ success: true, models });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ollama.pull':
|
||||
try {
|
||||
// Pull is long-running — we track progress via storage
|
||||
ollamaClient.pullModel(request.model, (status, completed, total) => {
|
||||
chrome.storage.local.set({
|
||||
'ollama.pull.progress': { model: request.model, status, completed, total, timestamp: Date.now() },
|
||||
});
|
||||
}).then(() => {
|
||||
chrome.storage.local.set({ 'ollama.pull.progress': { model: request.model, status: 'done', timestamp: Date.now() } });
|
||||
}).catch((e) => {
|
||||
chrome.storage.local.set({ 'ollama.pull.progress': { model: request.model, status: 'error', error: e.message, timestamp: Date.now() } });
|
||||
});
|
||||
sendResponse({ success: true, started: true });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ollama.delete':
|
||||
try {
|
||||
await ollamaClient.deleteModel(request.model);
|
||||
sendResponse({ success: true });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ollama.chat':
|
||||
try {
|
||||
const result = await ollamaClient.chat(
|
||||
request.model,
|
||||
request.messages,
|
||||
request.options
|
||||
);
|
||||
sendResponse({ success: true, content: result });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ollama.generate':
|
||||
try {
|
||||
const result = await ollamaClient.generate(
|
||||
request.model,
|
||||
request.prompt,
|
||||
request.options
|
||||
);
|
||||
sendResponse({ success: true, content: result });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ollama.embed':
|
||||
try {
|
||||
const embedding = await ollamaClient.embed(request.model, request.input);
|
||||
sendResponse({ success: true, embedding });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ollama.status':
|
||||
sendResponse({ success: true, ...ollamaClient.getStatus() });
|
||||
break;
|
||||
|
||||
case 'ollama.setUrl':
|
||||
ollamaClient.setBaseUrl(request.url);
|
||||
sendResponse({ success: true });
|
||||
break;
|
||||
|
||||
// --- Local LLM Provider (Ollama / LM Studio / Hanzo Desktop / Custom) ---
|
||||
case 'local.chat': {
|
||||
// Unified local LLM chat — routes to the configured local provider
|
||||
try {
|
||||
const provider = request.provider || 'ollama';
|
||||
const endpoint = request.endpoint;
|
||||
const model = request.model;
|
||||
const messages = request.messages;
|
||||
const options = request.options || {};
|
||||
|
||||
let result: string;
|
||||
|
||||
if (provider === 'ollama' && !endpoint) {
|
||||
result = await ollamaClient.chat(model, messages, options);
|
||||
} else {
|
||||
// LM Studio, Hanzo Desktop, or custom endpoint — all use OpenAI-compatible API
|
||||
const baseUrl = endpoint || (
|
||||
provider === 'lmstudio' ? 'http://localhost:1234/v1' :
|
||||
provider === 'hanzo-desktop' ? 'http://localhost:11435/v1' :
|
||||
'http://localhost:8080/v1'
|
||||
);
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (request.apiKey) headers['Authorization'] = `Bearer ${request.apiKey}`;
|
||||
|
||||
const body: any = { model, messages, stream: false };
|
||||
if (options.temperature !== undefined) body.temperature = options.temperature;
|
||||
if (options.top_p !== undefined) body.top_p = options.top_p;
|
||||
if (options.num_predict !== undefined) body.max_tokens = options.num_predict;
|
||||
|
||||
const resp = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`${provider} error: ${resp.status} ${text}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
result = data.choices?.[0]?.message?.content || '';
|
||||
}
|
||||
|
||||
sendResponse({ success: true, content: result });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'local.models': {
|
||||
// List models from any OpenAI-compatible local provider
|
||||
try {
|
||||
const endpoint = request.endpoint || (
|
||||
request.provider === 'lmstudio' ? 'http://localhost:1234/v1' :
|
||||
request.provider === 'hanzo-desktop' ? 'http://localhost:11435/v1' :
|
||||
request.provider === 'ollama' ? null :
|
||||
request.endpoint || 'http://localhost:8080/v1'
|
||||
);
|
||||
|
||||
if (!endpoint) {
|
||||
// Use Ollama native
|
||||
const models = await ollamaClient.listModels();
|
||||
sendResponse({ success: true, models: models.map(m => ({ id: m.name, name: m.name, size: m.size, details: m.details })) });
|
||||
break;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (request.apiKey) headers['Authorization'] = `Bearer ${request.apiKey}`;
|
||||
|
||||
const resp = await fetch(`${endpoint}/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
sendResponse({ success: true, models: data.data || data.models || [] });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'local.discover': {
|
||||
// Auto-discover all local LLM providers
|
||||
const providers: { name: string; url: string; available: boolean; models?: string[] }[] = [];
|
||||
|
||||
// Ollama
|
||||
const ollamaFound = await ollamaClient.discover();
|
||||
if (ollamaFound) {
|
||||
const models = await ollamaClient.listModels();
|
||||
providers.push({ name: 'ollama', url: ollamaClient.getStatus().url, available: true, models: models.map(m => m.name) });
|
||||
} else {
|
||||
providers.push({ name: 'ollama', url: 'http://localhost:11434', available: false });
|
||||
}
|
||||
|
||||
// LM Studio (OpenAI-compatible on :1234)
|
||||
try {
|
||||
const resp = await fetch('http://localhost:1234/v1/models', { signal: AbortSignal.timeout(2000) });
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
providers.push({ name: 'lmstudio', url: 'http://localhost:1234/v1', available: true, models: (data.data || []).map((m: any) => m.id) });
|
||||
} else {
|
||||
providers.push({ name: 'lmstudio', url: 'http://localhost:1234/v1', available: false });
|
||||
}
|
||||
} catch {
|
||||
providers.push({ name: 'lmstudio', url: 'http://localhost:1234/v1', available: false });
|
||||
}
|
||||
|
||||
// Hanzo Desktop (Ollama embedded, typically :11435 or node API)
|
||||
for (const port of [11435, 9550]) {
|
||||
try {
|
||||
const resp = await fetch(`http://localhost:${port}/api/tags`, { signal: AbortSignal.timeout(2000) });
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
providers.push({ name: 'hanzo-desktop', url: `http://localhost:${port}`, available: true, models: (data.models || []).map((m: any) => m.name) });
|
||||
break;
|
||||
}
|
||||
} catch { /* next */ }
|
||||
}
|
||||
if (!providers.find(p => p.name === 'hanzo-desktop')) {
|
||||
providers.push({ name: 'hanzo-desktop', url: 'http://localhost:11435', available: false });
|
||||
}
|
||||
|
||||
// Check for custom endpoints saved in storage
|
||||
const stored = await chrome.storage.local.get(['customLLMEndpoints']);
|
||||
const customs: { name: string; url: string; apiKey?: string }[] = stored.customLLMEndpoints || [];
|
||||
for (const c of customs) {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (c.apiKey) headers['Authorization'] = `Bearer ${c.apiKey}`;
|
||||
const resp = await fetch(`${c.url}/models`, { headers, signal: AbortSignal.timeout(3000) });
|
||||
if (resp.ok) {
|
||||
const data = await resp.json();
|
||||
providers.push({ name: c.name || 'custom', url: c.url, available: true, models: (data.data || data.models || []).map((m: any) => m.id || m.name) });
|
||||
} else {
|
||||
providers.push({ name: c.name || 'custom', url: c.url, available: false });
|
||||
}
|
||||
} catch {
|
||||
providers.push({ name: c.name || 'custom', url: c.url, available: false });
|
||||
}
|
||||
}
|
||||
|
||||
sendResponse({ success: true, providers });
|
||||
break;
|
||||
}
|
||||
|
||||
// --- Hanzo Cloud Models ---
|
||||
case 'models.list': {
|
||||
// List all available models from Hanzo Cloud
|
||||
try {
|
||||
const token = await auth.getToken();
|
||||
if (!token) {
|
||||
// Return free/public models
|
||||
sendResponse({
|
||||
success: true,
|
||||
models: [
|
||||
{ id: 'zen-coder-flash', name: 'Zen Coder Flash', provider: 'hanzo', free: true },
|
||||
{ id: 'zen-max', name: 'Zen Max', provider: 'hanzo', free: false },
|
||||
],
|
||||
authenticated: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
const cloudModels = await listModels(token);
|
||||
sendResponse({ success: true, models: cloudModels, authenticated: true });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'provider.save': {
|
||||
// Save a custom LLM endpoint
|
||||
try {
|
||||
const stored = await chrome.storage.local.get(['customLLMEndpoints']);
|
||||
const endpoints: any[] = stored.customLLMEndpoints || [];
|
||||
const existing = endpoints.findIndex(e => e.name === request.name || e.url === request.url);
|
||||
const entry = { name: request.name, url: request.url, apiKey: request.apiKey };
|
||||
if (existing >= 0) {
|
||||
endpoints[existing] = entry;
|
||||
} else {
|
||||
endpoints.push(entry);
|
||||
}
|
||||
await chrome.storage.local.set({ customLLMEndpoints: endpoints });
|
||||
sendResponse({ success: true });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'provider.remove': {
|
||||
try {
|
||||
const stored = await chrome.storage.local.get(['customLLMEndpoints']);
|
||||
const endpoints: any[] = (stored.customLLMEndpoints || []).filter(
|
||||
(e: any) => e.name !== request.name && e.url !== request.url
|
||||
);
|
||||
await chrome.storage.local.set({ customLLMEndpoints: endpoints });
|
||||
sendResponse({ success: true });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'provider.test': {
|
||||
// Test connection to any OpenAI-compatible endpoint
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (request.apiKey) headers['Authorization'] = `Bearer ${request.apiKey}`;
|
||||
const resp = await fetch(`${request.url}/models`, { headers, signal: AbortSignal.timeout(5000) });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
sendResponse({ success: true, models: (data.data || data.models || []).map((m: any) => m.id || m.name) });
|
||||
} catch (e: any) {
|
||||
sendResponse({ success: false, error: e.message });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -503,6 +503,29 @@ class CDPBridgeServer {
|
||||
case 'full_audit':
|
||||
return this.sendToExtension('audit.full', { tabId: rest.tabId });
|
||||
|
||||
case 'nextjs_audit':
|
||||
return this.sendToExtension('audit.nextjs', { tabId: rest.tabId });
|
||||
|
||||
case 'debugger_mode':
|
||||
case 'debug':
|
||||
return this.sendToExtension('debugger.mode', { tabId: rest.tabId });
|
||||
|
||||
// Ollama / Local LLM
|
||||
case 'ollama_models':
|
||||
return this.sendToExtension('ollama.models', {});
|
||||
|
||||
case 'ollama_chat':
|
||||
return this.sendToExtension('ollama.chat', { model: rest.model, messages: rest.messages, options: rest.options });
|
||||
|
||||
case 'local_chat':
|
||||
return this.sendToExtension('local.chat', { provider: rest.provider, endpoint: rest.endpoint, model: rest.model, messages: rest.messages, options: rest.options, apiKey: rest.apiKey });
|
||||
|
||||
case 'local_models':
|
||||
return this.sendToExtension('local.models', { provider: rest.provider, endpoint: rest.endpoint, apiKey: rest.apiKey });
|
||||
|
||||
case 'local_discover':
|
||||
return this.sendToExtension('local.discover', {});
|
||||
|
||||
// Status
|
||||
case 'status':
|
||||
return {
|
||||
@@ -601,6 +624,8 @@ async function startJSONRPCServer(bridgeServer: CDPBridgeServer) {
|
||||
'console_logs', 'console_errors', 'network_logs', 'network_errors', 'network_success',
|
||||
'start_monitoring', 'stop_monitoring', 'monitor_status', 'wipe_logs',
|
||||
'accessibility_audit', 'performance_audit', 'seo_audit', 'best_practices_audit', 'full_audit',
|
||||
'nextjs_audit', 'debugger_mode',
|
||||
'ollama_models', 'ollama_chat', 'local_chat', 'local_models', 'local_discover',
|
||||
'takeover.start', 'takeover.end', 'takeover.cursor',
|
||||
'status'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
// Ollama Client — Connect to local Ollama LLM server
|
||||
// Supports model discovery, chat completion (streaming), embeddings, and model management.
|
||||
|
||||
export interface OllamaModel {
|
||||
name: string;
|
||||
model: string;
|
||||
size: number;
|
||||
digest: string;
|
||||
modified_at: string;
|
||||
details?: {
|
||||
family: string;
|
||||
parameter_size: string;
|
||||
quantization_level: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatOptions {
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
num_predict?: number;
|
||||
stream?: boolean;
|
||||
stop?: string[];
|
||||
}
|
||||
|
||||
export class OllamaClient {
|
||||
private baseUrl: string = 'http://localhost:11434';
|
||||
private connected: boolean = false;
|
||||
private cachedModels: OllamaModel[] = [];
|
||||
private lastModelFetch: number = 0;
|
||||
|
||||
async discover(): Promise<boolean> {
|
||||
const ports = [11434, 11435, 11436, 11437, 11438, 11439, 11440];
|
||||
for (const port of ports) {
|
||||
const url = `http://localhost:${port}`;
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
||||
if (resp.ok) {
|
||||
const text = await resp.text();
|
||||
if (text.includes('Ollama')) {
|
||||
this.baseUrl = url;
|
||||
this.connected = true;
|
||||
console.log(`[Hanzo AI] Ollama discovered at ${url}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// try next port
|
||||
}
|
||||
}
|
||||
// Also try 127.0.0.1 on default port
|
||||
try {
|
||||
const resp = await fetch('http://127.0.0.1:11434', { signal: AbortSignal.timeout(2000) });
|
||||
if (resp.ok) {
|
||||
const text = await resp.text();
|
||||
if (text.includes('Ollama')) {
|
||||
this.baseUrl = 'http://127.0.0.1:11434';
|
||||
this.connected = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// not available
|
||||
}
|
||||
this.connected = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<{ ok: boolean; version?: string }> {
|
||||
try {
|
||||
const resp = await fetch(this.baseUrl, { signal: AbortSignal.timeout(3000) });
|
||||
if (!resp.ok) return { ok: false };
|
||||
// Try version endpoint
|
||||
try {
|
||||
const vResp = await fetch(`${this.baseUrl}/api/version`, { signal: AbortSignal.timeout(3000) });
|
||||
if (vResp.ok) {
|
||||
const data = await vResp.json();
|
||||
this.connected = true;
|
||||
return { ok: true, version: data.version };
|
||||
}
|
||||
} catch {
|
||||
// version endpoint may not exist on older Ollama
|
||||
}
|
||||
this.connected = true;
|
||||
return { ok: true };
|
||||
} catch {
|
||||
this.connected = false;
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
async listModels(): Promise<OllamaModel[]> {
|
||||
// Cache for 10 seconds
|
||||
if (this.cachedModels.length && Date.now() - this.lastModelFetch < 10000) {
|
||||
return this.cachedModels;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch(`${this.baseUrl}/api/tags`, { signal: AbortSignal.timeout(5000) });
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
this.cachedModels = (data.models || []).map((m: any) => ({
|
||||
name: m.name,
|
||||
model: m.model || m.name,
|
||||
size: m.size || 0,
|
||||
digest: m.digest || '',
|
||||
modified_at: m.modified_at || '',
|
||||
details: m.details ? {
|
||||
family: m.details.family || '',
|
||||
parameter_size: m.details.parameter_size || '',
|
||||
quantization_level: m.details.quantization_level || '',
|
||||
} : undefined,
|
||||
}));
|
||||
this.lastModelFetch = Date.now();
|
||||
this.connected = true;
|
||||
return this.cachedModels;
|
||||
} catch (error: any) {
|
||||
console.error('[Hanzo AI] Ollama listModels failed:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async pullModel(
|
||||
name: string,
|
||||
onProgress?: (status: string, completed?: number, total?: number) => void
|
||||
): Promise<void> {
|
||||
const resp = await fetch(`${this.baseUrl}/api/pull`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, stream: true }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`Pull failed: ${resp.status} ${text}`);
|
||||
}
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
onProgress?.(json.status || '', json.completed, json.total);
|
||||
} catch {
|
||||
// malformed line, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
// Invalidate cache
|
||||
this.lastModelFetch = 0;
|
||||
}
|
||||
|
||||
async deleteModel(name: string): Promise<void> {
|
||||
const resp = await fetch(`${this.baseUrl}/api/delete`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`Delete failed: ${resp.status} ${text}`);
|
||||
}
|
||||
this.lastModelFetch = 0;
|
||||
}
|
||||
|
||||
async chat(model: string, messages: ChatMessage[], options?: ChatOptions): Promise<string> {
|
||||
const body: any = {
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
};
|
||||
if (options?.temperature !== undefined) body.options = { ...body.options, temperature: options.temperature };
|
||||
if (options?.top_p !== undefined) body.options = { ...body.options, top_p: options.top_p };
|
||||
if (options?.num_predict !== undefined) body.options = { ...body.options, num_predict: options.num_predict };
|
||||
if (options?.stop) body.options = { ...body.options, stop: options.stop };
|
||||
|
||||
const resp = await fetch(`${this.baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`Chat failed: ${resp.status} ${text}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
return data.message?.content || '';
|
||||
}
|
||||
|
||||
async chatStream(
|
||||
model: string,
|
||||
messages: ChatMessage[],
|
||||
onToken: (token: string) => void,
|
||||
options?: ChatOptions
|
||||
): Promise<string> {
|
||||
const body: any = {
|
||||
model,
|
||||
messages,
|
||||
stream: true,
|
||||
};
|
||||
if (options?.temperature !== undefined) body.options = { ...body.options, temperature: options.temperature };
|
||||
if (options?.top_p !== undefined) body.options = { ...body.options, top_p: options.top_p };
|
||||
if (options?.num_predict !== undefined) body.options = { ...body.options, num_predict: options.num_predict };
|
||||
if (options?.stop) body.options = { ...body.options, stop: options.stop };
|
||||
|
||||
const resp = await fetch(`${this.baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`Chat stream failed: ${resp.status} ${text}`);
|
||||
}
|
||||
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let fullResponse = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const json = JSON.parse(line);
|
||||
if (json.message?.content) {
|
||||
onToken(json.message.content);
|
||||
fullResponse += json.message.content;
|
||||
}
|
||||
if (json.done) break;
|
||||
} catch {
|
||||
// skip malformed
|
||||
}
|
||||
}
|
||||
}
|
||||
return fullResponse;
|
||||
}
|
||||
|
||||
async generate(model: string, prompt: string, options?: ChatOptions): Promise<string> {
|
||||
const body: any = {
|
||||
model,
|
||||
prompt,
|
||||
stream: false,
|
||||
};
|
||||
if (options?.temperature !== undefined) body.options = { ...body.options, temperature: options.temperature };
|
||||
if (options?.num_predict !== undefined) body.options = { ...body.options, num_predict: options.num_predict };
|
||||
|
||||
const resp = await fetch(`${this.baseUrl}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(120000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`Generate failed: ${resp.status} ${text}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
return data.response || '';
|
||||
}
|
||||
|
||||
async embed(model: string, input: string): Promise<number[]> {
|
||||
const resp = await fetch(`${this.baseUrl}/api/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model, prompt: input }),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
throw new Error(`Embed failed: ${resp.status} ${text}`);
|
||||
}
|
||||
const data = await resp.json();
|
||||
return data.embedding || [];
|
||||
}
|
||||
|
||||
setBaseUrl(url: string): void {
|
||||
this.baseUrl = url.replace(/\/$/, '');
|
||||
this.connected = false;
|
||||
this.lastModelFetch = 0;
|
||||
}
|
||||
|
||||
getStatus(): { connected: boolean; url: string; models: string[] } {
|
||||
return {
|
||||
connected: this.connected,
|
||||
url: this.baseUrl,
|
||||
models: this.cachedModels.map(m => m.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const ollamaClient = new OllamaClient();
|
||||
export default OllamaClient;
|
||||
Reference in New Issue
Block a user