fix(web-ui): add model selector filter, onModelSelect callback, onClose callback, fuzzy search, fix streaming duplicates
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user