@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +609 -0
  3. package/example/README.md +61 -0
  4. package/example/index.html +13 -0
  5. package/example/package.json +24 -0
  6. package/example/src/app.css +1 -0
  7. package/example/src/custom-messages.ts +99 -0
  8. package/example/src/main.ts +420 -0
  9. package/example/tsconfig.json +23 -0
  10. package/example/vite.config.ts +6 -0
  11. package/package.json +57 -0
  12. package/scripts/count-prompt-tokens.ts +88 -0
  13. package/src/ChatPanel.ts +218 -0
  14. package/src/app.css +68 -0
  15. package/src/components/AgentInterface.ts +390 -0
  16. package/src/components/AttachmentTile.ts +107 -0
  17. package/src/components/ConsoleBlock.ts +74 -0
  18. package/src/components/CustomProviderCard.ts +96 -0
  19. package/src/components/ExpandableSection.ts +46 -0
  20. package/src/components/Input.ts +113 -0
  21. package/src/components/MessageEditor.ts +404 -0
  22. package/src/components/MessageList.ts +97 -0
  23. package/src/components/Messages.ts +384 -0
  24. package/src/components/ProviderKeyInput.ts +152 -0
  25. package/src/components/SandboxedIframe.ts +626 -0
  26. package/src/components/StreamingMessageContainer.ts +107 -0
  27. package/src/components/ThinkingBlock.ts +45 -0
  28. package/src/components/message-renderer-registry.ts +28 -0
  29. package/src/components/sandbox/ArtifactsRuntimeProvider.ts +219 -0
  30. package/src/components/sandbox/AttachmentsRuntimeProvider.ts +66 -0
  31. package/src/components/sandbox/ConsoleRuntimeProvider.ts +186 -0
  32. package/src/components/sandbox/FileDownloadRuntimeProvider.ts +110 -0
  33. package/src/components/sandbox/RuntimeMessageBridge.ts +82 -0
  34. package/src/components/sandbox/RuntimeMessageRouter.ts +216 -0
  35. package/src/components/sandbox/SandboxRuntimeProvider.ts +52 -0
  36. package/src/dialogs/ApiKeyPromptDialog.ts +75 -0
  37. package/src/dialogs/AttachmentOverlay.ts +640 -0
  38. package/src/dialogs/CustomProviderDialog.ts +274 -0
  39. package/src/dialogs/ModelSelector.ts +314 -0
  40. package/src/dialogs/PersistentStorageDialog.ts +146 -0
  41. package/src/dialogs/ProvidersModelsTab.ts +212 -0
  42. package/src/dialogs/SessionListDialog.ts +157 -0
  43. package/src/dialogs/SettingsDialog.ts +216 -0
  44. package/src/index.ts +115 -0
  45. package/src/prompts/prompts.ts +282 -0
  46. package/src/storage/app-storage.ts +60 -0
  47. package/src/storage/backends/indexeddb-storage-backend.ts +193 -0
  48. package/src/storage/store.ts +33 -0
  49. package/src/storage/stores/custom-providers-store.ts +62 -0
  50. package/src/storage/stores/provider-keys-store.ts +33 -0
  51. package/src/storage/stores/sessions-store.ts +136 -0
  52. package/src/storage/stores/settings-store.ts +34 -0
  53. package/src/storage/types.ts +206 -0
  54. package/src/tools/artifacts/ArtifactElement.ts +14 -0
  55. package/src/tools/artifacts/ArtifactPill.ts +26 -0
  56. package/src/tools/artifacts/Console.ts +102 -0
  57. package/src/tools/artifacts/DocxArtifact.ts +213 -0
  58. package/src/tools/artifacts/ExcelArtifact.ts +231 -0
  59. package/src/tools/artifacts/GenericArtifact.ts +118 -0
  60. package/src/tools/artifacts/HtmlArtifact.ts +203 -0
  61. package/src/tools/artifacts/ImageArtifact.ts +116 -0
  62. package/src/tools/artifacts/MarkdownArtifact.ts +83 -0
  63. package/src/tools/artifacts/PdfArtifact.ts +201 -0
  64. package/src/tools/artifacts/SvgArtifact.ts +82 -0
  65. package/src/tools/artifacts/TextArtifact.ts +148 -0
  66. package/src/tools/artifacts/artifacts-tool-renderer.ts +371 -0
  67. package/src/tools/artifacts/artifacts.ts +713 -0
  68. package/src/tools/artifacts/index.ts +7 -0
  69. package/src/tools/extract-document.ts +271 -0
  70. package/src/tools/index.ts +46 -0
  71. package/src/tools/javascript-repl.ts +316 -0
  72. package/src/tools/renderer-registry.ts +127 -0
  73. package/src/tools/renderers/BashRenderer.ts +52 -0
  74. package/src/tools/renderers/CalculateRenderer.ts +58 -0
  75. package/src/tools/renderers/DefaultRenderer.ts +95 -0
  76. package/src/tools/renderers/GetCurrentTimeRenderer.ts +92 -0
  77. package/src/tools/types.ts +15 -0
  78. package/src/utils/attachment-utils.ts +472 -0
  79. package/src/utils/auth-token.ts +22 -0
  80. package/src/utils/format.ts +42 -0
  81. package/src/utils/i18n.ts +653 -0
  82. package/src/utils/model-discovery.ts +277 -0
  83. package/src/utils/proxy-utils.ts +134 -0
  84. package/src/utils/test-sessions.ts +2357 -0
  85. package/tsconfig.build.json +20 -0
  86. package/tsconfig.json +7 -0
