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:
Hanzo Dev
2026-03-10 13:35:54 -07:00
parent 6c31b3cc18
commit 19aa67ea73
5 changed files with 1419 additions and 6 deletions
+474
View File
@@ -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();
+246 -3
View File
@@ -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`,
}
}
+363 -3
View File
@@ -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;
}
}
}
+25
View File
@@ -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'
]
+311
View File
@@ -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;