@oh-my-pi/pi-web-ui 1.337.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/README.md +609 -0
- package/example/README.md +61 -0
- package/example/index.html +13 -0
- package/example/package.json +24 -0
- package/example/src/app.css +1 -0
- package/example/src/custom-messages.ts +99 -0
- package/example/src/main.ts +420 -0
- package/example/tsconfig.json +23 -0
- package/example/vite.config.ts +6 -0
- package/package.json +57 -0
- package/scripts/count-prompt-tokens.ts +88 -0
- package/src/ChatPanel.ts +218 -0
- package/src/app.css +68 -0
- package/src/components/AgentInterface.ts +390 -0
- package/src/components/AttachmentTile.ts +107 -0
- package/src/components/ConsoleBlock.ts +74 -0
- package/src/components/CustomProviderCard.ts +96 -0
- package/src/components/ExpandableSection.ts +46 -0
- package/src/components/Input.ts +113 -0
- package/src/components/MessageEditor.ts +404 -0
- package/src/components/MessageList.ts +97 -0
- package/src/components/Messages.ts +384 -0
- package/src/components/ProviderKeyInput.ts +152 -0
- package/src/components/SandboxedIframe.ts +626 -0
- package/src/components/StreamingMessageContainer.ts +107 -0
- package/src/components/ThinkingBlock.ts +45 -0
- package/src/components/message-renderer-registry.ts +28 -0
- package/src/components/sandbox/ArtifactsRuntimeProvider.ts +219 -0
- package/src/components/sandbox/AttachmentsRuntimeProvider.ts +66 -0
- package/src/components/sandbox/ConsoleRuntimeProvider.ts +186 -0
- package/src/components/sandbox/FileDownloadRuntimeProvider.ts +110 -0
- package/src/components/sandbox/RuntimeMessageBridge.ts +82 -0
- package/src/components/sandbox/RuntimeMessageRouter.ts +216 -0
- package/src/components/sandbox/SandboxRuntimeProvider.ts +52 -0
- package/src/dialogs/ApiKeyPromptDialog.ts +75 -0
- package/src/dialogs/AttachmentOverlay.ts +640 -0
- package/src/dialogs/CustomProviderDialog.ts +274 -0
- package/src/dialogs/ModelSelector.ts +314 -0
- package/src/dialogs/PersistentStorageDialog.ts +146 -0
- package/src/dialogs/ProvidersModelsTab.ts +212 -0
- package/src/dialogs/SessionListDialog.ts +157 -0
- package/src/dialogs/SettingsDialog.ts +216 -0
- package/src/index.ts +115 -0
- package/src/prompts/prompts.ts +282 -0
- package/src/storage/app-storage.ts +60 -0
- package/src/storage/backends/indexeddb-storage-backend.ts +193 -0
- package/src/storage/store.ts +33 -0
- package/src/storage/stores/custom-providers-store.ts +62 -0
- package/src/storage/stores/provider-keys-store.ts +33 -0
- package/src/storage/stores/sessions-store.ts +136 -0
- package/src/storage/stores/settings-store.ts +34 -0
- package/src/storage/types.ts +206 -0
- package/src/tools/artifacts/ArtifactElement.ts +14 -0
- package/src/tools/artifacts/ArtifactPill.ts +26 -0
- package/src/tools/artifacts/Console.ts +102 -0
- package/src/tools/artifacts/DocxArtifact.ts +213 -0
- package/src/tools/artifacts/ExcelArtifact.ts +231 -0
- package/src/tools/artifacts/GenericArtifact.ts +118 -0
- package/src/tools/artifacts/HtmlArtifact.ts +203 -0
- package/src/tools/artifacts/ImageArtifact.ts +116 -0
- package/src/tools/artifacts/MarkdownArtifact.ts +83 -0
- package/src/tools/artifacts/PdfArtifact.ts +201 -0
- package/src/tools/artifacts/SvgArtifact.ts +82 -0
- package/src/tools/artifacts/TextArtifact.ts +148 -0
- package/src/tools/artifacts/artifacts-tool-renderer.ts +371 -0
- package/src/tools/artifacts/artifacts.ts +713 -0
- package/src/tools/artifacts/index.ts +7 -0
- package/src/tools/extract-document.ts +271 -0
- package/src/tools/index.ts +46 -0
- package/src/tools/javascript-repl.ts +316 -0
- package/src/tools/renderer-registry.ts +127 -0
- package/src/tools/renderers/BashRenderer.ts +52 -0
- package/src/tools/renderers/CalculateRenderer.ts +58 -0
- package/src/tools/renderers/DefaultRenderer.ts +95 -0
- package/src/tools/renderers/GetCurrentTimeRenderer.ts +92 -0
- package/src/tools/types.ts +15 -0
- package/src/utils/attachment-utils.ts +472 -0
- package/src/utils/auth-token.ts +22 -0
- package/src/utils/format.ts +42 -0
- package/src/utils/i18n.ts +653 -0
- package/src/utils/model-discovery.ts +277 -0
- package/src/utils/proxy-utils.ts +134 -0
- package/src/utils/test-sessions.ts +2357 -0
- package/tsconfig.build.json +20 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { i18n } from "@mariozechner/mini-lit";
|
|
2
|
+
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
|
3
|
+
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
|
|
4
|
+
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
|
|
5
|
+
import { Label } from "@mariozechner/mini-lit/dist/Label.js";
|
|
6
|
+
import { Select } from "@mariozechner/mini-lit/dist/Select.js";
|
|
7
|
+
import type { Model } from "@oh-my-pi/pi-ai";
|
|
8
|
+
import { html, type TemplateResult } from "lit";
|
|
9
|
+
import { state } from "lit/decorators.js";
|
|
10
|
+
import { getAppStorage } from "../storage/app-storage.js";
|
|
11
|
+
import type { CustomProvider, CustomProviderType } from "../storage/stores/custom-providers-store.js";
|
|
12
|
+
import { discoverModels } from "../utils/model-discovery.js";
|
|
13
|
+
|
|
14
|
+
export class CustomProviderDialog extends DialogBase {
|
|
15
|
+
private provider?: CustomProvider;
|
|
16
|
+
private initialType?: CustomProviderType;
|
|
17
|
+
private onSaveCallback?: () => void;
|
|
18
|
+
|
|
19
|
+
@state() private name = "";
|
|
20
|
+
@state() private type: CustomProviderType = "openai-completions";
|
|
21
|
+
@state() private baseUrl = "";
|
|
22
|
+
@state() private apiKey = "";
|
|
23
|
+
@state() private testing = false;
|
|
24
|
+
@state() private testError = "";
|
|
25
|
+
@state() private discoveredModels: Model<any>[] = [];
|
|
26
|
+
|
|
27
|
+
protected modalWidth = "min(800px, 90vw)";
|
|
28
|
+
protected modalHeight = "min(700px, 90vh)";
|
|
29
|
+
|
|
30
|
+
static async open(
|
|
31
|
+
provider: CustomProvider | undefined,
|
|
32
|
+
initialType: CustomProviderType | undefined,
|
|
33
|
+
onSave?: () => void,
|
|
34
|
+
) {
|
|
35
|
+
const dialog = new CustomProviderDialog();
|
|
36
|
+
dialog.provider = provider;
|
|
37
|
+
dialog.initialType = initialType;
|
|
38
|
+
dialog.onSaveCallback = onSave;
|
|
39
|
+
document.body.appendChild(dialog);
|
|
40
|
+
dialog.initializeFromProvider();
|
|
41
|
+
dialog.open();
|
|
42
|
+
dialog.requestUpdate();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private initializeFromProvider() {
|
|
46
|
+
if (this.provider) {
|
|
47
|
+
this.name = this.provider.name;
|
|
48
|
+
this.type = this.provider.type;
|
|
49
|
+
this.baseUrl = this.provider.baseUrl;
|
|
50
|
+
this.apiKey = this.provider.apiKey || "";
|
|
51
|
+
this.discoveredModels = this.provider.models || [];
|
|
52
|
+
} else {
|
|
53
|
+
this.name = "";
|
|
54
|
+
this.type = this.initialType || "openai-completions";
|
|
55
|
+
this.baseUrl = "";
|
|
56
|
+
this.updateDefaultBaseUrl();
|
|
57
|
+
this.apiKey = "";
|
|
58
|
+
this.discoveredModels = [];
|
|
59
|
+
}
|
|
60
|
+
this.testError = "";
|
|
61
|
+
this.testing = false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private updateDefaultBaseUrl() {
|
|
65
|
+
if (this.baseUrl) return;
|
|
66
|
+
|
|
67
|
+
const defaults: Record<string, string> = {
|
|
68
|
+
ollama: "http://localhost:11434",
|
|
69
|
+
"llama.cpp": "http://localhost:8080",
|
|
70
|
+
vllm: "http://localhost:8000",
|
|
71
|
+
lmstudio: "http://localhost:1234",
|
|
72
|
+
"openai-completions": "",
|
|
73
|
+
"openai-responses": "",
|
|
74
|
+
"anthropic-messages": "",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
this.baseUrl = defaults[this.type] || "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private isAutoDiscoveryType(): boolean {
|
|
81
|
+
return this.type === "ollama" || this.type === "llama.cpp" || this.type === "vllm" || this.type === "lmstudio";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private async testConnection() {
|
|
85
|
+
if (!this.isAutoDiscoveryType()) return;
|
|
86
|
+
|
|
87
|
+
this.testing = true;
|
|
88
|
+
this.testError = "";
|
|
89
|
+
this.discoveredModels = [];
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const models = await discoverModels(
|
|
93
|
+
this.type as "ollama" | "llama.cpp" | "vllm" | "lmstudio",
|
|
94
|
+
this.baseUrl,
|
|
95
|
+
this.apiKey || undefined,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
this.discoveredModels = models.map((model) => ({
|
|
99
|
+
...model,
|
|
100
|
+
provider: this.name || this.type,
|
|
101
|
+
}));
|
|
102
|
+
|
|
103
|
+
this.testError = "";
|
|
104
|
+
} catch (error) {
|
|
105
|
+
this.testError = error instanceof Error ? error.message : String(error);
|
|
106
|
+
this.discoveredModels = [];
|
|
107
|
+
} finally {
|
|
108
|
+
this.testing = false;
|
|
109
|
+
this.requestUpdate();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private async save() {
|
|
114
|
+
if (!this.name || !this.baseUrl) {
|
|
115
|
+
alert(i18n("Please fill in all required fields"));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const storage = getAppStorage();
|
|
121
|
+
|
|
122
|
+
const provider: CustomProvider = {
|
|
123
|
+
id: this.provider?.id || crypto.randomUUID(),
|
|
124
|
+
name: this.name,
|
|
125
|
+
type: this.type,
|
|
126
|
+
baseUrl: this.baseUrl,
|
|
127
|
+
apiKey: this.apiKey || undefined,
|
|
128
|
+
models: this.isAutoDiscoveryType() ? undefined : this.provider?.models || [],
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
await storage.customProviders.set(provider);
|
|
132
|
+
|
|
133
|
+
if (this.onSaveCallback) {
|
|
134
|
+
this.onSaveCallback();
|
|
135
|
+
}
|
|
136
|
+
this.close();
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error("Failed to save provider:", error);
|
|
139
|
+
alert(i18n("Failed to save provider"));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
protected override renderContent(): TemplateResult {
|
|
144
|
+
const providerTypes = [
|
|
145
|
+
{ value: "ollama", label: "Ollama (auto-discovery)" },
|
|
146
|
+
{ value: "llama.cpp", label: "llama.cpp (auto-discovery)" },
|
|
147
|
+
{ value: "vllm", label: "vLLM (auto-discovery)" },
|
|
148
|
+
{ value: "lmstudio", label: "LM Studio (auto-discovery)" },
|
|
149
|
+
{ value: "openai-completions", label: "OpenAI Completions Compatible" },
|
|
150
|
+
{ value: "openai-responses", label: "OpenAI Responses Compatible" },
|
|
151
|
+
{ value: "anthropic-messages", label: "Anthropic Messages Compatible" },
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
return html`
|
|
155
|
+
<div class="flex flex-col h-full overflow-hidden">
|
|
156
|
+
<div class="p-6 flex-shrink-0 border-b border-border">
|
|
157
|
+
<h2 class="text-lg font-semibold text-foreground">
|
|
158
|
+
${this.provider ? i18n("Edit Provider") : i18n("Add Provider")}
|
|
159
|
+
</h2>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="flex-1 overflow-y-auto p-6">
|
|
163
|
+
<div class="flex flex-col gap-4">
|
|
164
|
+
<div class="flex flex-col gap-2">
|
|
165
|
+
${Label({ htmlFor: "provider-name", children: i18n("Provider Name") })}
|
|
166
|
+
${Input({
|
|
167
|
+
value: this.name,
|
|
168
|
+
placeholder: i18n("e.g., My Ollama Server"),
|
|
169
|
+
onInput: (e: Event) => {
|
|
170
|
+
this.name = (e.target as HTMLInputElement).value;
|
|
171
|
+
this.requestUpdate();
|
|
172
|
+
},
|
|
173
|
+
})}
|
|
174
|
+
</div>
|
|
175
|
+
|
|
176
|
+
<div class="flex flex-col gap-2">
|
|
177
|
+
${Label({ htmlFor: "provider-type", children: i18n("Provider Type") })}
|
|
178
|
+
${Select({
|
|
179
|
+
value: this.type,
|
|
180
|
+
options: providerTypes.map((pt) => ({
|
|
181
|
+
value: pt.value,
|
|
182
|
+
label: pt.label,
|
|
183
|
+
})),
|
|
184
|
+
onChange: (value: string) => {
|
|
185
|
+
this.type = value as CustomProviderType;
|
|
186
|
+
this.baseUrl = "";
|
|
187
|
+
this.updateDefaultBaseUrl();
|
|
188
|
+
this.requestUpdate();
|
|
189
|
+
},
|
|
190
|
+
width: "100%",
|
|
191
|
+
})}
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<div class="flex flex-col gap-2">
|
|
195
|
+
${Label({ htmlFor: "base-url", children: i18n("Base URL") })}
|
|
196
|
+
${Input({
|
|
197
|
+
value: this.baseUrl,
|
|
198
|
+
placeholder: i18n("e.g., http://localhost:11434"),
|
|
199
|
+
onInput: (e: Event) => {
|
|
200
|
+
this.baseUrl = (e.target as HTMLInputElement).value;
|
|
201
|
+
this.requestUpdate();
|
|
202
|
+
},
|
|
203
|
+
})}
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
<div class="flex flex-col gap-2">
|
|
207
|
+
${Label({ htmlFor: "api-key", children: i18n("API Key (Optional)") })}
|
|
208
|
+
${Input({
|
|
209
|
+
type: "password",
|
|
210
|
+
value: this.apiKey,
|
|
211
|
+
placeholder: i18n("Leave empty if not required"),
|
|
212
|
+
onInput: (e: Event) => {
|
|
213
|
+
this.apiKey = (e.target as HTMLInputElement).value;
|
|
214
|
+
this.requestUpdate();
|
|
215
|
+
},
|
|
216
|
+
})}
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
${
|
|
220
|
+
this.isAutoDiscoveryType()
|
|
221
|
+
? html`
|
|
222
|
+
<div class="flex flex-col gap-2">
|
|
223
|
+
${Button({
|
|
224
|
+
onClick: () => this.testConnection(),
|
|
225
|
+
variant: "outline",
|
|
226
|
+
disabled: this.testing || !this.baseUrl,
|
|
227
|
+
children: this.testing ? i18n("Testing...") : i18n("Test Connection"),
|
|
228
|
+
})}
|
|
229
|
+
${this.testError ? html` <div class="text-sm text-destructive">${this.testError}</div> ` : ""}
|
|
230
|
+
${
|
|
231
|
+
this.discoveredModels.length > 0
|
|
232
|
+
? html`
|
|
233
|
+
<div class="text-sm text-muted-foreground">
|
|
234
|
+
${i18n("Discovered")} ${this.discoveredModels.length} ${i18n("models")}:
|
|
235
|
+
<ul class="list-disc list-inside mt-2">
|
|
236
|
+
${this.discoveredModels.slice(0, 5).map((model) => html`<li>${model.name}</li>`)}
|
|
237
|
+
${
|
|
238
|
+
this.discoveredModels.length > 5
|
|
239
|
+
? html`<li>...${i18n("and")} ${this.discoveredModels.length - 5} ${i18n("more")}</li>`
|
|
240
|
+
: ""
|
|
241
|
+
}
|
|
242
|
+
</ul>
|
|
243
|
+
</div>
|
|
244
|
+
`
|
|
245
|
+
: ""
|
|
246
|
+
}
|
|
247
|
+
</div>
|
|
248
|
+
`
|
|
249
|
+
: html` <div class="text-sm text-muted-foreground">
|
|
250
|
+
${i18n("For manual provider types, add models after saving the provider.")}
|
|
251
|
+
</div>`
|
|
252
|
+
}
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
|
|
256
|
+
<div class="p-6 flex-shrink-0 border-t border-border flex justify-end gap-2">
|
|
257
|
+
${Button({
|
|
258
|
+
onClick: () => this.close(),
|
|
259
|
+
variant: "ghost",
|
|
260
|
+
children: i18n("Cancel"),
|
|
261
|
+
})}
|
|
262
|
+
${Button({
|
|
263
|
+
onClick: () => this.save(),
|
|
264
|
+
variant: "default",
|
|
265
|
+
disabled: !this.name || !this.baseUrl,
|
|
266
|
+
children: i18n("Save"),
|
|
267
|
+
})}
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
`;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
customElements.define("custom-provider-dialog", CustomProviderDialog);
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { icon } from "@mariozechner/mini-lit";
|
|
2
|
+
import { Badge } from "@mariozechner/mini-lit/dist/Badge.js";
|
|
3
|
+
import { Button } from "@mariozechner/mini-lit/dist/Button.js";
|
|
4
|
+
import { DialogHeader } from "@mariozechner/mini-lit/dist/Dialog.js";
|
|
5
|
+
import { DialogBase } from "@mariozechner/mini-lit/dist/DialogBase.js";
|
|
6
|
+
import { getModels, getProviders, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
|
|
7
|
+
import { html, type PropertyValues, type TemplateResult } from "lit";
|
|
8
|
+
import { customElement, state } from "lit/decorators.js";
|
|
9
|
+
import { createRef, ref } from "lit/directives/ref.js";
|
|
10
|
+
import { Brain, Image as ImageIcon } from "lucide";
|
|
11
|
+
import { Input } from "../components/Input.js";
|
|
12
|
+
import { getAppStorage } from "../storage/app-storage.js";
|
|
13
|
+
import type { AutoDiscoveryProviderType } from "../storage/stores/custom-providers-store.js";
|
|
14
|
+
import { formatModelCost } from "../utils/format.js";
|
|
15
|
+
import { i18n } from "../utils/i18n.js";
|
|
16
|
+
import { discoverModels } from "../utils/model-discovery.js";
|
|
17
|
+
|
|
18
|
+
@customElement("agent-model-selector")
|
|
19
|
+
export class ModelSelector extends DialogBase {
|
|
20
|
+
@state() currentModel: Model<any> | null = null;
|
|
21
|
+
@state() searchQuery = "";
|
|
22
|
+
@state() filterThinking = false;
|
|
23
|
+
@state() filterVision = false;
|
|
24
|
+
@state() customProvidersLoading = false;
|
|
25
|
+
@state() selectedIndex = 0;
|
|
26
|
+
@state() private navigationMode: "mouse" | "keyboard" = "mouse";
|
|
27
|
+
@state() private customProviderModels: Model<any>[] = [];
|
|
28
|
+
|
|
29
|
+
private onSelectCallback?: (model: Model<any>) => void;
|
|
30
|
+
private scrollContainerRef = createRef<HTMLDivElement>();
|
|
31
|
+
private searchInputRef = createRef<HTMLInputElement>();
|
|
32
|
+
private lastMousePosition = { x: 0, y: 0 };
|
|
33
|
+
|
|
34
|
+
protected override modalWidth = "min(400px, 90vw)";
|
|
35
|
+
|
|
36
|
+
static async open(currentModel: Model<any> | null, onSelect: (model: Model<any>) => void) {
|
|
37
|
+
const selector = new ModelSelector();
|
|
38
|
+
selector.currentModel = currentModel;
|
|
39
|
+
selector.onSelectCallback = onSelect;
|
|
40
|
+
selector.open();
|
|
41
|
+
selector.loadCustomProviders();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override async firstUpdated(changedProperties: PropertyValues): Promise<void> {
|
|
45
|
+
super.firstUpdated(changedProperties);
|
|
46
|
+
// Wait for dialog to be fully rendered
|
|
47
|
+
await this.updateComplete;
|
|
48
|
+
// Focus the search input when dialog opens
|
|
49
|
+
this.searchInputRef.value?.focus();
|
|
50
|
+
|
|
51
|
+
// Track actual mouse movement
|
|
52
|
+
this.addEventListener("mousemove", (e: MouseEvent) => {
|
|
53
|
+
// Check if mouse actually moved
|
|
54
|
+
if (e.clientX !== this.lastMousePosition.x || e.clientY !== this.lastMousePosition.y) {
|
|
55
|
+
this.lastMousePosition = { x: e.clientX, y: e.clientY };
|
|
56
|
+
// Only switch to mouse mode on actual mouse movement
|
|
57
|
+
if (this.navigationMode === "keyboard") {
|
|
58
|
+
this.navigationMode = "mouse";
|
|
59
|
+
// Update selection to the item under the mouse
|
|
60
|
+
const target = e.target as HTMLElement;
|
|
61
|
+
const modelItem = target.closest("[data-model-item]");
|
|
62
|
+
if (modelItem) {
|
|
63
|
+
const allItems = this.scrollContainerRef.value?.querySelectorAll("[data-model-item]");
|
|
64
|
+
if (allItems) {
|
|
65
|
+
const index = Array.from(allItems).indexOf(modelItem);
|
|
66
|
+
if (index !== -1) {
|
|
67
|
+
this.selectedIndex = index;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Add global keyboard handler for the dialog
|
|
76
|
+
this.addEventListener("keydown", (e: KeyboardEvent) => {
|
|
77
|
+
// Get filtered models to know the bounds
|
|
78
|
+
const filteredModels = this.getFilteredModels();
|
|
79
|
+
|
|
80
|
+
if (e.key === "ArrowDown") {
|
|
81
|
+
e.preventDefault();
|
|
82
|
+
this.navigationMode = "keyboard";
|
|
83
|
+
this.selectedIndex = Math.min(this.selectedIndex + 1, filteredModels.length - 1);
|
|
84
|
+
this.scrollToSelected();
|
|
85
|
+
} else if (e.key === "ArrowUp") {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
this.navigationMode = "keyboard";
|
|
88
|
+
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
|
89
|
+
this.scrollToSelected();
|
|
90
|
+
} else if (e.key === "Enter") {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
if (filteredModels[this.selectedIndex]) {
|
|
93
|
+
this.handleSelect(filteredModels[this.selectedIndex].model);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async loadCustomProviders() {
|
|
100
|
+
this.customProvidersLoading = true;
|
|
101
|
+
const allCustomModels: Model<any>[] = [];
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const storage = getAppStorage();
|
|
105
|
+
const customProviders = await storage.customProviders.getAll();
|
|
106
|
+
|
|
107
|
+
// Load models from custom providers
|
|
108
|
+
for (const provider of customProviders) {
|
|
109
|
+
const isAutoDiscovery: boolean =
|
|
110
|
+
provider.type === "ollama" ||
|
|
111
|
+
provider.type === "llama.cpp" ||
|
|
112
|
+
provider.type === "vllm" ||
|
|
113
|
+
provider.type === "lmstudio";
|
|
114
|
+
|
|
115
|
+
if (isAutoDiscovery) {
|
|
116
|
+
try {
|
|
117
|
+
const models = await discoverModels(
|
|
118
|
+
provider.type as AutoDiscoveryProviderType,
|
|
119
|
+
provider.baseUrl,
|
|
120
|
+
provider.apiKey,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const modelsWithProvider = models.map((model) => ({
|
|
124
|
+
...model,
|
|
125
|
+
provider: provider.name,
|
|
126
|
+
}));
|
|
127
|
+
|
|
128
|
+
allCustomModels.push(...modelsWithProvider);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.debug(`Failed to load models from ${provider.name}:`, error);
|
|
131
|
+
}
|
|
132
|
+
} else if (provider.models) {
|
|
133
|
+
// Manual provider - models already defined
|
|
134
|
+
allCustomModels.push(...provider.models);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error("Failed to load custom providers:", error);
|
|
139
|
+
} finally {
|
|
140
|
+
this.customProviderModels = allCustomModels;
|
|
141
|
+
this.customProvidersLoading = false;
|
|
142
|
+
this.requestUpdate();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private formatTokens(tokens: number): string {
|
|
147
|
+
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(0)}M`;
|
|
148
|
+
if (tokens >= 1000) return `${(tokens / 1000).toFixed(0)}`;
|
|
149
|
+
return String(tokens);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private handleSelect(model: Model<any>) {
|
|
153
|
+
if (model) {
|
|
154
|
+
this.onSelectCallback?.(model);
|
|
155
|
+
this.close();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private getFilteredModels(): Array<{ provider: string; id: string; model: any }> {
|
|
160
|
+
// Collect all models from known providers
|
|
161
|
+
const allModels: Array<{ provider: string; id: string; model: any }> = [];
|
|
162
|
+
const knownProviders = getProviders();
|
|
163
|
+
|
|
164
|
+
for (const provider of knownProviders) {
|
|
165
|
+
const models = getModels(provider as any);
|
|
166
|
+
for (const model of models) {
|
|
167
|
+
allModels.push({ provider, id: model.id, model });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Add custom provider models
|
|
172
|
+
for (const model of this.customProviderModels) {
|
|
173
|
+
allModels.push({ provider: model.provider, id: model.id, model });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Filter models based on search and capability filters
|
|
177
|
+
let filteredModels = allModels;
|
|
178
|
+
|
|
179
|
+
// Apply search filter
|
|
180
|
+
if (this.searchQuery) {
|
|
181
|
+
filteredModels = filteredModels.filter(({ provider, id, model }) => {
|
|
182
|
+
const searchTokens = this.searchQuery.split(/\s+/).filter((t) => t);
|
|
183
|
+
const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
|
|
184
|
+
return searchTokens.every((token) => searchText.includes(token));
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Apply capability filters
|
|
189
|
+
if (this.filterThinking) {
|
|
190
|
+
filteredModels = filteredModels.filter(({ model }) => model.reasoning);
|
|
191
|
+
}
|
|
192
|
+
if (this.filterVision) {
|
|
193
|
+
filteredModels = filteredModels.filter(({ model }) => model.input.includes("image"));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Sort: current model first, then by provider
|
|
197
|
+
filteredModels.sort((a, b) => {
|
|
198
|
+
const aIsCurrent = modelsAreEqual(this.currentModel, a.model);
|
|
199
|
+
const bIsCurrent = modelsAreEqual(this.currentModel, b.model);
|
|
200
|
+
if (aIsCurrent && !bIsCurrent) return -1;
|
|
201
|
+
if (!aIsCurrent && bIsCurrent) return 1;
|
|
202
|
+
return a.provider.localeCompare(b.provider);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return filteredModels;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private scrollToSelected() {
|
|
209
|
+
requestAnimationFrame(() => {
|
|
210
|
+
const scrollContainer = this.scrollContainerRef.value;
|
|
211
|
+
const selectedElement = scrollContainer?.querySelectorAll("[data-model-item]")[
|
|
212
|
+
this.selectedIndex
|
|
213
|
+
] as HTMLElement;
|
|
214
|
+
if (selectedElement) {
|
|
215
|
+
selectedElement.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
protected override renderContent(): TemplateResult {
|
|
221
|
+
const filteredModels = this.getFilteredModels();
|
|
222
|
+
|
|
223
|
+
return html`
|
|
224
|
+
<!-- Header and Search -->
|
|
225
|
+
<div class="p-6 pb-4 flex flex-col gap-4 border-b border-border flex-shrink-0">
|
|
226
|
+
${DialogHeader({ title: i18n("Select Model") })}
|
|
227
|
+
${Input({
|
|
228
|
+
placeholder: i18n("Search models..."),
|
|
229
|
+
value: this.searchQuery,
|
|
230
|
+
inputRef: this.searchInputRef,
|
|
231
|
+
onInput: (e: Event) => {
|
|
232
|
+
this.searchQuery = (e.target as HTMLInputElement).value;
|
|
233
|
+
this.selectedIndex = 0;
|
|
234
|
+
// Reset scroll position when search changes
|
|
235
|
+
if (this.scrollContainerRef.value) {
|
|
236
|
+
this.scrollContainerRef.value.scrollTop = 0;
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
})}
|
|
240
|
+
<div class="flex gap-2">
|
|
241
|
+
${Button({
|
|
242
|
+
variant: this.filterThinking ? "default" : "secondary",
|
|
243
|
+
size: "sm",
|
|
244
|
+
onClick: () => {
|
|
245
|
+
this.filterThinking = !this.filterThinking;
|
|
246
|
+
this.selectedIndex = 0;
|
|
247
|
+
if (this.scrollContainerRef.value) {
|
|
248
|
+
this.scrollContainerRef.value.scrollTop = 0;
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
className: "rounded-full",
|
|
252
|
+
children: html`<span class="inline-flex items-center gap-1"
|
|
253
|
+
>${icon(Brain, "sm")} ${i18n("Thinking")}</span
|
|
254
|
+
>`,
|
|
255
|
+
})}
|
|
256
|
+
${Button({
|
|
257
|
+
variant: this.filterVision ? "default" : "secondary",
|
|
258
|
+
size: "sm",
|
|
259
|
+
onClick: () => {
|
|
260
|
+
this.filterVision = !this.filterVision;
|
|
261
|
+
this.selectedIndex = 0;
|
|
262
|
+
if (this.scrollContainerRef.value) {
|
|
263
|
+
this.scrollContainerRef.value.scrollTop = 0;
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
className: "rounded-full",
|
|
267
|
+
children: html`<span class="inline-flex items-center gap-1"
|
|
268
|
+
>${icon(ImageIcon, "sm")} ${i18n("Vision")}</span
|
|
269
|
+
>`,
|
|
270
|
+
})}
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
<!-- Scrollable model list -->
|
|
275
|
+
<div class="flex-1 overflow-y-auto" ${ref(this.scrollContainerRef)}>
|
|
276
|
+
${filteredModels.map(({ provider, id, model }, index) => {
|
|
277
|
+
const isCurrent = modelsAreEqual(this.currentModel, model);
|
|
278
|
+
const isSelected = index === this.selectedIndex;
|
|
279
|
+
return html`
|
|
280
|
+
<div
|
|
281
|
+
data-model-item
|
|
282
|
+
class="px-4 py-3 ${
|
|
283
|
+
this.navigationMode === "mouse" ? "hover:bg-muted" : ""
|
|
284
|
+
} cursor-pointer border-b border-border ${isSelected ? "bg-accent" : ""}"
|
|
285
|
+
@click=${() => this.handleSelect(model)}
|
|
286
|
+
@mouseenter=${() => {
|
|
287
|
+
// Only update selection in mouse mode
|
|
288
|
+
if (this.navigationMode === "mouse") {
|
|
289
|
+
this.selectedIndex = index;
|
|
290
|
+
}
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
<div class="flex items-center justify-between gap-2 mb-1">
|
|
294
|
+
<div class="flex items-center gap-2 flex-1 min-w-0">
|
|
295
|
+
<span class="text-sm font-medium text-foreground truncate">${id}</span>
|
|
296
|
+
${isCurrent ? html`<span class="text-green-500">✓</span>` : ""}
|
|
297
|
+
</div>
|
|
298
|
+
${Badge(provider, "outline")}
|
|
299
|
+
</div>
|
|
300
|
+
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
|
301
|
+
<div class="flex items-center gap-2">
|
|
302
|
+
<span class="${model.reasoning ? "" : "opacity-30"}">${icon(Brain, "sm")}</span>
|
|
303
|
+
<span class="${model.input.includes("image") ? "" : "opacity-30"}">${icon(ImageIcon, "sm")}</span>
|
|
304
|
+
<span>${this.formatTokens(model.contextWindow)}K/${this.formatTokens(model.maxTokens)}K</span>
|
|
305
|
+
</div>
|
|
306
|
+
<span>${formatModelCost(model.cost)}</span>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
`;
|
|
310
|
+
})}
|
|
311
|
+
</div>
|
|
312
|
+
`;
|
|
313
|
+
}
|
|
314
|
+
}
|