@hyperspaceng/neural-web-ui 0.60.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 +329 -0
  2. package/README.md +601 -0
  3. package/example/README.md +61 -0
  4. package/example/index.html +13 -0
  5. package/example/package.json +25 -0
  6. package/example/src/app.css +1 -0
  7. package/example/src/custom-messages.ts +99 -0
  8. package/example/src/main.ts +421 -0
  9. package/example/tsconfig.json +23 -0
  10. package/example/vite.config.ts +6 -0
  11. package/package.json +51 -0
  12. package/scripts/count-prompt-tokens.ts +88 -0
  13. package/src/ChatPanel.ts +209 -0
  14. package/src/app.css +68 -0
  15. package/src/components/AgentInterface.ts +401 -0
  16. package/src/components/AttachmentTile.ts +107 -0
  17. package/src/components/ConsoleBlock.ts +72 -0
  18. package/src/components/CustomProviderCard.ts +100 -0
  19. package/src/components/ExpandableSection.ts +46 -0
  20. package/src/components/Input.ts +113 -0
  21. package/src/components/MessageEditor.ts +402 -0
  22. package/src/components/MessageList.ts +98 -0
  23. package/src/components/Messages.ts +383 -0
  24. package/src/components/ProviderKeyInput.ts +153 -0
  25. package/src/components/SandboxedIframe.ts +626 -0
  26. package/src/components/StreamingMessageContainer.ts +103 -0
  27. package/src/components/ThinkingBlock.ts +43 -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 +636 -0
  38. package/src/dialogs/CustomProviderDialog.ts +274 -0
  39. package/src/dialogs/ModelSelector.ts +367 -0
  40. package/src/dialogs/PersistentStorageDialog.ts +144 -0
  41. package/src/dialogs/ProvidersModelsTab.ts +212 -0
  42. package/src/dialogs/SessionListDialog.ts +150 -0
  43. package/src/dialogs/SettingsDialog.ts +218 -0
  44. package/src/index.ts +120 -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 +93 -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 +117 -0
  60. package/src/tools/artifacts/HtmlArtifact.ts +195 -0
  61. package/src/tools/artifacts/ImageArtifact.ts +116 -0
  62. package/src/tools/artifacts/MarkdownArtifact.ts +82 -0
  63. package/src/tools/artifacts/PdfArtifact.ts +201 -0
  64. package/src/tools/artifacts/SvgArtifact.ts +78 -0
  65. package/src/tools/artifacts/TextArtifact.ts +148 -0
  66. package/src/tools/artifacts/artifacts-tool-renderer.ts +310 -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 +275 -0
  70. package/src/tools/index.ts +46 -0
  71. package/src/tools/javascript-repl.ts +293 -0
  72. package/src/tools/renderer-registry.ts +130 -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 +103 -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 +139 -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,402 @@
