Skip to content

Commit c07e733

Browse files
committed
Onboarding, LLM simplification
1 parent 6753e95 commit c07e733

15 files changed

+2433
-554
lines changed

config/gni/devtools_grd_files.gni

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,8 @@ grd_files_bundled_sources = [
654654
"front_end/panels/ai_chat/ui/HelpDialog.js",
655655
"front_end/panels/ai_chat/ui/PromptEditDialog.js",
656656
"front_end/panels/ai_chat/ui/SettingsDialog.js",
657+
"front_end/panels/ai_chat/ui/OnboardingDialog.js",
658+
"front_end/panels/ai_chat/ui/onboardingStyles.js",
657659
"front_end/panels/ai_chat/ui/mcp/MCPConnectionsDialog.js",
658660
"front_end/panels/ai_chat/ui/mcp/MCPConnectorsCatalogDialog.js",
659661
"front_end/panels/ai_chat/ui/EvaluationDialog.js",
@@ -687,6 +689,7 @@ grd_files_bundled_sources = [
687689
"front_end/panels/ai_chat/LLM/LLMProviderRegistry.js",
688690
"front_end/panels/ai_chat/LLM/LLMErrorHandler.js",
689691
"front_end/panels/ai_chat/LLM/LLMResponseParser.js",
692+
"front_end/panels/ai_chat/LLM/FuzzyModelMatcher.js",
690693
"front_end/panels/ai_chat/LLM/OpenAIProvider.js",
691694
"front_end/panels/ai_chat/LLM/LiteLLMProvider.js",
692695
"front_end/panels/ai_chat/LLM/GroqProvider.js",

config/gni/devtools_image_files.gni

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ devtools_image_files = [
2020
"touchCursor.png",
2121
"gdp-logo-light.png",
2222
"gdp-logo-dark.png",
23+
"browser-operator-logo.png",
2324
]
2425

2526
devtools_svg_sources = [
7.96 KB
Loading

front_end/Images/demo.gif

1.78 MB
Loading

front_end/panels/ai_chat/BUILD.gn

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ devtools_module("ai_chat") {
4040
"ui/ToolDescriptionFormatter.ts",
4141
"ui/HelpDialog.ts",
4242
"ui/SettingsDialog.ts",
43+
"ui/OnboardingDialog.ts",
44+
"ui/onboardingStyles.ts",
4345
"ui/settings/types.ts",
4446
"ui/settings/constants.ts",
4547
"ui/settings/i18n-strings.ts",
@@ -103,6 +105,7 @@ devtools_module("ai_chat") {
103105
"LLM/LLMProviderRegistry.ts",
104106
"LLM/LLMErrorHandler.ts",
105107
"LLM/LLMResponseParser.ts",
108+
"LLM/FuzzyModelMatcher.ts",
106109
"LLM/OpenAIProvider.ts",
107110
"LLM/LiteLLMProvider.ts",
108111
"LLM/GroqProvider.ts",
@@ -306,6 +309,7 @@ _ai_chat_sources = [
306309
"LLM/LLMProviderRegistry.ts",
307310
"LLM/LLMErrorHandler.ts",
308311
"LLM/LLMResponseParser.ts",
312+
"LLM/FuzzyModelMatcher.ts",
309313
"LLM/OpenAIProvider.ts",
310314
"LLM/LiteLLMProvider.ts",
311315
"LLM/GroqProvider.ts",
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright 2025 The Chromium Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
/**
6+
* Fuzzy model name matcher for finding the closest available model
7+
* when an exact match isn't found.
8+
*/
9+
10+
/**
11+
* Calculate Levenshtein distance between two strings
12+
*/
13+
function levenshteinDistance(a: string, b: string): number {
14+
const matrix: number[][] = [];
15+
16+
// Initialize matrix
17+
for (let i = 0; i <= b.length; i++) {
18+
matrix[i] = [i];
19+
}
20+
for (let j = 0; j <= a.length; j++) {
21+
matrix[0][j] = j;
22+
}
23+
24+
// Fill matrix
25+
for (let i = 1; i <= b.length; i++) {
26+
for (let j = 1; j <= a.length; j++) {
27+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
28+
matrix[i][j] = matrix[i - 1][j - 1];
29+
} else {
30+
matrix[i][j] = Math.min(
31+
matrix[i - 1][j - 1] + 1, // substitution
32+
matrix[i][j - 1] + 1, // insertion
33+
matrix[i - 1][j] + 1 // deletion
34+
);
35+
}
36+
}
37+
}
38+
39+
return matrix[b.length][a.length];
40+
}
41+
42+
/**
43+
* Calculate similarity score between two strings (0-1)
44+
*/
45+
function similarity(a: string, b: string): number {
46+
const distance = levenshteinDistance(a, b);
47+
const maxLen = Math.max(a.length, b.length);
48+
return maxLen === 0 ? 1 : 1 - distance / maxLen;
49+
}
50+
51+
/**
52+
* Normalize model name for comparison by removing dates, versions, and separators
53+
*/
54+
function normalizeModelName(name: string): string {
55+
return name
56+
.toLowerCase()
57+
.replace(/[-_]/g, '') // Remove separators
58+
.replace(/\d{4}-?\d{2}-?\d{2}$/g, '') // Remove date suffixes (2025-04-14 or 20250514)
59+
.replace(/\d{8}$/g, '') // Remove date suffixes without dashes
60+
.trim();
61+
}
62+
63+
/**
64+
* Check if target is a prefix of candidate (case-insensitive)
65+
*/
66+
function isPrefixMatch(target: string, candidate: string): boolean {
67+
const normalizedTarget = target.toLowerCase().replace(/[._]/g, '-');
68+
const normalizedCandidate = candidate.toLowerCase().replace(/[._]/g, '-');
69+
return normalizedCandidate.startsWith(normalizedTarget);
70+
}
71+
72+
/**
73+
* Find the closest matching model from available options
74+
*
75+
* Matching strategy (in priority order):
76+
* 1. Exact match - return immediately
77+
* 2. Prefix match - if target is prefix of an available model
78+
* 3. Normalized match - strip dates/versions and compare base names
79+
* 4. Levenshtein similarity - if similarity > threshold, return best match
80+
*
81+
* @param targetModel - The model name to find a match for
82+
* @param availableModels - Array of available model names
83+
* @param threshold - Minimum similarity score (0-1) for fuzzy matching (default: 0.5)
84+
* @returns The closest matching model name, or null if no good match found
85+
*/
86+
export function findClosestModel(
87+
targetModel: string,
88+
availableModels: string[],
89+
threshold: number = 0.5
90+
): string | null {
91+
if (!targetModel || availableModels.length === 0) {
92+
return null;
93+
}
94+
95+
// 1. Exact match
96+
if (availableModels.includes(targetModel)) {
97+
return targetModel;
98+
}
99+
100+
// 2. Prefix match - find models where target is a prefix
101+
const prefixMatches = availableModels.filter(model => isPrefixMatch(targetModel, model));
102+
if (prefixMatches.length > 0) {
103+
// Return the shortest prefix match (most specific)
104+
return prefixMatches.sort((a, b) => a.length - b.length)[0];
105+
}
106+
107+
// 3. Normalized match - compare base names without dates/versions
108+
const normalizedTarget = normalizeModelName(targetModel);
109+
for (const model of availableModels) {
110+
if (normalizeModelName(model) === normalizedTarget) {
111+
return model;
112+
}
113+
}
114+
115+
// 4. Levenshtein similarity on normalized names
116+
let bestMatch: string | null = null;
117+
let bestScore = 0;
118+
119+
for (const model of availableModels) {
120+
const score = similarity(normalizedTarget, normalizeModelName(model));
121+
if (score > bestScore && score >= threshold) {
122+
bestScore = score;
123+
bestMatch = model;
124+
}
125+
}
126+
127+
return bestMatch;
128+
}
129+
130+
/**
131+
* Find closest model with detailed match info for logging
132+
*/
133+
export interface FuzzyMatchResult {
134+
match: string | null;
135+
matchType: 'exact' | 'prefix' | 'normalized' | 'similarity' | 'none';
136+
score: number;
137+
}
138+
139+
export function findClosestModelWithInfo(
140+
targetModel: string,
141+
availableModels: string[],
142+
threshold: number = 0.5
143+
): FuzzyMatchResult {
144+
if (!targetModel || availableModels.length === 0) {
145+
return { match: null, matchType: 'none', score: 0 };
146+
}
147+
148+
// 1. Exact match
149+
if (availableModels.includes(targetModel)) {
150+
return { match: targetModel, matchType: 'exact', score: 1 };
151+
}
152+
153+
// 2. Prefix match
154+
const prefixMatches = availableModels.filter(model => isPrefixMatch(targetModel, model));
155+
if (prefixMatches.length > 0) {
156+
const match = prefixMatches.sort((a, b) => a.length - b.length)[0];
157+
return { match, matchType: 'prefix', score: targetModel.length / match.length };
158+
}
159+
160+
// 3. Normalized match
161+
const normalizedTarget = normalizeModelName(targetModel);
162+
for (const model of availableModels) {
163+
if (normalizeModelName(model) === normalizedTarget) {
164+
return { match: model, matchType: 'normalized', score: 1 };
165+
}
166+
}
167+
168+
// 4. Levenshtein similarity
169+
let bestMatch: string | null = null;
170+
let bestScore = 0;
171+
172+
for (const model of availableModels) {
173+
const score = similarity(normalizedTarget, normalizeModelName(model));
174+
if (score > bestScore && score >= threshold) {
175+
bestScore = score;
176+
bestMatch = model;
177+
}
178+
}
179+
180+
if (bestMatch) {
181+
return { match: bestMatch, matchType: 'similarity', score: bestScore };
182+
}
183+
184+
return { match: null, matchType: 'none', score: 0 };
185+
}

front_end/panels/ai_chat/LLM/LLMProviderRegistry.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { createLogger } from '../core/Logger.js';
66
import type { LLMProviderInterface } from './LLMProvider.js';
77
import type { LLMProvider, ModelInfo } from './LLMTypes.js';
8+
import { isCustomProvider } from './LLMTypes.js';
89
import { OpenAIProvider } from './OpenAIProvider.js';
910
import { LiteLLMProvider } from './LiteLLMProvider.js';
1011
import { GroqProvider } from './GroqProvider.js';
@@ -13,6 +14,8 @@ import { BrowserOperatorProvider } from './BrowserOperatorProvider.js';
1314
import { CerebrasProvider } from './CerebrasProvider.js';
1415
import { AnthropicProvider } from './AnthropicProvider.js';
1516
import { GoogleAIProvider } from './GoogleAIProvider.js';
17+
import { GenericOpenAIProvider } from './GenericOpenAIProvider.js';
18+
import { CustomProviderManager } from '../core/CustomProviderManager.js';
1619

1720
const logger = createLogger('LLMProviderRegistry');
1821

@@ -122,6 +125,16 @@ export class LLMProviderRegistry {
122125
endpoint?: string
123126
): LLMProviderInterface | null {
124127
try {
128+
// Handle custom providers - create GenericOpenAIProvider with config from CustomProviderManager
129+
if (isCustomProvider(providerType)) {
130+
const config = CustomProviderManager.getProvider(providerType);
131+
if (!config) {
132+
logger.warn(`Custom provider ${providerType} not found in CustomProviderManager`);
133+
return null;
134+
}
135+
return new GenericOpenAIProvider(config, apiKey || undefined);
136+
}
137+
125138
switch (providerType) {
126139
case 'openai':
127140
return new OpenAIProvider(apiKey);
@@ -176,6 +189,13 @@ export class LLMProviderRegistry {
176189
* Returns the localStorage keys used by the provider for credentials
177190
*/
178191
static getProviderStorageKeys(providerType: LLMProvider): {apiKey?: string; endpoint?: string; [key: string]: string | undefined} {
192+
// Handle custom providers - they use CustomProviderManager for storage
193+
if (isCustomProvider(providerType)) {
194+
return {
195+
apiKey: CustomProviderManager.getApiKeyStorageKey(providerType),
196+
};
197+
}
198+
179199
const provider = this.getOrCreateProvider(providerType);
180200
if (!provider) {
181201
logger.warn(`Provider ${providerType} not available`);
@@ -188,6 +208,11 @@ export class LLMProviderRegistry {
188208
* Get API key from localStorage for a provider
189209
*/
190210
static getProviderApiKey(providerType: LLMProvider): string {
211+
// Handle custom providers - they use CustomProviderManager for API key storage
212+
if (isCustomProvider(providerType)) {
213+
return CustomProviderManager.getApiKey(providerType) || '';
214+
}
215+
191216
const keys = this.getProviderStorageKeys(providerType);
192217
if (!keys.apiKey) {
193218
return '';
@@ -307,16 +332,65 @@ export class LLMProviderRegistry {
307332
apiKey: string,
308333
endpoint?: string
309334
): Promise<ModelInfo[]> {
310-
// Get or create provider with provided credentials
311-
const provider = this.getOrCreateProvider(providerType, apiKey, endpoint);
335+
// Handle custom providers - check if models were manually configured
336+
if (isCustomProvider(providerType)) {
337+
const config = CustomProviderManager.getProvider(providerType);
338+
if (!config) {
339+
logger.warn(`Custom provider ${providerType} not found`);
340+
return [];
341+
}
342+
343+
// If models were manually added by user, return them as-is
344+
if (config.modelsManuallyAdded && config.models.length > 0) {
345+
logger.debug(`Returning ${config.models.length} manually configured models for ${providerType}`);
346+
return config.models.map(modelId => ({
347+
id: modelId,
348+
name: modelId,
349+
provider: providerType,
350+
}));
351+
}
352+
353+
// Otherwise, fetch from the custom provider's API (OpenAI-compatible)
354+
logger.debug(`Fetching models from API for custom provider ${providerType}`);
355+
const provider = new GenericOpenAIProvider(config, apiKey || undefined);
356+
try {
357+
if (typeof provider.fetchModels === 'function') {
358+
const models = await provider.fetchModels();
359+
return models.map((m: any) => ({
360+
id: m.id || m.name,
361+
name: m.name || m.id,
362+
provider: providerType,
363+
...(m.capabilities ? { capabilities: m.capabilities } : {}),
364+
}));
365+
}
366+
return await provider.getModels();
367+
} catch (error) {
368+
logger.error(`Failed to fetch models for custom provider ${providerType}:`, error);
369+
throw error;
370+
}
371+
}
372+
373+
// Built-in providers: always create a fresh provider instance with the provided credentials for testing
374+
// Don't use getOrCreateProvider() which returns the registered instance with old/no API key
375+
const provider = this.createTemporaryProvider(providerType, apiKey, endpoint);
312376
if (!provider) {
313377
logger.warn(`Provider ${providerType} not available`);
314378
return [];
315379
}
316380

317381
try {
318-
// Use getModels() which returns standardized ModelInfo[] with 'id' property
319-
// The provider was created with the provided apiKey, so getModels() will use it
382+
// Use fetchModels() if available - it throws on API errors (good for validation)
383+
// Fall back to getModels() which may swallow errors and return defaults
384+
if (typeof (provider as any).fetchModels === 'function') {
385+
const models = await (provider as any).fetchModels();
386+
// Convert to ModelInfo format if needed
387+
return models.map((m: any) => ({
388+
id: m.id || m.name,
389+
name: m.name || m.id,
390+
provider: providerType,
391+
...(m.capabilities ? { capabilities: m.capabilities } : {}),
392+
}));
393+
}
320394
return await provider.getModels();
321395
} catch (error) {
322396
logger.error(`Failed to fetch models for ${providerType}:`, error);

0 commit comments

Comments
 (0)