fix(web-ui): add model selector filter, onModelSelect callback, onClose callback, fuzzy search, fix streaming duplicates

This commit is contained in:
Mario Zechner
2026-03-15 23:40:18 +01:00
parent aac0e0c1bf
commit 83378aad7e
7 changed files with 118 additions and 23 deletions

View File

@@ -2,6 +2,23 @@
## [Unreleased] ## [Unreleased]
### Added
- `onModelSelect` callback on `AgentInterface` and `ChatPanel.setAgent` config
- `allowedProviders` filter on `ModelSelector.open()` to restrict visible models
- `onClose` callback on `SettingsDialog.open()`
- `state_change` event emitted by Agent on `setModel()` and `setThinkingLevel()`
- Subsequence-based fuzzy search in model selector (replaces substring matching)
- `openai-codex` and `github-copilot` to `shouldUseProxyForProvider`
### Changed
- Anthropic test model updated from `claude-3-5-haiku-20241022` to `claude-haiku-4-5`
### Fixed
- `AgentInterface` clears streaming container on `message_end` to prevent duplicate tool rendering
## [0.58.3] - 2026-03-15 ## [0.58.3] - 2026-03-15
### Fixed ### Fixed

View File

@@ -59,6 +59,7 @@ export class ChatPanel extends LitElement {
onApiKeyRequired?: (provider: string) => Promise<boolean>; onApiKeyRequired?: (provider: string) => Promise<boolean>;
onBeforeSend?: () => void | Promise<void>; onBeforeSend?: () => void | Promise<void>;
onCostClick?: () => void; onCostClick?: () => void;
onModelSelect?: () => void;
sandboxUrlProvider?: () => string; sandboxUrlProvider?: () => string;
toolsFactory?: ( toolsFactory?: (
agent: Agent, agent: Agent,
@@ -78,6 +79,7 @@ export class ChatPanel extends LitElement {
this.agentInterface.enableThinkingSelector = true; this.agentInterface.enableThinkingSelector = true;
this.agentInterface.showThemeToggle = false; this.agentInterface.showThemeToggle = false;
this.agentInterface.onApiKeyRequired = config?.onApiKeyRequired; this.agentInterface.onApiKeyRequired = config?.onApiKeyRequired;
this.agentInterface.onModelSelect = config?.onModelSelect;
this.agentInterface.onBeforeSend = config?.onBeforeSend; this.agentInterface.onBeforeSend = config?.onBeforeSend;
this.agentInterface.onCostClick = config?.onCostClick; this.agentInterface.onCostClick = config?.onCostClick;

View File

@@ -32,6 +32,8 @@ export class AgentInterface extends LitElement {
@property({ attribute: false }) onBeforeToolCall?: (toolName: string, args: any) => boolean | Promise<boolean>; @property({ attribute: false }) onBeforeToolCall?: (toolName: string, args: any) => boolean | Promise<boolean>;
// Optional callback called when cost display is clicked // Optional callback called when cost display is clicked
@property({ attribute: false }) onCostClick?: () => void; @property({ attribute: false }) onCostClick?: () => void;
// Optional callback to override model selector behavior
@property({ attribute: false }) onModelSelect?: () => void;
// References // References
@query("message-editor") private _messageEditor!: MessageEditor; @query("message-editor") private _messageEditor!: MessageEditor;
@@ -151,12 +153,19 @@ export class AgentInterface extends LitElement {
this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => { this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => {
switch (ev.type) { switch (ev.type) {
case "message_start": case "message_start":
case "message_end":
case "turn_start": case "turn_start":
case "turn_end": case "turn_end":
case "agent_start": case "agent_start":
this.requestUpdate(); this.requestUpdate();
break; break;
case "message_end":
// Clear streaming container when a message completes
// to prevent duplicate rendering (stable list now has this message)
if (this._streamingContainer) {
this._streamingContainer.setMessage(null, true);
}
this.requestUpdate();
break;
case "agent_end": case "agent_end":
// Clear streaming container when agent finishes // Clear streaming container when agent finishes
if (this._streamingContainer) { if (this._streamingContainer) {
@@ -364,7 +373,11 @@ export class AgentInterface extends LitElement {
}} }}
.onAbort=${() => session.abort()} .onAbort=${() => session.abort()}
.onModelSelect=${() => { .onModelSelect=${() => {
ModelSelector.open(state.model, (model) => session.setModel(model)); if (this.onModelSelect) {
this.onModelSelect();
} else {
ModelSelector.open(state.model, (model) => session.setModel(model));
}
}} }}
.onThinkingChange=${ .onThinkingChange=${
this.enableThinkingSelector this.enableThinkingSelector

View File

@@ -10,7 +10,7 @@ import { Input } from "./Input.js";
// Test models for each provider // Test models for each provider
const TEST_MODELS: Record<string, string> = { const TEST_MODELS: Record<string, string> = {
anthropic: "claude-3-5-haiku-20241022", anthropic: "claude-haiku-4-5",
openai: "gpt-4o-mini", openai: "gpt-4o-mini",
google: "gemini-2.5-flash", google: "gemini-2.5-flash",
groq: "openai/gpt-oss-20b", groq: "openai/gpt-oss-20b",

View File

@@ -15,6 +15,37 @@ import { formatModelCost } from "../utils/format.js";
import { i18n } from "../utils/i18n.js"; import { i18n } from "../utils/i18n.js";
import { discoverModels } from "../utils/model-discovery.js"; import { discoverModels } from "../utils/model-discovery.js";
/**
* Score a query against a text using subsequence matching.
* All query characters must appear in order in the text.
* Higher score = tighter match (fewer gaps between matched characters).
* Returns 0 if no match.
*/
function subsequenceScore(query: string, text: string): number {
let qi = 0;
let ti = 0;
let gaps = 0;
let lastMatchIndex = -1;
while (qi < query.length && ti < text.length) {
if (query[qi] === text[ti]) {
if (lastMatchIndex >= 0) {
gaps += ti - lastMatchIndex - 1;
}
lastMatchIndex = ti;
qi++;
}
ti++;
}
// All query chars must match
if (qi < query.length) return 0;
// Score: longer query match = better, fewer gaps = better
// Normalize so exact substring gets highest score
return query.length / (query.length + gaps);
}
@customElement("agent-model-selector") @customElement("agent-model-selector")
export class ModelSelector extends DialogBase { export class ModelSelector extends DialogBase {
@state() currentModel: Model<any> | null = null; @state() currentModel: Model<any> | null = null;
@@ -27,16 +58,24 @@ export class ModelSelector extends DialogBase {
@state() private customProviderModels: Model<any>[] = []; @state() private customProviderModels: Model<any>[] = [];
private onSelectCallback?: (model: Model<any>) => void; private onSelectCallback?: (model: Model<any>) => void;
private allowedProviders?: Set<string>;
private scrollContainerRef = createRef<HTMLDivElement>(); private scrollContainerRef = createRef<HTMLDivElement>();
private searchInputRef = createRef<HTMLInputElement>(); private searchInputRef = createRef<HTMLInputElement>();
private lastMousePosition = { x: 0, y: 0 }; private lastMousePosition = { x: 0, y: 0 };
protected override modalWidth = "min(400px, 90vw)"; protected override modalWidth = "min(400px, 90vw)";
static async open(currentModel: Model<any> | null, onSelect: (model: Model<any>) => void) { static async open(
currentModel: Model<any> | null,
onSelect: (model: Model<any>) => void,
allowedProviders?: string[],
) {
const selector = new ModelSelector(); const selector = new ModelSelector();
selector.currentModel = currentModel; selector.currentModel = currentModel;
selector.onSelectCallback = onSelect; selector.onSelectCallback = onSelect;
if (allowedProviders) {
selector.allowedProviders = new Set(allowedProviders);
}
selector.open(); selector.open();
selector.loadCustomProviders(); selector.loadCustomProviders();
} }
@@ -173,19 +212,30 @@ export class ModelSelector extends DialogBase {
allModels.push({ provider: model.provider, id: model.id, model }); allModels.push({ provider: model.provider, id: model.id, model });
} }
// Filter by allowed providers if set
if (this.allowedProviders) {
const allowed = this.allowedProviders;
allModels.splice(0, allModels.length, ...allModels.filter(({ provider }) => allowed.has(provider)));
}
// Filter models based on search and capability filters // Filter models based on search and capability filters
let filteredModels = allModels; let filteredModels = allModels;
// Apply search filter // Apply search filter (subsequence match: characters must appear in order)
if (this.searchQuery) { if (this.searchQuery) {
filteredModels = filteredModels.filter(({ provider, id, model }) => { const query = this.searchQuery.toLowerCase().replace(/\s+/g, "");
const searchTokens = this.searchQuery if (query) {
.toLowerCase() const scored: Array<{ item: (typeof allModels)[0]; score: number }> = [];
.split(/\s+/) for (const entry of filteredModels) {
.filter((t) => t); const searchText = `${entry.provider} ${entry.id} ${entry.model.name}`.toLowerCase();
const searchText = `${provider} ${id} ${model.name}`.toLowerCase(); const score = subsequenceScore(query, searchText);
return searchTokens.every((token) => searchText.includes(token)); if (score > 0) {
}); scored.push({ item: entry, score });
}
}
scored.sort((a, b) => b.score - a.score);
filteredModels = scored.map((s) => s.item);
}
} }
// Apply capability filters // Apply capability filters
@@ -196,14 +246,18 @@ export class ModelSelector extends DialogBase {
filteredModels = filteredModels.filter(({ model }) => model.input.includes("image")); filteredModels = filteredModels.filter(({ model }) => model.input.includes("image"));
} }
// Sort: current model first, then by provider // Sort: when not searching, current model first then by provider.
filteredModels.sort((a, b) => { // When searching, preserve the score-based order from above,
const aIsCurrent = modelsAreEqual(this.currentModel, a.model); // but still float the current model to the top.
const bIsCurrent = modelsAreEqual(this.currentModel, b.model); if (!this.searchQuery) {
if (aIsCurrent && !bIsCurrent) return -1; filteredModels.sort((a, b) => {
if (!aIsCurrent && bIsCurrent) return 1; const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
return a.provider.localeCompare(b.provider); const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
}); if (aIsCurrent && !bIsCurrent) return -1;
if (!aIsCurrent && bIsCurrent) return 1;
return a.provider.localeCompare(b.provider);
});
}
return filteredModels; return filteredModels;
} }

View File

@@ -122,9 +122,12 @@ export class SettingsDialog extends LitElement {
return this; return this;
} }
static async open(tabs: SettingsTab[]) { private onCloseCallback?: () => void;
static async open(tabs: SettingsTab[], onClose?: () => void) {
const dialog = new SettingsDialog(); const dialog = new SettingsDialog();
dialog.tabs = tabs; dialog.tabs = tabs;
dialog.onCloseCallback = onClose;
dialog.isOpen = true; dialog.isOpen = true;
document.body.appendChild(dialog); document.body.appendChild(dialog);
} }
@@ -173,6 +176,7 @@ export class SettingsDialog extends LitElement {
onClose: () => { onClose: () => {
this.isOpen = false; this.isOpen = false;
this.remove(); this.remove();
this.onCloseCallback?.();
}, },
width: "min(1000px, 90vw)", width: "min(1000px, 90vw)",
height: "min(800px, 90vh)", height: "min(800px, 90vh)",

View File

@@ -25,7 +25,11 @@ export function shouldUseProxyForProvider(provider: string, apiKey: string): boo
case "anthropic": case "anthropic":
// Anthropic OAuth tokens (sk-ant-oat-*) require proxy // Anthropic OAuth tokens (sk-ant-oat-*) require proxy
// Regular API keys (sk-ant-api-*) do NOT require proxy // Regular API keys (sk-ant-api-*) do NOT require proxy
return apiKey.startsWith("sk-ant-oat"); return apiKey.startsWith("sk-ant-oat") || apiKey.startsWith("{");
case "openai-codex":
// Codex uses chatgpt.com/backend-api which has no CORS
return true;
// These providers work without proxy // These providers work without proxy
case "openai": case "openai":
@@ -36,6 +40,7 @@ export function shouldUseProxyForProvider(provider: string, apiKey: string): boo
case "xai": case "xai":
case "ollama": case "ollama":
case "lmstudio": case "lmstudio":
case "github-copilot":
return false; return false;
// Unknown providers - assume no proxy needed // Unknown providers - assume no proxy needed