rspace-online/lib/folk-prompt.ts

482 lines
10 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";
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;
}
.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;
}
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;
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-1.5-flash";
#messagesEl: HTMLElement | null = null;
#promptInput: HTMLTextAreaElement | null = null;
#modelSelect: HTMLSelectElement | null = null;
#sendBtn: HTMLButtonElement | 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">
<option value="gemini-1.5-flash">Gemini Flash</option>
<option value="gemini-1.5-pro">Gemini Pro</option>
<option value="claude-3-haiku">Claude Haiku</option>
<option value="claude-3-sonnet">Claude Sonnet</option>
</select>
<div class="prompt-row">
<textarea class="prompt-input" placeholder="Type your message..." rows="2"></textarea>
<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");
const clearBtn = wrapper.querySelector(".clear-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// 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-1.5-flash";
});
// Clear button
clearBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#clearChat();
});
// Prevent drag on inputs
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
return root;
}
async #send() {
const content = this.#promptInput?.value.trim();
if (!content || this.#isStreaming) return;
// Add user message
const userMessage: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
content,
timestamp: new Date(),
};
this.#messages.push(userMessage);
// Clear input
if (this.#promptInput) this.#promptInput.value = "";
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,
})),
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) => `<div class="message ${msg.role}">${this.#formatContent(msg.content)}</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) => ({
...msg,
timestamp: msg.timestamp.toISOString(),
})),
};
}
}