@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,46 @@
|
|
|
1
|
+
import { icon } from "@mariozechner/mini-lit";
|
|
2
|
+
import { html, LitElement, type TemplateResult } from "lit";
|
|
3
|
+
import { customElement, property, state } from "lit/decorators.js";
|
|
4
|
+
import { ChevronDown, ChevronRight } from "lucide";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reusable expandable section component for tool renderers.
|
|
8
|
+
* Captures children in connectedCallback and re-renders them in the details area.
|
|
9
|
+
*/
|
|
10
|
+
@customElement("expandable-section")
|
|
11
|
+
export class ExpandableSection extends LitElement {
|
|
12
|
+
@property() summary!: string;
|
|
13
|
+
@property({ type: Boolean }) defaultExpanded = false;
|
|
14
|
+
@state() private expanded = false;
|
|
15
|
+
private capturedChildren: Node[] = [];
|
|
16
|
+
|
|
17
|
+
protected createRenderRoot() {
|
|
18
|
+
return this; // light DOM
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
override connectedCallback() {
|
|
22
|
+
super.connectedCallback();
|
|
23
|
+
// Capture children before first render
|
|
24
|
+
this.capturedChildren = Array.from(this.childNodes);
|
|
25
|
+
// Clear children (we'll re-insert them in render)
|
|
26
|
+
this.innerHTML = "";
|
|
27
|
+
this.expanded = this.defaultExpanded;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
override render(): TemplateResult {
|
|
31
|
+
return html`
|
|
32
|
+
<div>
|
|
33
|
+
<button
|
|
34
|
+
@click=${() => {
|
|
35
|
+
this.expanded = !this.expanded;
|
|
36
|
+
}}
|
|
37
|
+
class="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors w-full text-left"
|
|
38
|
+
>
|
|
39
|
+
${icon(this.expanded ? ChevronDown : ChevronRight, "sm")}
|
|
40
|
+
<span>${this.summary}</span>
|
|
41
|
+
</button>
|
|
42
|
+
${this.expanded ? html`<div class="mt-2">${this.capturedChildren}</div>` : ""}
|
|
43
|
+
</div>
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type BaseComponentProps, fc } from "@mariozechner/mini-lit/dist/mini.js";
|
|
2
|
+
import { html } from "lit";
|
|
3
|
+
import { type Ref, ref } from "lit/directives/ref.js";
|
|
4
|
+
import { i18n } from "../utils/i18n.js";
|
|
5
|
+
|
|
6
|
+
export type InputType = "text" | "email" | "password" | "number" | "url" | "tel" | "search";
|
|
7
|
+
export type InputSize = "sm" | "md" | "lg";
|
|
8
|
+
|
|
9
|
+
export interface InputProps extends BaseComponentProps {
|
|
10
|
+
type?: InputType;
|
|
11
|
+
size?: InputSize;
|
|
12
|
+
value?: string;
|
|
13
|
+
placeholder?: string;
|
|
14
|
+
label?: string;
|
|
15
|
+
error?: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
name?: string;
|
|
19
|
+
autocomplete?: string;
|
|
20
|
+
min?: number;
|
|
21
|
+
max?: number;
|
|
22
|
+
step?: number;
|
|
23
|
+
inputRef?: Ref<HTMLInputElement>;
|
|
24
|
+
onInput?: (e: Event) => void;
|
|
25
|
+
onChange?: (e: Event) => void;
|
|
26
|
+
onKeyDown?: (e: KeyboardEvent) => void;
|
|
27
|
+
onKeyUp?: (e: KeyboardEvent) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const Input = fc<InputProps>(
|
|
31
|
+
({
|
|
32
|
+
type = "text",
|
|
33
|
+
size = "md",
|
|
34
|
+
value = "",
|
|
35
|
+
placeholder = "",
|
|
36
|
+
label = "",
|
|
37
|
+
error = "",
|
|
38
|
+
disabled = false,
|
|
39
|
+
required = false,
|
|
40
|
+
name = "",
|
|
41
|
+
autocomplete = "",
|
|
42
|
+
min,
|
|
43
|
+
max,
|
|
44
|
+
step,
|
|
45
|
+
inputRef,
|
|
46
|
+
onInput,
|
|
47
|
+
onChange,
|
|
48
|
+
onKeyDown,
|
|
49
|
+
onKeyUp,
|
|
50
|
+
className = "",
|
|
51
|
+
}) => {
|
|
52
|
+
const sizeClasses = {
|
|
53
|
+
sm: "h-8 px-3 py-1 text-sm",
|
|
54
|
+
md: "h-9 px-3 py-1 text-sm md:text-sm",
|
|
55
|
+
lg: "h-10 px-4 py-1 text-base",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const baseClasses =
|
|
59
|
+
"flex w-full min-w-0 rounded-md border bg-transparent text-foreground shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium";
|
|
60
|
+
const interactionClasses =
|
|
61
|
+
"placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground";
|
|
62
|
+
const focusClasses = "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]";
|
|
63
|
+
const darkClasses = "dark:bg-input/30";
|
|
64
|
+
const stateClasses = error
|
|
65
|
+
? "border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40"
|
|
66
|
+
: "border-input";
|
|
67
|
+
const disabledClasses = "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50";
|
|
68
|
+
|
|
69
|
+
const handleInput = (e: Event) => {
|
|
70
|
+
onInput?.(e);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const handleChange = (e: Event) => {
|
|
74
|
+
onChange?.(e);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return html`
|
|
78
|
+
<div class="flex flex-col gap-1.5 ${className}">
|
|
79
|
+
${
|
|
80
|
+
label
|
|
81
|
+
? html`
|
|
82
|
+
<label class="text-sm font-medium text-foreground">
|
|
83
|
+
${label} ${required ? html`<span class="text-destructive">${i18n("*")}</span>` : ""}
|
|
84
|
+
</label>
|
|
85
|
+
`
|
|
86
|
+
: ""
|
|
87
|
+
}
|
|
88
|
+
<input
|
|
89
|
+
type="${type}"
|
|
90
|
+
class="${baseClasses} ${
|
|
91
|
+
sizeClasses[size]
|
|
92
|
+
} ${interactionClasses} ${focusClasses} ${darkClasses} ${stateClasses} ${disabledClasses}"
|
|
93
|
+
.value=${value}
|
|
94
|
+
placeholder="${placeholder}"
|
|
95
|
+
?disabled=${disabled}
|
|
96
|
+
?required=${required}
|
|
97
|
+
?aria-invalid=${!!error}
|
|
98
|
+
name="${name}"
|
|
99
|
+
autocomplete="${autocomplete}"
|
|
100
|
+
min="${min ?? ""}"
|
|
101
|
+
max="${max ?? ""}"
|
|
102
|
+
step="${step ?? ""}"
|
|
103
|
+
@input=${handleInput}
|
|
104
|
+
@change=${handleChange}
|
|
105
|
+
@keydown=${onKeyDown}
|
|
106
|
+
@keyup=${onKeyUp}
|
|
107
|
+
${inputRef ? ref(inputRef) : ""}
|
|
108
|
+
/>
|
|
109
|
+
${error ? html`<span class="text-sm text-destructive">${error}</span>` : ""}
|
|
110
|
+
</div>
|
|
111
|
+
`;
|
|
112
|
+
},
|
|
113
|
+
);
|
|
@@ -0,0 +1,404 @@
|
|
|
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 "@oh-my-pi/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 "@oh-my-pi/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 ${
|
|
243
|
+
this.isDragging ? "border-primary border-2 bg-primary/5" : "border-border"
|
|
244
|
+
}"
|
|
245
|
+
@dragover=${this.handleDragOver}
|
|
246
|
+
@dragleave=${this.handleDragLeave}
|
|
247
|
+
@drop=${this.handleDrop}
|
|
248
|
+
>
|
|
249
|
+
<!-- Drag overlay -->
|
|
250
|
+
${
|
|
251
|
+
this.isDragging
|
|
252
|
+
? html`
|
|
253
|
+
<div
|
|
254
|
+
class="absolute inset-0 bg-primary/10 rounded-xl pointer-events-none z-10 flex items-center justify-center"
|
|
255
|
+
>
|
|
256
|
+
<div class="text-primary font-medium">${i18n("Drop files here")}</div>
|
|
257
|
+
</div>
|
|
258
|
+
`
|
|
259
|
+
: ""
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
<!-- Attachments -->
|
|
263
|
+
${
|
|
264
|
+
this.attachments.length > 0
|
|
265
|
+
? html`
|
|
266
|
+
<div class="px-4 pt-3 pb-2 flex flex-wrap gap-2">
|
|
267
|
+
${this.attachments.map(
|
|
268
|
+
(attachment) => html`
|
|
269
|
+
<attachment-tile
|
|
270
|
+
.attachment=${attachment}
|
|
271
|
+
.showDelete=${true}
|
|
272
|
+
.onDelete=${() => this.removeFile(attachment.id)}
|
|
273
|
+
></attachment-tile>
|
|
274
|
+
`,
|
|
275
|
+
)}
|
|
276
|
+
</div>
|
|
277
|
+
`
|
|
278
|
+
: ""
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
<textarea
|
|
282
|
+
class="w-full bg-transparent p-4 text-foreground placeholder-muted-foreground outline-none resize-none overflow-y-auto"
|
|
283
|
+
placeholder=${i18n("Type a message...")}
|
|
284
|
+
rows="1"
|
|
285
|
+
style="max-height: 200px; field-sizing: content; min-height: 1lh; height: auto;"
|
|
286
|
+
.value=${this.value}
|
|
287
|
+
@input=${this.handleTextareaInput}
|
|
288
|
+
@keydown=${this.handleKeyDown}
|
|
289
|
+
@paste=${this.handlePaste}
|
|
290
|
+
${ref(this.textareaRef)}
|
|
291
|
+
></textarea>
|
|
292
|
+
|
|
293
|
+
<!-- Hidden file input -->
|
|
294
|
+
<input
|
|
295
|
+
type="file"
|
|
296
|
+
${ref(this.fileInputRef)}
|
|
297
|
+
@change=${this.handleFilesSelected}
|
|
298
|
+
accept=${this.acceptedTypes}
|
|
299
|
+
multiple
|
|
300
|
+
style="display: none;"
|
|
301
|
+
/>
|
|
302
|
+
|
|
303
|
+
<!-- Button Row -->
|
|
304
|
+
<div class="px-2 pb-2 flex items-center justify-between">
|
|
305
|
+
<!-- Left side - attachment and thinking selector -->
|
|
306
|
+
<div class="flex gap-2 items-center">
|
|
307
|
+
${
|
|
308
|
+
this.showAttachmentButton
|
|
309
|
+
? this.processingFiles
|
|
310
|
+
? html`
|
|
311
|
+
<div class="h-8 w-8 flex items-center justify-center">
|
|
312
|
+
${icon(Loader2, "sm", "animate-spin text-muted-foreground")}
|
|
313
|
+
</div>
|
|
314
|
+
`
|
|
315
|
+
: html`
|
|
316
|
+
${Button({
|
|
317
|
+
variant: "ghost",
|
|
318
|
+
size: "icon",
|
|
319
|
+
className: "h-8 w-8",
|
|
320
|
+
onClick: this.handleAttachmentClick,
|
|
321
|
+
children: icon(Paperclip, "sm"),
|
|
322
|
+
})}
|
|
323
|
+
`
|
|
324
|
+
: ""
|
|
325
|
+
}
|
|
326
|
+
${
|
|
327
|
+
supportsThinking && this.showThinkingSelector
|
|
328
|
+
? html`
|
|
329
|
+
${Select({
|
|
330
|
+
value: this.thinkingLevel,
|
|
331
|
+
placeholder: i18n("Off"),
|
|
332
|
+
options: [
|
|
333
|
+
{ value: "off", label: i18n("Off"), icon: icon(Brain, "sm") },
|
|
334
|
+
{ value: "minimal", label: i18n("Minimal"), icon: icon(Brain, "sm") },
|
|
335
|
+
{ value: "low", label: i18n("Low"), icon: icon(Brain, "sm") },
|
|
336
|
+
{ value: "medium", label: i18n("Medium"), icon: icon(Brain, "sm") },
|
|
337
|
+
{ value: "high", label: i18n("High"), icon: icon(Brain, "sm") },
|
|
338
|
+
] as SelectOption[],
|
|
339
|
+
onChange: (value: string) => {
|
|
340
|
+
this.onThinkingChange?.(value as "off" | "minimal" | "low" | "medium" | "high");
|
|
341
|
+
},
|
|
342
|
+
width: "80px",
|
|
343
|
+
size: "sm",
|
|
344
|
+
variant: "ghost",
|
|
345
|
+
fitContent: true,
|
|
346
|
+
})}
|
|
347
|
+
`
|
|
348
|
+
: ""
|
|
349
|
+
}
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<!-- Model selector and send on the right -->
|
|
353
|
+
<div class="flex gap-2 items-center">
|
|
354
|
+
${
|
|
355
|
+
this.showModelSelector && this.currentModel
|
|
356
|
+
? html`
|
|
357
|
+
${Button({
|
|
358
|
+
variant: "ghost",
|
|
359
|
+
size: "sm",
|
|
360
|
+
onClick: () => {
|
|
361
|
+
// Focus textarea before opening model selector so focus returns there
|
|
362
|
+
this.textareaRef.value?.focus();
|
|
363
|
+
// Wait for next frame to ensure focus takes effect before dialog captures it
|
|
364
|
+
requestAnimationFrame(() => {
|
|
365
|
+
this.onModelSelect?.();
|
|
366
|
+
});
|
|
367
|
+
},
|
|
368
|
+
children: html`
|
|
369
|
+
${icon(Sparkles, "sm")}
|
|
370
|
+
<span class="ml-1">${this.currentModel.id}</span>
|
|
371
|
+
`,
|
|
372
|
+
className: "h-8 text-xs truncate",
|
|
373
|
+
})}
|
|
374
|
+
`
|
|
375
|
+
: ""
|
|
376
|
+
}
|
|
377
|
+
${
|
|
378
|
+
this.isStreaming
|
|
379
|
+
? html`
|
|
380
|
+
${Button({
|
|
381
|
+
variant: "ghost",
|
|
382
|
+
size: "icon",
|
|
383
|
+
onClick: this.onAbort,
|
|
384
|
+
children: icon(Square, "sm"),
|
|
385
|
+
className: "h-8 w-8",
|
|
386
|
+
})}
|
|
387
|
+
`
|
|
388
|
+
: html`
|
|
389
|
+
${Button({
|
|
390
|
+
variant: "ghost",
|
|
391
|
+
size: "icon",
|
|
392
|
+
onClick: this.handleSend,
|
|
393
|
+
disabled: (!this.value.trim() && this.attachments.length === 0) || this.processingFiles,
|
|
394
|
+
children: html`<div style="transform: rotate(-45deg)">${icon(Send, "sm")}</div>`,
|
|
395
|
+
className: "h-8 w-8",
|
|
396
|
+
})}
|
|
397
|
+
`
|
|
398
|
+
}
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
`;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
|
|
2
|
+
import type {
|
|
3
|
+
AssistantMessage as AssistantMessageType,
|
|
4
|
+
ToolResultMessage as ToolResultMessageType,
|
|
5
|
+
} from "@oh-my-pi/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
|
+
.onCostClick=${this.onCostClick}
|
|
71
|
+
></assistant-message>`,
|
|
72
|
+
});
|
|
73
|
+
index++;
|
|
74
|
+
} else {
|
|
75
|
+
// Skip standalone toolResult messages; they are rendered via paired tool-message above
|
|
76
|
+
// Skip unknown roles
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return items;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
override render() {
|
|
83
|
+
const items = this.buildRenderItems();
|
|
84
|
+
return html`<div class="flex flex-col gap-3">
|
|
85
|
+
${repeat(
|
|
86
|
+
items,
|
|
87
|
+
(it) => it.key,
|
|
88
|
+
(it) => it.template,
|
|
89
|
+
)}
|
|
90
|
+
</div>`;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Register custom element
|
|
95
|
+
if (!customElements.get("message-list")) {
|
|
96
|
+
customElements.define("message-list", MessageList);
|
|
97
|
+
}
|