@@ -0,0 +1,384 @@
1
+ import type {
2
+ AssistantMessage as AssistantMessageType,
3
+ ImageContent,
4
+ TextContent,
5
+ ToolCall,
6
+ ToolResultMessage as ToolResultMessageType,
7
+ UserMessage as UserMessageType,
8
+ } from "@oh-my-pi/pi-ai";
9
+ import { html, LitElement, type TemplateResult } from "lit";
10
+ import { customElement, property } from "lit/decorators.js";
11
+ import { renderTool } from "../tools/index.js";
12
+ import type { Attachment } from "../utils/attachment-utils.js";
13
+ import { formatUsage } from "../utils/format.js";
14
+ import { i18n } from "../utils/i18n.js";
15
+ import "./ThinkingBlock.js";
16
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
17
+
18
+ export type UserMessageWithAttachments = {
19
+ role: "user-with-attachments";
20
+ content: string | (TextContent | ImageContent)[];
21
+ timestamp: number;
22
+ attachments?: Attachment[];
23
+ };
24
+
25
+ // Artifact message type for session persistence
26
+ export interface ArtifactMessage {
27
+ role: "artifact";
28
+ action: "create" | "update" | "delete";
29
+ filename: string;
30
+ content?: string;
31
+ title?: string;
32
+ timestamp: string;
33
+ }
34
+
35
+ declare module "@oh-my-pi/pi-agent-core" {
36
+ interface CustomAgentMessages {
37
+ "user-with-attachments": UserMessageWithAttachments;
38
+ artifact: ArtifactMessage;
39
+ }
40
+ }
41
+
42
+ @customElement("user-message")
43
+ export class UserMessage extends LitElement {
44
+ @property({ type: Object }) message!: UserMessageWithAttachments | UserMessageType;
45
+
46
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
47
+ return this;
48
+ }
49
+
50
+ override connectedCallback(): void {
51
+ super.connectedCallback();
52
+ this.style.display = "block";
53
+ }
54
+
55
+ override render() {
56
+ const content =
57
+ typeof this.message.content === "string"
58
+ ? this.message.content
59
+ : this.message.content.find((c) => c.type === "text")?.text || "";
60
+
61
+ return html`
62
+ <div class="flex justify-start mx-4">
63
+ <div class="user-message-container py-2 px-4 rounded-xl">
64
+ <markdown-block .content=${content}></markdown-block>
65
+ ${
66
+ this.message.role === "user-with-attachments" &&
67
+ this.message.attachments &&
68
+ this.message.attachments.length > 0
69
+ ? html`
70
+ <div class="mt-3 flex flex-wrap gap-2">
71
+ ${this.message.attachments.map(
72
+ (attachment) => html` <attachment-tile .attachment=${attachment}></attachment-tile> `,
73
+ )}
74
+ </div>
75
+ `
76
+ : ""
77
+ }
78
+ </div>
79
+ </div>
80
+ `;
81
+ }
82
+ }
83
+
84
+ @customElement("assistant-message")
85
+ export class AssistantMessage extends LitElement {
86
+ @property({ type: Object }) message!: AssistantMessageType;
87
+ @property({ type: Array }) tools?: AgentTool<any>[];
88
+ @property({ type: Object }) pendingToolCalls?: Set<string>;
89
+ @property({ type: Boolean }) hideToolCalls = false;
90
+ @property({ type: Object }) toolResultsById?: Map<string, ToolResultMessageType>;
91
+ @property({ type: Boolean }) isStreaming: boolean = false;
92
+ @property({ attribute: false }) onCostClick?: () => void;
93
+
94
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
95
+ return this;
96
+ }
97
+
98
+ override connectedCallback(): void {
99
+ super.connectedCallback();
100
+ this.style.display = "block";
101
+ }
102
+
103
+ override render() {
104
+ // Render content in the order it appears
105
+ const orderedParts: TemplateResult[] = [];
106
+
107
+ for (const chunk of this.message.content) {
108
+ if (chunk.type === "text" && chunk.text.trim() !== "") {
109
+ orderedParts.push(html`<markdown-block .content=${chunk.text}></markdown-block>`);
110
+ } else if (chunk.type === "thinking" && chunk.thinking.trim() !== "") {
111
+ orderedParts.push(
112
+ html`<thinking-block .content=${chunk.thinking} .isStreaming=${this.isStreaming}></thinking-block>`,
113
+ );
114
+ } else if (chunk.type === "toolCall") {
115
+ if (!this.hideToolCalls) {
116
+ const tool = this.tools?.find((t) => t.name === chunk.name);
117
+ const pending = this.pendingToolCalls?.has(chunk.id) ?? false;
118
+ const result = this.toolResultsById?.get(chunk.id);
119
+ // A tool call is aborted if the message was aborted and there's no result for this tool call
120
+ const aborted = this.message.stopReason === "aborted" && !result;
121
+ orderedParts.push(
122
+ html`<tool-message
123
+ .tool=${tool}
124
+ .toolCall=${chunk}
125
+ .result=${result}
126
+ .pending=${pending}
127
+ .aborted=${aborted}
128
+ .isStreaming=${this.isStreaming}
129
+ ></tool-message>`,
130
+ );
131
+ }
132
+ }
133
+ }
134
+
135
+ return html`
136
+ <div>
137
+ ${orderedParts.length ? html` <div class="px-4 flex flex-col gap-3">${orderedParts}</div> ` : ""}
138
+ ${
139
+ this.message.usage && !this.isStreaming
140
+ ? this.onCostClick
141
+ ? html`
142
+ <div
143
+ class="px-4 mt-2 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
144
+ @click=${this.onCostClick}
145
+ >
146
+ ${formatUsage(this.message.usage)}
147
+ </div>
148
+ `
149
+ : html` <div class="px-4 mt-2 text-xs text-muted-foreground">${formatUsage(this.message.usage)}</div> `
150
+ : ""
151
+ }
152
+ ${
153
+ this.message.stopReason === "error" && this.message.errorMessage
154
+ ? html`
155
+ <div class="mx-4 mt-3 p-3 bg-destructive/10 text-destructive rounded-lg text-sm overflow-hidden">
156
+ <strong>${i18n("Error:")}</strong> ${this.message.errorMessage}
157
+ </div>
158
+ `
159
+ : ""
160
+ }
161
+ ${
162
+ this.message.stopReason === "aborted"
163
+ ? html`<span class="text-sm text-destructive italic">${i18n("Request aborted")}</span>`
164
+ : ""
165
+ }
166
+ </div>
167
+ `;
168
+ }
169
+ }
170
+
171
+ @customElement("tool-message-debug")
172
+ export class ToolMessageDebugView extends LitElement {
173
+ @property({ type: Object }) callArgs: any;
174
+ @property({ type: Object }) result?: ToolResultMessageType;
175
+ @property({ type: Boolean }) hasResult: boolean = false;
176
+
177
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
178
+ return this; // light DOM for shared styles
179
+ }
180
+
181
+ override connectedCallback(): void {
182
+ super.connectedCallback();
183
+ this.style.display = "block";
184
+ }
185
+
186
+ private pretty(value: unknown): { content: string; isJson: boolean } {
187
+ try {
188
+ if (typeof value === "string") {
189
+ const maybeJson = JSON.parse(value);
190
+ return { content: JSON.stringify(maybeJson, null, 2), isJson: true };
191
+ }
192
+ return { content: JSON.stringify(value, null, 2), isJson: true };
193
+ } catch {
194
+ return { content: typeof value === "string" ? value : String(value), isJson: false };
195
+ }
196
+ }
197
+
198
+ override render() {
199
+ const textOutput =
200
+ this.result?.content
201
+ ?.filter((c) => c.type === "text")
202
+ .map((c: any) => c.text)
203
+ .join("\n") || "";
204
+ const output = this.pretty(textOutput);
205
+ const details = this.pretty(this.result?.details);
206
+
207
+ return html`
208
+ <div class="mt-3 flex flex-col gap-2">
209
+ <div>
210
+ <div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Call")}</div>
211
+ <code-block .code=${this.pretty(this.callArgs).content} language="json"></code-block>
212
+ </div>
213
+ <div>
214
+ <div class="text-xs font-medium mb-1 text-muted-foreground">${i18n("Result")}</div>
215
+ ${
216
+ this.hasResult
217
+ ? html`<code-block .code=${output.content} language="${output.isJson ? "json" : "text"}"></code-block>
218
+ <code-block .code=${details.content} language="${details.isJson ? "json" : "text"}"></code-block>`
219
+ : html`<div class="text-xs text-muted-foreground">${i18n("(no result)")}</div>`
220
+ }
221
+ </div>
222
+ </div>
223
+ `;
224
+ }
225
+ }
226
+
227
+ @customElement("tool-message")
228
+ export class ToolMessage extends LitElement {
229
+ @property({ type: Object }) toolCall!: ToolCall;
230
+ @property({ type: Object }) tool?: AgentTool<any>;
231
+ @property({ type: Object }) result?: ToolResultMessageType;
232
+ @property({ type: Boolean }) pending: boolean = false;
233
+ @property({ type: Boolean }) aborted: boolean = false;
234
+ @property({ type: Boolean }) isStreaming: boolean = false;
235
+
236
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
237
+ return this;
238
+ }
239
+
240
+ override connectedCallback(): void {
241
+ super.connectedCallback();
242
+ this.style.display = "block";
243
+ }
244
+
245
+ override render() {
246
+ const toolName = this.tool?.name || this.toolCall.name;
247
+
248
+ // Render tool content (renderer handles errors and styling)
249
+ const result: ToolResultMessageType<any> | undefined = this.aborted
250
+ ? {
251
+ role: "toolResult",
252
+ isError: true,
253
+ content: [],
254
+ toolCallId: this.toolCall.id,
255
+ toolName: this.toolCall.name,
256
+ timestamp: Date.now(),
257
+ }
258
+ : this.result;
259
+ const renderResult = renderTool(
260
+ toolName,
261
+ this.toolCall.arguments,
262
+ result,
263
+ !this.aborted && (this.isStreaming || this.pending),
264
+ );
265
+
266
+ // Handle custom rendering (no card wrapper)
267
+ if (renderResult.isCustom) {
268
+ return renderResult.content;
269
+ }
270
+
271
+ // Default: wrap in card
272
+ return html`
273
+ <div class="p-2.5 border border-border rounded-md bg-card text-card-foreground shadow-xs">
274
+ ${renderResult.content}
275
+ </div>
276
+ `;
277
+ }
278
+ }
279
+
280
+ @customElement("aborted-message")
281
+ export class AbortedMessage extends LitElement {
282
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
283
+ return this;
284
+ }
285
+
286
+ override connectedCallback(): void {
287
+ super.connectedCallback();
288
+ this.style.display = "block";
289
+ }
290
+
291
+ protected override render(): unknown {
292
+ return html`<span class="text-sm text-destructive italic">${i18n("Request aborted")}</span>`;
293
+ }
294
+ }
295
+
296
+ // ============================================================================
297
+ // Default Message Transformer
298
+ // ============================================================================
299
+
300
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
301
+ import type { Message } from "@oh-my-pi/pi-ai";
302
+
303
+ /**
304
+ * Convert attachments to content blocks for LLM.
305
+ * - Images become ImageContent blocks
306
+ * - Documents with extractedText become TextContent blocks with filename header
307
+ */
308
+ export function convertAttachments(attachments: Attachment[]): (TextContent | ImageContent)[] {
309
+ const content: (TextContent | ImageContent)[] = [];
310
+ for (const attachment of attachments) {
311
+ if (attachment.type === "image") {
312
+ content.push({
313
+ type: "image",
314
+ data: attachment.content,
315
+ mimeType: attachment.mimeType,
316
+ } as ImageContent);
317
+ } else if (attachment.type === "document" && attachment.extractedText) {
318
+ content.push({
319
+ type: "text",
320
+ text: `\n\n[Document: ${attachment.fileName}]\n${attachment.extractedText}`,
321
+ } as TextContent);
322
+ }
323
+ }
324
+ return content;
325
+ }
326
+
327
+ /**
328
+ * Check if a message is a UserMessageWithAttachments.
329
+ */
330
+ export function isUserMessageWithAttachments(msg: AgentMessage): msg is UserMessageWithAttachments {
331
+ return (msg as UserMessageWithAttachments).role === "user-with-attachments";
332
+ }
333
+
334
+ /**
335
+ * Check if a message is an ArtifactMessage.
336
+ */
337
+ export function isArtifactMessage(msg: AgentMessage): msg is ArtifactMessage {
338
+ return (msg as ArtifactMessage).role === "artifact";
339
+ }
340
+
341
+ /**
342
+ * Default convertToLlm for web-ui apps.
343
+ *
344
+ * Handles:
345
+ * - UserMessageWithAttachments: converts to user message with content blocks
346
+ * - ArtifactMessage: filtered out (UI-only, for session reconstruction)
347
+ * - Standard LLM messages (user, assistant, toolResult): passed through
348
+ */
349
+ export function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
350
+ return messages
351
+ .filter((m) => {
352
+ // Filter out artifact messages - they're for session reconstruction only
353
+ if (isArtifactMessage(m)) {
354
+ return false;
355
+ }
356
+ return true;
357
+ })
358
+ .map((m): Message | null => {
359
+ // Convert user-with-attachments to user message with content blocks
360
+ if (isUserMessageWithAttachments(m)) {
361
+ const textContent: (TextContent | ImageContent)[] =
362
+ typeof m.content === "string" ? [{ type: "text", text: m.content }] : [...m.content];
363
+
364
+ if (m.attachments) {
365
+ textContent.push(...convertAttachments(m.attachments));
366
+ }
367
+
368
+ return {
369
+ role: "user",
370
+ content: textContent,
371
+ timestamp: m.timestamp,
372
+ } as Message;
373
+ }
374
+
375
+ // Pass through standard LLM roles
376
+ if (m.role === "user" || m.role === "assistant" || m.role === "toolResult") {
377
+ return m as Message;
378
+ }
379
+
380
+ // Filter out unknown message types
381
+ return null;
382
+ })
383
+ .filter((m): m is Message => m !== null);
384
+ }
@@ -0,0 +1,152 @@
1
+ import { i18n } 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 { type Context, complete, getModel } from "@oh-my-pi/pi-ai";
5
+ import { html, LitElement } from "lit";
6
+ import { customElement, property, state } from "lit/decorators.js";
7
+ import { getAppStorage } from "../storage/app-storage.js";
8
+ import { applyProxyIfNeeded } from "../utils/proxy-utils.js";
9
+ import { Input } from "./Input.js";
10
+
11
+ // Test models for each provider
12
+ const TEST_MODELS: Record<string, string> = {
13
+ anthropic: "claude-3-5-haiku-20241022",
14
+ openai: "gpt-4o-mini",
15
+ google: "gemini-2.5-flash",
16
+ groq: "openai/gpt-oss-20b",
17
+ openrouter: "z-ai/glm-4.6",
18
+ cerebras: "gpt-oss-120b",
19
+ xai: "grok-4-fast-non-reasoning",
20
+ zai: "glm-4.5-air",
21
+ };
22
+
23
+ @customElement("provider-key-input")
24
+ export class ProviderKeyInput extends LitElement {
25
+ @property() provider = "";
26
+ @state() private keyInput = "";
27
+ @state() private testing = false;
28
+ @state() private failed = false;
29
+ @state() private hasKey = false;
30
+ @state() private inputChanged = false;
31
+
32
+ protected createRenderRoot() {
33
+ return this;
34
+ }
35
+
36
+ override async connectedCallback() {
37
+ super.connectedCallback();
38
+ await this.checkKeyStatus();
39
+ }
40
+
41
+ private async checkKeyStatus() {
42
+ try {
43
+ const key = await getAppStorage().providerKeys.get(this.provider);
44
+ this.hasKey = !!key;
45
+ } catch (error) {
46
+ console.error("Failed to check key status:", error);
47
+ }
48
+ }
49
+
50
+ private async testApiKey(provider: string, apiKey: string): Promise<boolean> {
51
+ try {
52
+ const modelId = TEST_MODELS[provider];
53
+ // Returning true here for Ollama and friends. Can' know which model to use for testing
54
+ if (!modelId) return true;
55
+
56
+ let model = getModel(provider as any, modelId);
57
+ if (!model) return false;
58
+
59
+ // Get proxy URL from settings (if available)
60
+ const proxyEnabled = await getAppStorage().settings.get<boolean>("proxy.enabled");
61
+ const proxyUrl = await getAppStorage().settings.get<string>("proxy.url");
62
+
63
+ // Apply proxy only if this provider/key combination requires it
64
+ model = applyProxyIfNeeded(model, apiKey, proxyEnabled ? proxyUrl || undefined : undefined);
65
+
66
+ const context: Context = {
67
+ messages: [{ role: "user", content: "Reply with: ok", timestamp: Date.now() }],
68
+ };
69
+
70
+ const result = await complete(model, context, {
71
+ apiKey,
72
+ maxTokens: 200,
73
+ } as any);
74
+
75
+ return result.stopReason === "stop";
76
+ } catch (error) {
77
+ console.error(`API key test failed for ${provider}:`, error);
78
+ return false;
79
+ }
80
+ }
81
+
82
+ private async saveKey() {
83
+ if (!this.keyInput) return;
84
+
85
+ this.testing = true;
86
+ this.failed = false;
87
+
88
+ const success = await this.testApiKey(this.provider, this.keyInput);
89
+
90
+ this.testing = false;
91
+
92
+ if (success) {
93
+ try {
94
+ await getAppStorage().providerKeys.set(this.provider, this.keyInput);
95
+ this.hasKey = true;
96
+ this.inputChanged = false;
97
+ this.requestUpdate();
98
+ } catch (error) {
99
+ console.error("Failed to save API key:", error);
100
+ this.failed = true;
101
+ setTimeout(() => {
102
+ this.failed = false;
103
+ this.requestUpdate();
104
+ }, 5000);
105
+ }
106
+ } else {
107
+ this.failed = true;
108
+ setTimeout(() => {
109
+ this.failed = false;
110
+ this.requestUpdate();
111
+ }, 5000);
112
+ }
113
+ }
114
+
115
+ render() {
116
+ return html`
117
+ <div class="space-y-3">
118
+ <div class="flex items-center gap-2">
119
+ <span class="text-sm font-medium capitalize text-foreground">${this.provider}</span>
120
+ ${
121
+ this.testing
122
+ ? Badge({ children: i18n("Testing..."), variant: "secondary" })
123
+ : this.hasKey
124
+ ? html`<span class="text-green-600 dark:text-green-400">✓</span>`
125
+ : ""
126
+ }
127
+ ${this.failed ? Badge({ children: i18n("✗ Invalid"), variant: "destructive" }) : ""}
128
+ </div>
129
+ <div class="flex items-center gap-2">
130
+ ${Input({
131
+ type: "password",
132
+ placeholder: this.hasKey ? "••••••••••••" : i18n("Enter API key"),
133
+ value: this.keyInput,
134
+ onInput: (e: Event) => {
135
+ this.keyInput = (e.target as HTMLInputElement).value;
136
+ this.inputChanged = true;
137
+ this.requestUpdate();
138
+ },
139
+ className: "flex-1",
140
+ })}
141
+ ${Button({
142
+ onClick: () => this.saveKey(),
143
+ variant: "default",
144
+ size: "sm",
145
+ disabled: !this.keyInput || this.testing || (this.hasKey && !this.inputChanged),
146
+ children: i18n("Save"),
147
+ })}
148
+ </div>
149
+ </div>
150
+ `;
151
+ }
152
+ }