1
+ import { icon } from "@mariozechner/mini-lit";
2
+ import { Button } from "@mariozechner/mini-lit/dist/Button.js";
3
+ import { Select, type SelectOption } from "@mariozechner/mini-lit/dist/Select.js";
4
+ import type { Model } from "@mariozechner/pi-ai";
5
+ import { html, LitElement } from "lit";
6
+ import { customElement, property, state } from "lit/decorators.js";
7
+ import { createRef, ref } from "lit/directives/ref.js";
8
+ import { Brain, Loader2, Paperclip, Send, Sparkles, Square } from "lucide";
9
+ import { type Attachment, loadAttachment } from "../utils/attachment-utils.js";
10
+ import { i18n } from "../utils/i18n.js";
11
+ import "./AttachmentTile.js";
12
+ import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
13
+
14
+ @customElement("message-editor")
15
+ export class MessageEditor extends LitElement {
16
+ private _value = "";
17
+ private textareaRef = createRef<HTMLTextAreaElement>();
18
+
19
+ @property()
20
+ get value() {
21
+ return this._value;
22
+ }
23
+
24
+ set value(val: string) {
25
+ const oldValue = this._value;
26
+ this._value = val;
27
+ this.requestUpdate("value", oldValue);
28
+ }
29
+
30
+ @property() isStreaming = false;
31
+ @property() currentModel?: Model<any>;
32
+ @property() thinkingLevel: ThinkingLevel = "off";
33
+ @property() showAttachmentButton = true;
34
+ @property() showModelSelector = true;
35
+ @property() showThinkingSelector = true;
36
+ @property() onInput?: (value: string) => void;
37
+ @property() onSend?: (input: string, attachments: Attachment[]) => void;
38
+ @property() onAbort?: () => void;
39
+ @property() onModelSelect?: () => void;
40
+ @property() onThinkingChange?: (level: "off" | "minimal" | "low" | "medium" | "high") => void;
41
+ @property() onFilesChange?: (files: Attachment[]) => void;
42
+ @property() attachments: Attachment[] = [];
43
+ @property() maxFiles = 10;
44
+ @property() maxFileSize = 20 * 1024 * 1024; // 20MB
45
+ @property() acceptedTypes =
46
+ "image/*,application/pdf,.docx,.pptx,.xlsx,.xls,.txt,.md,.json,.xml,.html,.css,.js,.ts,.jsx,.tsx,.yml,.yaml";
47
+
48
+ @state() processingFiles = false;
49
+ @state() isDragging = false;
50
+ private fileInputRef = createRef<HTMLInputElement>();
51
+
52
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
53
+ return this;
54
+ }
55
+
56
+ private handleTextareaInput = (e: Event) => {
57
+ const textarea = e.target as HTMLTextAreaElement;
58
+ this.value = textarea.value;
59
+ this.onInput?.(this.value);
60
+ };
61
+
62
+ private handleKeyDown = (e: KeyboardEvent) => {
63
+ if (e.key === "Enter" && !e.shiftKey) {
64
+ e.preventDefault();
65
+ if (!this.isStreaming && !this.processingFiles && (this.value.trim() || this.attachments.length > 0)) {
66
+ this.handleSend();
67
+ }
68
+ } else if (e.key === "Escape" && this.isStreaming) {
69
+ e.preventDefault();
70
+ this.onAbort?.();
71
+ }
72
+ };
73
+
74
+ private handlePaste = async (e: ClipboardEvent) => {
75
+ const items = e.clipboardData?.items;
76
+ if (!items) return;
77
+
78
+ const imageFiles: File[] = [];
79
+
80
+ // Check for image items in clipboard
81
+ for (let i = 0; i < items.length; i++) {
82
+ const item = items[i];
83
+ if (item.type.startsWith("image/")) {
84
+ const file = item.getAsFile();
85
+ if (file) {
86
+ imageFiles.push(file);
87
+ }
88
+ }
89
+ }
90
+
91
+ // If we found images, process them
92
+ if (imageFiles.length > 0) {
93
+ e.preventDefault(); // Prevent default paste behavior
94
+
95
+ if (imageFiles.length + this.attachments.length > this.maxFiles) {
96
+ alert(`Maximum ${this.maxFiles} files allowed`);
97
+ return;
98
+ }
99
+
100
+ this.processingFiles = true;
101
+ const newAttachments: Attachment[] = [];
102
+
103
+ for (const file of imageFiles) {
104
+ try {
105
+ if (file.size > this.maxFileSize) {
106
+ alert(`Image exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
107
+ continue;
108
+ }
109
+
110
+ const attachment = await loadAttachment(file);
111
+ newAttachments.push(attachment);
112
+ } catch (error) {
113
+ console.error("Error processing pasted image:", error);
114
+ alert(`Failed to process pasted image: ${String(error)}`);
115
+ }
116
+ }
117
+
118
+ this.attachments = [...this.attachments, ...newAttachments];
119
+ this.onFilesChange?.(this.attachments);
120
+ this.processingFiles = false;
121
+ }
122
+ };
123
+
124
+ private handleSend = () => {
125
+ this.onSend?.(this.value, this.attachments);
126
+ };
127
+
128
+ private handleAttachmentClick = () => {
129
+ this.fileInputRef.value?.click();
130
+ };
131
+
132
+ private async handleFilesSelected(e: Event) {
133
+ const input = e.target as HTMLInputElement;
134
+ const files = Array.from(input.files || []);
135
+ if (files.length === 0) return;
136
+
137
+ if (files.length + this.attachments.length > this.maxFiles) {
138
+ alert(`Maximum ${this.maxFiles} files allowed`);
139
+ input.value = "";
140
+ return;
141
+ }
142
+
143
+ this.processingFiles = true;
144
+ const newAttachments: Attachment[] = [];
145
+
146
+ for (const file of files) {
147
+ try {
148
+ if (file.size > this.maxFileSize) {
149
+ alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
150
+ continue;
151
+ }
152
+
153
+ const attachment = await loadAttachment(file);
154
+ newAttachments.push(attachment);
155
+ } catch (error) {
156
+ console.error(`Error processing ${file.name}:`, error);
157
+ alert(`Failed to process ${file.name}: ${String(error)}`);
158
+ }
159
+ }
160
+
161
+ this.attachments = [...this.attachments, ...newAttachments];
162
+ this.onFilesChange?.(this.attachments);
163
+ this.processingFiles = false;
164
+ input.value = ""; // Reset input
165
+ }
166
+
167
+ private removeFile(fileId: string) {
168
+ this.attachments = this.attachments.filter((f) => f.id !== fileId);
169
+ this.onFilesChange?.(this.attachments);
170
+ }
171
+
172
+ private handleDragOver = (e: DragEvent) => {
173
+ e.preventDefault();
174
+ e.stopPropagation();
175
+ if (!this.isDragging) {
176
+ this.isDragging = true;
177
+ }
178
+ };
179
+
180
+ private handleDragLeave = (e: DragEvent) => {
181
+ e.preventDefault();
182
+ e.stopPropagation();
183
+ // Only set isDragging to false if we're leaving the entire component
184
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
185
+ const x = e.clientX;
186
+ const y = e.clientY;
187
+ if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) {
188
+ this.isDragging = false;
189
+ }
190
+ };
191
+
192
+ private handleDrop = async (e: DragEvent) => {
193
+ e.preventDefault();
194
+ e.stopPropagation();
195
+ this.isDragging = false;
196
+
197
+ const files = Array.from(e.dataTransfer?.files || []);
198
+ if (files.length === 0) return;
199
+
200
+ if (files.length + this.attachments.length > this.maxFiles) {
201
+ alert(`Maximum ${this.maxFiles} files allowed`);
202
+ return;
203
+ }
204
+
205
+ this.processingFiles = true;
206
+ const newAttachments: Attachment[] = [];
207
+
208
+ for (const file of files) {
209
+ try {
210
+ if (file.size > this.maxFileSize) {
211
+ alert(`${file.name} exceeds maximum size of ${Math.round(this.maxFileSize / 1024 / 1024)}MB`);
212
+ continue;
213
+ }
214
+
215
+ const attachment = await loadAttachment(file);
216
+ newAttachments.push(attachment);
217
+ } catch (error) {
218
+ console.error(`Error processing ${file.name}:`, error);
219
+ alert(`Failed to process ${file.name}: ${String(error)}`);
220
+ }
221
+ }
222
+
223
+ this.attachments = [...this.attachments, ...newAttachments];
224
+ this.onFilesChange?.(this.attachments);
225
+ this.processingFiles = false;
226
+ };
227
+
228
+ override firstUpdated() {
229
+ const textarea = this.textareaRef.value;
230
+ if (textarea) {
231
+ textarea.focus();
232
+ }
233
+ }
234
+
235
+ override render() {
236
+ // Check if current model supports thinking/reasoning
237
+ const model = this.currentModel;
238
+ const supportsThinking = model?.reasoning === true; // Models with reasoning:true support thinking
239
+
240
+ return html`
241
+ <div
242
+ class="bg-card rounded-xl border shadow-sm relative ${this.isDragging ? "border-primary border-2 bg-primary/5" : "border-border"}"
243
+ @dragover=${this.handleDragOver}
244
+ @dragleave=${this.handleDragLeave}
245
+ @drop=${this.handleDrop}
246
+ >
247
+ <!-- Drag overlay -->
248
+ ${
249
+ this.isDragging
250
+ ? html`
251
+ <div class="absolute inset-0 bg-primary/10 rounded-xl pointer-events-none z-10 flex items-center justify-center">
252
+ <div class="text-primary font-medium">${i18n("Drop files here")}</div>
253
+ </div>
254
+ `
255
+ : ""
256
+ }
257
+
258
+ <!-- Attachments -->
259
+ ${
260
+ this.attachments.length > 0
261
+ ? html`
262
+ <div class="px-4 pt-3 pb-2 flex flex-wrap gap-2">
263
+ ${this.attachments.map(
264
+ (attachment) => html`
265
+ <attachment-tile
266
+ .attachment=${attachment}
267
+ .showDelete=${true}
268
+ .onDelete=${() => this.removeFile(attachment.id)}
269
+ ></attachment-tile>
270
+ `,
271
+ )}
272
+ </div>
273
+ `
274
+ : ""
275
+ }
276
+
277
+ <textarea
278
+ class="w-full bg-transparent p-4 text-foreground placeholder-muted-foreground outline-none resize-none overflow-y-auto"
279
+ placeholder=${i18n("Type a message...")}
280
+ rows="1"
281
+ style="max-height: 200px; field-sizing: content; min-height: 1lh; height: auto;"
282
+ .value=${this.value}
283
+ @input=${this.handleTextareaInput}
284
+ @keydown=${this.handleKeyDown}
285
+ @paste=${this.handlePaste}
286
+ ${ref(this.textareaRef)}
287
+ ></textarea>
288
+
289
+ <!-- Hidden file input -->
290
+ <input
291
+ type="file"
292
+ ${ref(this.fileInputRef)}
293
+ @change=${this.handleFilesSelected}
294
+ accept=${this.acceptedTypes}
295
+ multiple
296
+ style="display: none;"
297
+ />
298
+
299
+ <!-- Button Row -->
300
+ <div class="px-2 pb-2 flex items-center justify-between">
301
+ <!-- Left side - attachment and thinking selector -->
302
+ <div class="flex gap-2 items-center">
303
+ ${
304
+ this.showAttachmentButton
305
+ ? this.processingFiles
306
+ ? html`
307
+ <div class="h-8 w-8 flex items-center justify-center">
308
+ ${icon(Loader2, "sm", "animate-spin text-muted-foreground")}
309
+ </div>
310
+ `
311
+ : html`
312
+ ${Button({
313
+ variant: "ghost",
314
+ size: "icon",
315
+ className: "h-8 w-8",
316
+ onClick: this.handleAttachmentClick,
317
+ children: icon(Paperclip, "sm"),
318
+ })}
319
+ `
320
+ : ""
321
+ }
322
+ ${
323
+ supportsThinking && this.showThinkingSelector
324
+ ? html`
325
+ ${Select({
326
+ value: this.thinkingLevel,
327
+ placeholder: i18n("Off"),
328
+ options: [
329
+ { value: "off", label: i18n("Off"), icon: icon(Brain, "sm") },
330
+ { value: "minimal", label: i18n("Minimal"), icon: icon(Brain, "sm") },
331
+ { value: "low", label: i18n("Low"), icon: icon(Brain, "sm") },
332
+ { value: "medium", label: i18n("Medium"), icon: icon(Brain, "sm") },
333
+ { value: "high", label: i18n("High"), icon: icon(Brain, "sm") },
334
+ ] as SelectOption[],
335
+ onChange: (value: string) => {
336
+ const level = value as "off" | "minimal" | "low" | "medium" | "high";
337
+ this.thinkingLevel = level;
338
+ this.onThinkingChange?.(level);
339
+ },
340
+ width: "80px",
341
+ size: "sm",
342
+ variant: "ghost",
343
+ fitContent: true,
344
+ })}
345
+ `
346
+ : ""
347
+ }
348
+ </div>
349
+
350
+ <!-- Model selector and send on the right -->
351
+ <div class="flex gap-2 items-center">
352
+ ${
353
+ this.showModelSelector && this.currentModel
354
+ ? html`
355
+ ${Button({
356
+ variant: "ghost",
357
+ size: "sm",
358
+ onClick: () => {
359
+ // Focus textarea before opening model selector so focus returns there
360
+ this.textareaRef.value?.focus();
361
+ // Wait for next frame to ensure focus takes effect before dialog captures it
362
+ requestAnimationFrame(() => {
363
+ this.onModelSelect?.();
364
+ });
365
+ },
366
+ children: html`
367
+ ${icon(Sparkles, "sm")}
368
+ <span class="ml-1">${this.currentModel.id}</span>
369
+ `,
370
+ className: "h-8 text-xs truncate",
371
+ })}
372
+ `
373
+ : ""
374
+ }
375
+ ${
376
+ this.isStreaming
377
+ ? html`
378
+ ${Button({
379
+ variant: "ghost",
380
+ size: "icon",
381
+ onClick: this.onAbort,
382
+ children: icon(Square, "sm"),
383
+ className: "h-8 w-8",
384
+ })}
385
+ `
386
+ : html`
387
+ ${Button({
388
+ variant: "ghost",
389
+ size: "icon",
390
+ onClick: this.handleSend,
391
+ disabled: (!this.value.trim() && this.attachments.length === 0) || this.processingFiles,
392
+ children: html`<div style="transform: rotate(-45deg)">${icon(Send, "sm")}</div>`,
393
+ className: "h-8 w-8",
394
+ })}
395
+ `
396
+ }
397
+ </div>
398
+ </div>
399
+ </div>
400
+ `;
401
+ }
402
+ }
@@ -0,0 +1,98 @@
1
+ import type { AgentMessage, AgentTool } from "@mariozechner/pi-agent-core";
2
+ import type {
3
+ AssistantMessage as AssistantMessageType,
4
+ ToolResultMessage as ToolResultMessageType,
5
+ } from "@mariozechner/pi-ai";
6
+ import { html, LitElement, type TemplateResult } from "lit";
7
+ import { property } from "lit/decorators.js";
8
+ import { repeat } from "lit/directives/repeat.js";
9
+ import { renderMessage } from "./message-renderer-registry.js";
10
+
11
+ export class MessageList extends LitElement {
12
+ @property({ type: Array }) messages: AgentMessage[] = [];
13
+ @property({ type: Array }) tools: AgentTool[] = [];
14
+ @property({ type: Object }) pendingToolCalls?: Set<string>;
15
+ @property({ type: Boolean }) isStreaming: boolean = false;
16
+ @property({ attribute: false }) onCostClick?: () => void;
17
+
18
+ protected override createRenderRoot(): HTMLElement | DocumentFragment {
19
+ return this;
20
+ }
21
+
22
+ override connectedCallback(): void {
23
+ super.connectedCallback();
24
+ this.style.display = "block";
25
+ }
26
+
27
+ private buildRenderItems() {
28
+ // Map tool results by call id for quick lookup
29
+ const resultByCallId = new Map<string, ToolResultMessageType>();
30
+ for (const message of this.messages) {
31
+ if (message.role === "toolResult") {
32
+ resultByCallId.set(message.toolCallId, message);
33
+ }
34
+ }
35
+
36
+ const items: Array<{ key: string; template: TemplateResult }> = [];
37
+ let index = 0;
38
+ for (const msg of this.messages) {
39
+ // Skip artifact messages - they're for session persistence only, not UI display
40
+ if (msg.role === "artifact") {
41
+ continue;
42
+ }
43
+
44
+ // Try custom renderer first
45
+ const customTemplate = renderMessage(msg);
46
+ if (customTemplate) {
47
+ items.push({ key: `msg:${index}`, template: customTemplate });
48
+ index++;
49
+ continue;
50
+ }
51
+
52
+ // Fall back to built-in renderers
53
+ if (msg.role === "user" || msg.role === "user-with-attachments") {
54
+ items.push({
55
+ key: `msg:${index}`,
56
+ template: html`<user-message .message=${msg}></user-message>`,
57
+ });
58
+ index++;
59
+ } else if (msg.role === "assistant") {
60
+ const amsg = msg as AssistantMessageType;
61
+ items.push({
62
+ key: `msg:${index}`,
63
+ template: html`<assistant-message
64
+ .message=${amsg}
65
+ .tools=${this.tools}
66
+ .isStreaming=${false}
67
+ .pendingToolCalls=${this.pendingToolCalls}
68
+ .toolResultsById=${resultByCallId}
69
+ .hideToolCalls=${false}
70
+ .hidePendingToolCalls=${this.isStreaming}
71
+ .onCostClick=${this.onCostClick}
72
+ ></assistant-message>`,
73
+ });
74
+ index++;
75
+ } else {
76
+ // Skip standalone toolResult messages; they are rendered via paired tool-message above
77
+ // Skip unknown roles
78
+ }
79
+ }
80
+ return items;
81
+ }
82
+
83
+ override render() {
84
+ const items = this.buildRenderItems();
85
+ return html`<div class="flex flex-col gap-3">
86
+ ${repeat(
87
+ items,
88
+ (it) => it.key,
89
+ (it) => it.template,
90
+ )}
91
+ </div>`;
92
+ }
93
+ }
94
+
95
+ // Register custom element
96
+ if (!customElements.get("message-list")) {
97
+ customElements.define("message-list", MessageList);
98
+ }