rspace-online/lib/folk-prompt.ts

682 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { SpeechDictation } from "./speech-dictation";
const styles = css`
:host {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 400px;
min-height: 450px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #0ea5e9, #6366f1);
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions {
display: flex;
gap: 4px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
display: flex;
flex-direction: column;
height: calc(100% - 36px);
overflow: hidden;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
max-width: 85%;
padding: 10px 14px;
border-radius: 12px;
font-size: 13px;
line-height: 1.5;
}
.message.user {
align-self: flex-end;
background: linear-gradient(135deg, #0ea5e9, #6366f1);
color: white;
border-bottom-right-radius: 4px;
}
.message.assistant {
align-self: flex-start;
background: #f1f5f9;
color: #1e293b;
border-bottom-left-radius: 4px;
}
.message.streaming {
background: #f1f5f9;
}
.message.streaming::after {
content: "▌";
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #94a3b8;
text-align: center;
gap: 8px;
padding: 24px;
}
.placeholder-icon {
font-size: 48px;
opacity: 0.5;
}
.input-area {
padding: 12px;
border-top: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
gap: 8px;
}
.model-select {
padding: 6px 10px;
border: 2px solid #e2e8f0;
border-radius: 6px;
font-size: 12px;
background: white;
cursor: pointer;
width: fit-content;
}
.prompt-row {
display: flex;
gap: 8px;
}
.prompt-input {
flex: 1;
padding: 10px 14px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 13px;
resize: none;
outline: none;
font-family: inherit;
}
.prompt-input:focus {
border-color: #6366f1;
}
.send-btn {
padding: 10px 16px;
background: linear-gradient(135deg, #0ea5e9, #6366f1);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: opacity 0.2s;
}
.send-btn:hover {
opacity: 0.9;
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mic-btn {
padding: 10px 12px;
background: transparent;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.mic-btn:hover {
border-color: #6366f1;
}
.mic-btn.recording {
border-color: #ef4444;
animation: micPulse 1.5s infinite;
}
@keyframes micPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.15); }
}
.error {
color: #ef4444;
padding: 12px;
background: #fef2f2;
border-radius: 6px;
font-size: 13px;
margin: 12px;
}
.clear-btn {
font-size: 12px;
padding: 4px 8px;
background: #f1f5f9;
border: none;
border-radius: 4px;
cursor: pointer;
color: #64748b;
}
.clear-btn:hover {
background: #e2e8f0;
}
.attach-btn {
padding: 10px 12px;
background: transparent;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.attach-btn:hover {
border-color: #6366f1;
}
.pending-images {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.pending-thumb {
position: relative;
width: 48px;
height: 48px;
border-radius: 6px;
overflow: hidden;
}
.pending-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.pending-thumb .remove-img {
position: absolute;
top: 1px;
right: 1px;
width: 14px;
height: 14px;
background: rgba(0,0,0,0.6);
color: white;
border: none;
border-radius: 50%;
font-size: 9px;
line-height: 14px;
text-align: center;
cursor: pointer;
padding: 0;
}
.msg-images {
display: flex;
gap: 4px;
margin-top: 6px;
}
.msg-images img {
max-width: 80px;
max-height: 80px;
border-radius: 4px;
object-fit: cover;
}
pre {
background: #1e293b;
color: #e2e8f0;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
margin: 8px 0;
}
code {
font-family: "Monaco", "Consolas", monospace;
}
`;
export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
images?: string[];
timestamp: Date;
}
declare global {
interface HTMLElementTagNameMap {
"folk-prompt": FolkPrompt;
}
}
export class FolkPrompt extends FolkShape {
static override tagName = "folk-prompt";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#messages: ChatMessage[] = [];
#isStreaming = false;
#error: string | null = null;
#model = "gemini-flash";
#pendingImages: string[] = [];
#messagesEl: HTMLElement | null = null;
#promptInput: HTMLTextAreaElement | null = null;
#modelSelect: HTMLSelectElement | null = null;
#sendBtn: HTMLButtonElement | null = null;
#attachInput: HTMLInputElement | null = null;
#pendingImagesEl: HTMLElement | null = null;
get messages() {
return this.#messages;
}
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>💬</span>
<span>AI Prompt</span>
</span>
<div class="header-actions">
<button class="clear-btn" title="Clear chat">Clear</button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="messages">
<div class="placeholder">
<span class="placeholder-icon">🤖</span>
<span>Ask me anything!</span>
<span style="font-size: 11px;">I can help with code, writing, analysis, and more</span>
</div>
</div>
<div class="input-area">
<select class="model-select">
<optgroup label="Gemini">
<option value="gemini-flash">Gemini 2.5 Flash</option>
<option value="gemini-pro">Gemini 2.5 Pro</option>
</optgroup>
<optgroup label="Local (Ollama)">
<option value="llama3.2">Llama 3.2 (3B)</option>
<option value="llama3.1">Llama 3.1 (8B)</option>
<option value="qwen2.5-coder">Qwen Coder (7B)</option>
<option value="mistral-small">Mistral Small (24B)</option>
</optgroup>
</select>
<div class="pending-images"></div>
<div class="prompt-row">
<textarea class="prompt-input" placeholder="Type your message..." rows="2"></textarea>
<button class="attach-btn" title="Attach image">📎</button>
<input type="file" class="attach-input" accept="image/*" multiple hidden />
${SpeechDictation.isSupported() ? '<button class="mic-btn" title="Voice dictation">🎤</button>' : ''}
<button class="send-btn">→</button>
</div>
</div>
</div>
`;
// Replace the container div (slot's parent) with our wrapper
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
this.#messagesEl = wrapper.querySelector(".messages");
this.#promptInput = wrapper.querySelector(".prompt-input");
this.#modelSelect = wrapper.querySelector(".model-select");
this.#sendBtn = wrapper.querySelector(".send-btn");
this.#attachInput = wrapper.querySelector(".attach-input");
this.#pendingImagesEl = wrapper.querySelector(".pending-images");
const clearBtn = wrapper.querySelector(".clear-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
const attachBtn = wrapper.querySelector(".attach-btn") as HTMLButtonElement | null;
// Attach button
attachBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#attachInput?.click();
});
this.#attachInput?.addEventListener("change", () => {
if (!this.#attachInput?.files) return;
for (const file of Array.from(this.#attachInput.files)) {
if (!file.type.startsWith("image/")) continue;
const reader = new FileReader();
reader.onload = () => {
this.#pendingImages.push(reader.result as string);
this.#renderPendingImages();
};
reader.readAsDataURL(file);
}
this.#attachInput.value = "";
});
// Send button
this.#sendBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#send();
});
// Enter key (without shift)
this.#promptInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.#send();
}
});
// Model select
this.#modelSelect?.addEventListener("change", () => {
this.#model = this.#modelSelect?.value || "gemini-flash";
});
// Clear button
clearBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#clearChat();
});
// Prevent drag on inputs
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
// Voice dictation
const micBtn = wrapper.querySelector(".mic-btn") as HTMLButtonElement | null;
if (micBtn) {
let baseText = "";
let interimText = "";
const dictation = new SpeechDictation({
onInterim: (text) => {
interimText = text;
if (this.#promptInput) {
this.#promptInput.value = baseText + (baseText ? " " : "") + text;
}
},
onFinal: (text) => {
interimText = "";
baseText += (baseText ? " " : "") + text;
if (this.#promptInput) {
this.#promptInput.value = baseText;
}
},
onStateChange: (recording) => {
micBtn.classList.toggle("recording", recording);
if (!recording) {
baseText = this.#promptInput?.value || "";
interimText = "";
}
},
onError: (err) => console.warn("Prompt dictation:", err),
});
micBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (!dictation.isRecording) {
baseText = this.#promptInput?.value || "";
}
dictation.toggle();
this.#promptInput?.focus();
});
}
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
return root;
}
#renderPendingImages() {
if (!this.#pendingImagesEl) return;
if (!this.#pendingImages.length) {
this.#pendingImagesEl.innerHTML = "";
return;
}
this.#pendingImagesEl.innerHTML = this.#pendingImages
.map(
(img, i) => `<div class="pending-thumb">
<img src="${this.#escapeHtml(img)}" />
<button class="remove-img" data-img-index="${i}">×</button>
</div>`
)
.join("");
this.#pendingImagesEl.querySelectorAll(".remove-img").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt((btn as HTMLElement).dataset.imgIndex || "0", 10);
this.#pendingImages.splice(idx, 1);
this.#renderPendingImages();
});
});
}
async #send() {
const content = this.#promptInput?.value.trim();
if (!content || this.#isStreaming) return;
// Add user message with any pending images
const userMessage: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
content,
images: this.#pendingImages.length ? [...this.#pendingImages] : undefined,
timestamp: new Date(),
};
this.#messages.push(userMessage);
// Clear input and pending images
if (this.#promptInput) this.#promptInput.value = "";
this.#pendingImages = [];
this.#renderPendingImages();
this.#isStreaming = true;
this.#error = null;
if (this.#sendBtn) this.#sendBtn.disabled = true;
this.#renderMessages(true);
try {
const response = await fetch("/api/prompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: this.#messages.map((m) => ({
role: m.role,
content: m.content,
...(m.images?.length ? { images: m.images } : {}),
})),
model: this.#model,
}),
});
if (!response.ok) {
throw new Error(`Request failed: ${response.statusText}`);
}
const result = await response.json();
const assistantMessage: ChatMessage = {
id: crypto.randomUUID(),
role: "assistant",
content: result.content || result.text || result.message,
timestamp: new Date(),
};
this.#messages.push(assistantMessage);
this.dispatchEvent(
new CustomEvent("message", { detail: { message: assistantMessage } })
);
} catch (error) {
this.#error = error instanceof Error ? error.message : "Request failed";
// Remove the user message if we failed
this.#messages.pop();
} finally {
this.#isStreaming = false;
if (this.#sendBtn) this.#sendBtn.disabled = false;
this.#renderMessages(false);
}
}
#clearChat() {
this.#messages = [];
this.#error = null;
this.#renderMessages(false);
}
#renderMessages(streaming: boolean) {
if (!this.#messagesEl) return;
if (this.#messages.length === 0 && !this.#error) {
this.#messagesEl.innerHTML = `
<div class="placeholder">
<span class="placeholder-icon">🤖</span>
<span>Ask me anything!</span>
<span style="font-size: 11px;">I can help with code, writing, analysis, and more</span>
</div>
`;
return;
}
let messagesHtml = this.#messages
.map((msg) => {
let imgHtml = "";
if (msg.images?.length) {
imgHtml = `<div class="msg-images">${msg.images.map((src) => `<img src="${this.#escapeHtml(src)}" />`).join("")}</div>`;
}
return `<div class="message ${msg.role}">${this.#formatContent(msg.content)}${imgHtml}</div>`;
})
.join("");
if (streaming) {
messagesHtml += '<div class="message assistant streaming"></div>';
}
if (this.#error) {
messagesHtml += `<div class="error">${this.#escapeHtml(this.#error)}</div>`;
}
this.#messagesEl.innerHTML = messagesHtml;
this.#messagesEl.scrollTop = this.#messagesEl.scrollHeight;
}
#formatContent(content: string): string {
// Simple markdown-like formatting
let html = this.#escapeHtml(content);
// Code blocks
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>");
// Inline code
html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
// Bold
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
// Line breaks
html = html.replace(/\n/g, "<br>");
return html;
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-prompt",
model: this.#model,
messages: this.messages.map((msg) => ({
role: msg.role,
content: msg.content,
...(msg.images?.length ? { images: msg.images } : {}),
timestamp: msg.timestamp.toISOString(),
})),
};
}
}