@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,390 @@
1
+ import { streamSimple, type ToolResultMessage, type Usage } from "@oh-my-pi/pi-ai";
2
+ import { html, LitElement } from "lit";
3
+ import { customElement, property, query } from "lit/decorators.js";
4
+ import { ModelSelector } from "../dialogs/ModelSelector.js";
5
+ import type { MessageEditor } from "./MessageEditor.js";
6
+ import "./MessageEditor.js";
7
+ import "./MessageList.js";
8
+ import "./Messages.js"; // Import for side effects to register the custom elements
9
+ import { getAppStorage } from "../storage/app-storage.js";
10
+ import "./StreamingMessageContainer.js";
11
+ import type { Agent, AgentEvent } from "@oh-my-pi/pi-agent-core";
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 { createStreamFn } from "../utils/proxy-utils.js";
16
+ import type { UserMessageWithAttachments } from "./Messages.js";
17
+ import type { StreamingMessageContainer } from "./StreamingMessageContainer.js";
18
+
19
+ @customElement("agent-interface")
20
+ export class AgentInterface extends LitElement {
21
+ // Optional external session: when provided, this component becomes a view over the session
22
+ @property({ attribute: false }) session?: Agent;
23
+ @property({ type: Boolean }) enableAttachments = true;
24
+ @property({ type: Boolean }) enableModelSelector = true;
25
+ @property({ type: Boolean }) enableThinkingSelector = true;
26
+ @property({ type: Boolean }) showThemeToggle = false;
27
+ // Optional custom API key prompt handler - if not provided, uses default dialog
28
+ @property({ attribute: false }) onApiKeyRequired?: (provider: string) => Promise<boolean>;
29
+ // Optional callback called before sending a message
30
+ @property({ attribute: false }) onBeforeSend?: () => void | Promise<void>;
31
+ // Optional callback called before executing a tool call - return false to prevent execution
32
+ @property({ attribute: false }) onBeforeToolCall?: (toolName: string, args: any) => boolean | Promise<boolean>;
33
+ // Optional callback called when cost display is clicked
34
+ @property({ attribute: false }) onCostClick?: () => void;
35
+
36
+ // References
37
+ @query("message-editor") private _messageEditor!: MessageEditor;
38
+ @query("streaming-message-container") private _streamingContainer!: StreamingMessageContainer;
39
+
40
+ private _autoScroll = true;
41
+ private _lastScrollTop = 0;
42
+ private _lastClientHeight = 0;
43
+ private _scrollContainer?: HTMLElement;
44
+ private _resizeObserver?: ResizeObserver;
45
+ private _unsubscribeSession?: () => void;
46
+
47
+ public setInput(text: string, attachments?: Attachment[]) {
48
+ const update = () => {
49
+ if (!this._messageEditor) requestAnimationFrame(update);
50
+ else {
51
+ this._messageEditor.value = text;
52
+ this._messageEditor.attachments = attachments || [];
53
+ }
54
+ };
55
+ update();
56
+ }
57
+
58
+ public setAutoScroll(enabled: boolean) {
59
+ this._autoScroll = enabled;
60
+ }
61
+
62
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
63
+ return this;
64
+ }
65
+
66
+ override willUpdate(changedProperties: Map<string, any>) {
67
+ super.willUpdate(changedProperties);
68
+
69
+ // Re-subscribe when session property changes
70
+ if (changedProperties.has("session")) {
71
+ this.setupSessionSubscription();
72
+ }
73
+ }
74
+
75
+ override async connectedCallback() {
76
+ super.connectedCallback();
77
+
78
+ this.style.display = "flex";
79
+ this.style.flexDirection = "column";
80
+ this.style.height = "100%";
81
+ this.style.minHeight = "0";
82
+
83
+ // Wait for first render to get scroll container
84
+ await this.updateComplete;
85
+ this._scrollContainer = this.querySelector(".overflow-y-auto") as HTMLElement;
86
+
87
+ if (this._scrollContainer) {
88
+ // Set up ResizeObserver to detect content changes
89
+ this._resizeObserver = new ResizeObserver(() => {
90
+ if (this._autoScroll && this._scrollContainer) {
91
+ this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight;
92
+ }
93
+ });
94
+
95
+ // Observe the content container inside the scroll container
96
+ const contentContainer = this._scrollContainer.querySelector(".max-w-3xl");
97
+ if (contentContainer) {
98
+ this._resizeObserver.observe(contentContainer);
99
+ }
100
+
101
+ // Set up scroll listener with better detection
102
+ this._scrollContainer.addEventListener("scroll", this._handleScroll);
103
+ }
104
+
105
+ // Subscribe to external session if provided
106
+ this.setupSessionSubscription();
107
+ }
108
+
109
+ override disconnectedCallback() {
110
+ super.disconnectedCallback();
111
+
112
+ // Clean up observers and listeners
113
+ if (this._resizeObserver) {
114
+ this._resizeObserver.disconnect();
115
+ this._resizeObserver = undefined;
116
+ }
117
+
118
+ if (this._scrollContainer) {
119
+ this._scrollContainer.removeEventListener("scroll", this._handleScroll);
120
+ }
121
+
122
+ if (this._unsubscribeSession) {
123
+ this._unsubscribeSession();
124
+ this._unsubscribeSession = undefined;
125
+ }
126
+ }
127
+
128
+ private setupSessionSubscription() {
129
+ if (this._unsubscribeSession) {
130
+ this._unsubscribeSession();
131
+ this._unsubscribeSession = undefined;
132
+ }
133
+ if (!this.session) return;
134
+
135
+ // Set default streamFn with proxy support if not already set
136
+ if (this.session.streamFn === streamSimple) {
137
+ this.session.streamFn = createStreamFn(async () => {
138
+ const enabled = await getAppStorage().settings.get<boolean>("proxy.enabled");
139
+ return enabled ? (await getAppStorage().settings.get<string>("proxy.url")) || undefined : undefined;
140
+ });
141
+ }
142
+
143
+ // Set default getApiKey if not already set
144
+ if (!this.session.getApiKey) {
145
+ this.session.getApiKey = async (provider: string) => {
146
+ const key = await getAppStorage().providerKeys.get(provider);
147
+ return key ?? undefined;
148
+ };
149
+ }
150
+
151
+ this._unsubscribeSession = this.session.subscribe(async (ev: AgentEvent) => {
152
+ switch (ev.type) {
153
+ case "message_start":
154
+ case "message_end":
155
+ case "turn_start":
156
+ case "turn_end":
157
+ case "agent_start":
158
+ this.requestUpdate();
159
+ break;
160
+ case "agent_end":
161
+ // Clear streaming container when agent finishes
162
+ if (this._streamingContainer) {
163
+ this._streamingContainer.isStreaming = false;
164
+ this._streamingContainer.setMessage(null, true);
165
+ }
166
+ this.requestUpdate();
167
+ break;
168
+ case "message_update":
169
+ if (this._streamingContainer) {
170
+ const isStreaming = this.session?.state.isStreaming || false;
171
+ this._streamingContainer.isStreaming = isStreaming;
172
+ this._streamingContainer.setMessage(ev.message, !isStreaming);
173
+ }
174
+ this.requestUpdate();
175
+ break;
176
+ }
177
+ });
178
+ }
179
+
180
+ private _handleScroll = (_ev: any) => {
181
+ if (!this._scrollContainer) return;
182
+
183
+ const currentScrollTop = this._scrollContainer.scrollTop;
184
+ const scrollHeight = this._scrollContainer.scrollHeight;
185
+ const clientHeight = this._scrollContainer.clientHeight;
186
+ const distanceFromBottom = scrollHeight - currentScrollTop - clientHeight;
187
+
188
+ // Ignore relayout due to message editor getting pushed up by stats
189
+ if (clientHeight < this._lastClientHeight) {
190
+ this._lastClientHeight = clientHeight;
191
+ return;
192
+ }
193
+
194
+ // Only disable auto-scroll if user scrolled UP or is far from bottom
195
+ if (currentScrollTop !== 0 && currentScrollTop < this._lastScrollTop && distanceFromBottom > 50) {
196
+ this._autoScroll = false;
197
+ } else if (distanceFromBottom < 10) {
198
+ // Re-enable if very close to bottom
199
+ this._autoScroll = true;
200
+ }
201
+
202
+ this._lastScrollTop = currentScrollTop;
203
+ this._lastClientHeight = clientHeight;
204
+ };
205
+
206
+ public async sendMessage(input: string, attachments?: Attachment[]) {
207
+ if ((!input.trim() && attachments?.length === 0) || this.session?.state.isStreaming) return;
208
+ const session = this.session;
209
+ if (!session) throw new Error("No session set on AgentInterface");
210
+ if (!session.state.model) throw new Error("No model set on AgentInterface");
211
+
212
+ // Check if API key exists for the provider (only needed in direct mode)
213
+ const provider = session.state.model.provider;
214
+ const apiKey = await getAppStorage().providerKeys.get(provider);
215
+
216
+ // If no API key, prompt for it
217
+ if (!apiKey) {
218
+ if (!this.onApiKeyRequired) {
219
+ console.error("No API key configured and no onApiKeyRequired handler set");
220
+ return;
221
+ }
222
+
223
+ const success = await this.onApiKeyRequired(provider);
224
+
225
+ // If still no API key, abort the send
226
+ if (!success) {
227
+ return;
228
+ }
229
+ }
230
+
231
+ // Call onBeforeSend hook before sending
232
+ if (this.onBeforeSend) {
233
+ await this.onBeforeSend();
234
+ }
235
+
236
+ // Only clear editor after we know we can send
237
+ this._messageEditor.value = "";
238
+ this._messageEditor.attachments = [];
239
+ this._autoScroll = true; // Enable auto-scroll when sending a message
240
+
241
+ // Compose message with attachments if any
242
+ if (attachments && attachments.length > 0) {
243
+ const message: UserMessageWithAttachments = {
244
+ role: "user-with-attachments",
245
+ content: input,
246
+ attachments,
247
+ timestamp: Date.now(),
248
+ };
249
+ await this.session?.prompt(message);
250
+ } else {
251
+ await this.session?.prompt(input);
252
+ }
253
+ }
254
+
255
+ private renderMessages() {
256
+ if (!this.session)
257
+ return html`<div class="p-4 text-center text-muted-foreground">${i18n("No session available")}</div>`;
258
+ const state = this.session.state;
259
+ // Build a map of tool results to allow inline rendering in assistant messages
260
+ const toolResultsById = new Map<string, ToolResultMessage<any>>();
261
+ for (const message of state.messages) {
262
+ if (message.role === "toolResult") {
263
+ toolResultsById.set(message.toolCallId, message);
264
+ }
265
+ }
266
+ return html`
267
+ <div class="flex flex-col gap-3">
268
+ <!-- Stable messages list - won't re-render during streaming -->
269
+ <message-list
270
+ .messages=${this.session.state.messages}
271
+ .tools=${state.tools}
272
+ .pendingToolCalls=${this.session ? this.session.state.pendingToolCalls : new Set<string>()}
273
+ .isStreaming=${state.isStreaming}
274
+ .onCostClick=${this.onCostClick}
275
+ ></message-list>
276
+
277
+ <!-- Streaming message container - manages its own updates -->
278
+ <streaming-message-container
279
+ class="${state.isStreaming ? "" : "hidden"}"
280
+ .tools=${state.tools}
281
+ .isStreaming=${state.isStreaming}
282
+ .pendingToolCalls=${state.pendingToolCalls}
283
+ .toolResultsById=${toolResultsById}
284
+ .onCostClick=${this.onCostClick}
285
+ ></streaming-message-container>
286
+ </div>
287
+ `;
288
+ }
289
+
290
+ private renderStats() {
291
+ if (!this.session) return html`<div class="text-xs h-5"></div>`;
292
+
293
+ const state = this.session.state;
294
+ const totals = state.messages
295
+ .filter((m) => m.role === "assistant")
296
+ .reduce(
297
+ (acc, msg: any) => {
298
+ const usage = msg.usage;
299
+ if (usage) {
300
+ acc.input += usage.input;
301
+ acc.output += usage.output;
302
+ acc.cacheRead += usage.cacheRead;
303
+ acc.cacheWrite += usage.cacheWrite;
304
+ acc.cost.total += usage.cost.total;
305
+ }
306
+ return acc;
307
+ },
308
+ {
309
+ input: 0,
310
+ output: 0,
311
+ cacheRead: 0,
312
+ cacheWrite: 0,
313
+ totalTokens: 0,
314
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
315
+ } satisfies Usage,
316
+ );
317
+
318
+ const hasTotals = totals.input || totals.output || totals.cacheRead || totals.cacheWrite;
319
+ const totalsText = hasTotals ? formatUsage(totals) : "";
320
+
321
+ return html`
322
+ <div class="text-xs text-muted-foreground flex justify-between items-center h-5">
323
+ <div class="flex items-center gap-1">
324
+ ${this.showThemeToggle ? html`<theme-toggle></theme-toggle>` : html``}
325
+ </div>
326
+ <div class="flex ml-auto items-center gap-3">
327
+ ${
328
+ totalsText
329
+ ? this.onCostClick
330
+ ? html`<span class="cursor-pointer hover:text-foreground transition-colors" @click=${this.onCostClick}
331
+ >${totalsText}</span
332
+ >`
333
+ : html`<span>${totalsText}</span>`
334
+ : ""
335
+ }
336
+ </div>
337
+ </div>
338
+ `;
339
+ }
340
+
341
+ override render() {
342
+ if (!this.session)
343
+ return html`<div class="p-4 text-center text-muted-foreground">${i18n("No session set")}</div>`;
344
+
345
+ const session = this.session;
346
+ const state = this.session.state;
347
+ return html`
348
+ <div class="flex flex-col h-full bg-background text-foreground">
349
+ <!-- Messages Area -->
350
+ <div class="flex-1 overflow-y-auto">
351
+ <div class="max-w-3xl mx-auto p-4 pb-0">${this.renderMessages()}</div>
352
+ </div>
353
+
354
+ <!-- Input Area -->
355
+ <div class="shrink-0">
356
+ <div class="max-w-3xl mx-auto px-2">
357
+ <message-editor
358
+ .isStreaming=${state.isStreaming}
359
+ .currentModel=${state.model}
360
+ .thinkingLevel=${state.thinkingLevel}
361
+ .showAttachmentButton=${this.enableAttachments}
362
+ .showModelSelector=${this.enableModelSelector}
363
+ .showThinkingSelector=${this.enableThinkingSelector}
364
+ .onSend=${(input: string, attachments: Attachment[]) => {
365
+ this.sendMessage(input, attachments);
366
+ }}
367
+ .onAbort=${() => session.abort()}
368
+ .onModelSelect=${() => {
369
+ ModelSelector.open(state.model, (model) => session.setModel(model));
370
+ }}
371
+ .onThinkingChange=${
372
+ this.enableThinkingSelector
373
+ ? (level: "off" | "minimal" | "low" | "medium" | "high") => {
374
+ session.setThinkingLevel(level);
375
+ }
376
+ : undefined
377
+ }
378
+ ></message-editor>
379
+ ${this.renderStats()}
380
+ </div>
381
+ </div>
382
+ </div>
383
+ `;
384
+ }
385
+ }
386
+
387
+ // Register custom element with guard
388
+ if (!customElements.get("agent-interface")) {
389
+ customElements.define("agent-interface", AgentInterface);
390
+ }
@@ -0,0 +1,107 @@
1
+ import { icon } from "@mariozechner/mini-lit/dist/icons.js";
2
+ import { LitElement } from "lit";
3
+ import { customElement, property } from "lit/decorators.js";
4
+ import { html } from "lit/html.js";
5
+ import { FileSpreadsheet, FileText, X } from "lucide";
6
+ import { AttachmentOverlay } from "../dialogs/AttachmentOverlay.js";
7
+ import type { Attachment } from "../utils/attachment-utils.js";
8
+ import { i18n } from "../utils/i18n.js";
9
+
10
+ @customElement("attachment-tile")
11
+ export class AttachmentTile extends LitElement {
12
+ @property({ type: Object }) attachment!: Attachment;
13
+ @property({ type: Boolean }) showDelete = false;
14
+ @property() onDelete?: () => void;
15
+
16
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
17
+ return this;
18
+ }
19
+
20
+ override connectedCallback(): void {
21
+ super.connectedCallback();
22
+ this.style.display = "block";
23
+ this.classList.add("max-h-16");
24
+ }
25
+
26
+ private handleClick = () => {
27
+ AttachmentOverlay.open(this.attachment);
28
+ };
29
+
30
+ override render() {
31
+ const hasPreview = !!this.attachment.preview;
32
+ const isImage = this.attachment.type === "image";
33
+ const isPdf = this.attachment.mimeType === "application/pdf";
34
+ const isExcel =
35
+ this.attachment.mimeType?.includes("spreadsheetml") ||
36
+ this.attachment.fileName.toLowerCase().endsWith(".xlsx") ||
37
+ this.attachment.fileName.toLowerCase().endsWith(".xls");
38
+
39
+ // Choose the appropriate icon
40
+ const getDocumentIcon = () => {
41
+ if (isExcel) return icon(FileSpreadsheet, "md");
42
+ return icon(FileText, "md");
43
+ };
44
+
45
+ return html`
46
+ <div class="relative group inline-block">
47
+ ${
48
+ hasPreview
49
+ ? html`
50
+ <div class="relative">
51
+ <img
52
+ src="data:${isImage ? this.attachment.mimeType : "image/png"};base64,${this.attachment.preview}"
53
+ class="w-16 h-16 object-cover rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity"
54
+ alt="${this.attachment.fileName}"
55
+ title="${this.attachment.fileName}"
56
+ @click=${this.handleClick}
57
+ />
58
+ ${
59
+ isPdf
60
+ ? html`
61
+ <!-- PDF badge overlay -->
62
+ <div class="absolute bottom-0 left-0 right-0 bg-background/90 px-1 py-0.5 rounded-b-lg">
63
+ <div class="text-[10px] text-muted-foreground text-center font-medium">${i18n("PDF")}</div>
64
+ </div>
65
+ `
66
+ : ""
67
+ }
68
+ </div>
69
+ `
70
+ : html`
71
+ <!-- Fallback: document icon + filename -->
72
+ <div
73
+ class="w-16 h-16 rounded-lg border border-input cursor-pointer hover:opacity-80 transition-opacity bg-muted text-muted-foreground flex flex-col items-center justify-center p-2"
74
+ @click=${this.handleClick}
75
+ title="${this.attachment.fileName}"
76
+ >
77
+ ${getDocumentIcon()}
78
+ <div class="text-[10px] text-center truncate w-full">
79
+ ${
80
+ this.attachment.fileName.length > 10
81
+ ? `${this.attachment.fileName.substring(0, 8)}...`
82
+ : this.attachment.fileName
83
+ }
84
+ </div>
85
+ </div>
86
+ `
87
+ }
88
+ ${
89
+ this.showDelete
90
+ ? html`
91
+ <button
92
+ @click=${(e: Event) => {
93
+ e.stopPropagation();
94
+ this.onDelete?.();
95
+ }}
96
+ class="absolute -top-1 -right-1 w-5 h-5 bg-background hover:bg-muted text-muted-foreground hover:text-foreground rounded-full flex items-center justify-center opacity-100 hover:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100 transition-opacity border border-input shadow-sm"
97
+ title="${i18n("Remove")}"
98
+ >
99
+ ${icon(X, "xs")}
100
+ </button>
101
+ `
102
+ : ""
103
+ }
104
+ </div>
105
+ `;
106
+ }
107
+ }
@@ -0,0 +1,74 @@
1
+ import { icon } from "@mariozechner/mini-lit";
2
+ import { LitElement } from "lit";
3
+ import { property, state } from "lit/decorators.js";
4
+ import { html } from "lit/html.js";
5
+ import { Check, Copy } from "lucide";
6
+ import { i18n } from "../utils/i18n.js";
7
+
8
+ export class ConsoleBlock extends LitElement {
9
+ @property() content: string = "";
10
+ @property() variant: "default" | "error" = "default";
11
+ @state() private copied = false;
12
+
13
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
14
+ return this;
15
+ }
16
+
17
+ override connectedCallback(): void {
18
+ super.connectedCallback();
19
+ this.style.display = "block";
20
+ }
21
+
22
+ private async copy() {
23
+ try {
24
+ await navigator.clipboard.writeText(this.content || "");
25
+ this.copied = true;
26
+ setTimeout(() => {
27
+ this.copied = false;
28
+ }, 1500);
29
+ } catch (e) {
30
+ console.error("Copy failed", e);
31
+ }
32
+ }
33
+
34
+ override updated() {
35
+ // Auto-scroll to bottom on content changes
36
+ const container = this.querySelector(".console-scroll") as HTMLElement | null;
37
+ if (container) {
38
+ container.scrollTop = container.scrollHeight;
39
+ }
40
+ }
41
+
42
+ override render() {
43
+ const isError = this.variant === "error";
44
+ const textClass = isError ? "text-destructive" : "text-foreground";
45
+
46
+ return html`
47
+ <div class="border border-border rounded-lg overflow-hidden">
48
+ <div class="flex items-center justify-between px-3 py-1.5 bg-muted border-b border-border">
49
+ <span class="text-xs text-muted-foreground font-mono">${i18n("console")}</span>
50
+ <button
51
+ @click=${() => this.copy()}
52
+ class="flex items-center gap-1 px-2 py-0.5 text-xs rounded hover:bg-accent text-muted-foreground hover:text-accent-foreground transition-colors"
53
+ title="${i18n("Copy output")}"
54
+ >
55
+ ${this.copied ? icon(Check, "sm") : icon(Copy, "sm")}
56
+ ${this.copied ? html`<span>${i18n("Copied!")}</span>` : ""}
57
+ </button>
58
+ </div>
59
+ <div class="console-scroll overflow-auto max-h-64">
60
+ <pre
61
+ class="!bg-background !border-0 !rounded-none m-0 p-3 text-xs ${textClass} font-mono whitespace-pre-wrap"
62
+ >
63
+ ${this.content || ""}</pre
64
+ >
65
+ </div>
66
+ </div>
67
+ `;
68
+ }
69
+ }
70
+
71
+ // Register custom element
72
+ if (!customElements.get("console-block")) {
73
+ customElements.define("console-block", ConsoleBlock);
74
+ }
@@ -0,0 +1,96 @@
1
+ import { i18n } from "@mariozechner/mini-lit";
2
+ import { Button } from "@mariozechner/mini-lit/dist/Button.js";
3
+ import { html, LitElement, type TemplateResult } from "lit";
4
+ import { customElement, property } from "lit/decorators.js";
5
+ import type { CustomProvider } from "../storage/stores/custom-providers-store.js";
6
+
7
+ @customElement("custom-provider-card")
8
+ export class CustomProviderCard extends LitElement {
9
+ @property({ type: Object }) provider!: CustomProvider;
10
+ @property({ type: Boolean }) isAutoDiscovery = false;
11
+ @property({ type: Object }) status?: { modelCount: number; status: "connected" | "disconnected" | "checking" };
12
+ @property() onRefresh?: (provider: CustomProvider) => void;
13
+ @property() onEdit?: (provider: CustomProvider) => void;
14
+ @property() onDelete?: (provider: CustomProvider) => void;
15
+
16
+ protected createRenderRoot() {
17
+ return this;
18
+ }
19
+
20
+ private renderStatus(): TemplateResult {
21
+ if (!this.isAutoDiscovery) {
22
+ return html`
23
+ <div class="text-xs text-muted-foreground mt-1">${i18n("Models")}: ${this.provider.models?.length || 0}</div>
24
+ `;
25
+ }
26
+
27
+ if (!this.status) return html``;
28
+
29
+ const statusIcon =
30
+ this.status.status === "connected"
31
+ ? html`<span class="text-green-500">●</span>`
32
+ : this.status.status === "checking"
33
+ ? html`<span class="text-yellow-500">●</span>`
34
+ : html`<span class="text-red-500">●</span>`;
35
+
36
+ const statusText =
37
+ this.status.status === "connected"
38
+ ? `${this.status.modelCount} ${i18n("models")}`
39
+ : this.status.status === "checking"
40
+ ? i18n("Checking...")
41
+ : i18n("Disconnected");
42
+
43
+ return html`
44
+ <div class="text-xs text-muted-foreground mt-1 flex items-center gap-1">${statusIcon} ${statusText}</div>
45
+ `;
46
+ }
47
+
48
+ render(): TemplateResult {
49
+ return html`
50
+ <div class="border border-border rounded-lg p-4 space-y-2">
51
+ <div class="flex items-center justify-between">
52
+ <div class="flex-1">
53
+ <div class="font-medium text-sm text-foreground">${this.provider.name}</div>
54
+ <div class="text-xs text-muted-foreground mt-1">
55
+ <span class="capitalize">${this.provider.type}</span>
56
+ ${this.provider.baseUrl ? html` • ${this.provider.baseUrl}` : ""}
57
+ </div>
58
+ ${this.renderStatus()}
59
+ </div>
60
+ <div class="flex gap-2">
61
+ ${
62
+ this.isAutoDiscovery && this.onRefresh
63
+ ? Button({
64
+ onClick: () => this.onRefresh?.(this.provider),
65
+ variant: "ghost",
66
+ size: "sm",
67
+ children: i18n("Refresh"),
68
+ })
69
+ : ""
70
+ }
71
+ ${
72
+ this.onEdit
73
+ ? Button({
74
+ onClick: () => this.onEdit?.(this.provider),
75
+ variant: "ghost",
76
+ size: "sm",
77
+ children: i18n("Edit"),
78
+ })
79
+ : ""
80
+ }
81
+ ${
82
+ this.onDelete
83
+ ? Button({
84
+ onClick: () => this.onDelete?.(this.provider),
85
+ variant: "ghost",
86
+ size: "sm",
87
+ children: i18n("Delete"),
88
+ })
89
+ : ""
90
+ }
91
+ </div>
92
+ </div>
93
+ </div>
94
+ `;
95
+ }
96
+ }