468 lines
12 KiB
TypeScript
468 lines
12 KiB
TypeScript
import { FolkShape } from "./folk-shape";
|
|
import { css, html } from "./tags";
|
|
|
|
const styles = css`
|
|
:host {
|
|
background: var(--rs-bg-surface, #fff);
|
|
color: var(--rs-text-primary, #1e293b);
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
min-width: 420px;
|
|
min-height: 500px;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
background: linear-gradient(135deg, #7c3aed, #a78bfa);
|
|
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;
|
|
align-items: center;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.state-badge {
|
|
font-size: 9px;
|
|
padding: 2px 6px;
|
|
border-radius: 10px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
background: rgba(255,255,255,0.2);
|
|
}
|
|
|
|
.wrapper {
|
|
height: 100%;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.prompt-area {
|
|
padding: 12px;
|
|
border-bottom: 1px solid var(--rs-border, #e2e8f0);
|
|
}
|
|
|
|
.prompt-input {
|
|
width: 100%;
|
|
padding: 10px 12px;
|
|
border: 2px solid var(--rs-input-border, #e2e8f0);
|
|
border-radius: 8px;
|
|
font-size: 13px;
|
|
resize: none;
|
|
outline: none;
|
|
font-family: inherit;
|
|
background: var(--rs-input-bg, #fff);
|
|
color: var(--rs-input-text, inherit);
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.prompt-input:focus {
|
|
border-color: #7c3aed;
|
|
}
|
|
|
|
.btn-row {
|
|
display: flex;
|
|
gap: 6px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.btn {
|
|
padding: 6px 14px;
|
|
border-radius: 6px;
|
|
border: none;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, #7c3aed, #a78bfa);
|
|
color: white;
|
|
}
|
|
.btn-primary:hover { background: linear-gradient(135deg, #6d28d9, #8b5cf6); }
|
|
.btn-primary:disabled { opacity: 0.5; cursor: default; }
|
|
|
|
.btn-secondary {
|
|
background: var(--rs-bg-elevated, #f1f5f9);
|
|
color: var(--rs-text-primary, #475569);
|
|
}
|
|
.btn-secondary:hover { background: var(--rs-bg-hover, #e2e8f0); }
|
|
|
|
.status-area {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 12px;
|
|
font-size: 12px;
|
|
min-height: 0;
|
|
}
|
|
|
|
.step {
|
|
padding: 6px 0;
|
|
border-bottom: 1px solid var(--rs-border, #f1f5f9);
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
}
|
|
|
|
.step-icon {
|
|
flex-shrink: 0;
|
|
width: 18px;
|
|
height: 18px;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 10px;
|
|
margin-top: 1px;
|
|
}
|
|
|
|
.step-icon.thinking { background: #dbeafe; color: #2563eb; }
|
|
.step-icon.executing { background: #fef3c7; color: #d97706; }
|
|
.step-icon.done { background: #d1fae5; color: #059669; }
|
|
.step-icon.error { background: #fee2e2; color: #dc2626; }
|
|
|
|
.step-content {
|
|
flex: 1;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.step-tool {
|
|
font-family: monospace;
|
|
background: var(--rs-bg-elevated, #f1f5f9);
|
|
padding: 1px 4px;
|
|
border-radius: 3px;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.export-row {
|
|
display: flex;
|
|
gap: 6px;
|
|
padding: 8px 12px;
|
|
border-top: 1px solid var(--rs-border, #e2e8f0);
|
|
justify-content: center;
|
|
}
|
|
|
|
.placeholder {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 2rem;
|
|
color: #94a3b8;
|
|
text-align: center;
|
|
}
|
|
|
|
.placeholder-icon { font-size: 2rem; }
|
|
|
|
.spinner {
|
|
display: inline-block;
|
|
width: 12px;
|
|
height: 12px;
|
|
border: 2px solid rgba(255,255,255,0.3);
|
|
border-top-color: #fff;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
vertical-align: middle;
|
|
margin-right: 4px;
|
|
}
|
|
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
`;
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"folk-design-agent": FolkDesignAgent;
|
|
}
|
|
}
|
|
|
|
type AgentState = "idle" | "planning" | "executing" | "verifying" | "done" | "error";
|
|
|
|
export class FolkDesignAgent extends FolkShape {
|
|
static override tagName = "folk-design-agent";
|
|
|
|
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;
|
|
}
|
|
|
|
#state: AgentState = "idle";
|
|
#abortController: AbortController | null = null;
|
|
#promptInput: HTMLTextAreaElement | null = null;
|
|
#statusArea: HTMLElement | null = null;
|
|
#generateBtn: HTMLButtonElement | null = null;
|
|
#stopBtn: HTMLButtonElement | null = null;
|
|
#exportRow: HTMLElement | null = null;
|
|
#stateBadge: HTMLElement | null = null;
|
|
|
|
override createRenderRoot() {
|
|
const root = super.createRenderRoot();
|
|
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "wrapper";
|
|
wrapper.innerHTML = html`
|
|
<div class="header">
|
|
<span class="header-title">
|
|
<span>🎯</span>
|
|
<span>rDesign Agent</span>
|
|
</span>
|
|
<div class="header-actions">
|
|
<span class="state-badge" data-ref="state-badge">Idle</span>
|
|
<button class="close-btn" title="Close">×</button>
|
|
</div>
|
|
</div>
|
|
<div class="content">
|
|
<div class="prompt-area">
|
|
<textarea class="prompt-input" placeholder="Describe the design you want to create..." rows="3"></textarea>
|
|
<div class="btn-row">
|
|
<button class="btn btn-primary" data-ref="generate-btn">Generate Design</button>
|
|
<button class="btn btn-secondary" data-ref="stop-btn" style="display:none">Stop</button>
|
|
</div>
|
|
</div>
|
|
<div class="status-area" data-ref="status-area">
|
|
<div class="placeholder">
|
|
<span class="placeholder-icon">📐</span>
|
|
<span>Enter a design brief to get started.<br>The agent will create a Scribus document step by step.</span>
|
|
</div>
|
|
</div>
|
|
<div class="export-row" data-ref="export-row" style="display:none">
|
|
<a class="btn btn-secondary" href="https://design.rspace.online" target="_blank" rel="noopener">Open in Scribus</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Replace slot container with our wrapper
|
|
const slot = root.querySelector("slot");
|
|
const containerDiv = slot?.parentElement as HTMLElement;
|
|
if (containerDiv) containerDiv.replaceWith(wrapper);
|
|
|
|
this.#promptInput = wrapper.querySelector(".prompt-input");
|
|
this.#statusArea = wrapper.querySelector('[data-ref="status-area"]');
|
|
this.#generateBtn = wrapper.querySelector('[data-ref="generate-btn"]');
|
|
this.#stopBtn = wrapper.querySelector('[data-ref="stop-btn"]');
|
|
this.#exportRow = wrapper.querySelector('[data-ref="export-row"]');
|
|
this.#stateBadge = wrapper.querySelector('[data-ref="state-badge"]');
|
|
|
|
// Set initial brief from attribute
|
|
const brief = this.getAttribute("brief");
|
|
if (brief && this.#promptInput) this.#promptInput.value = brief;
|
|
|
|
// Generate button
|
|
this.#generateBtn?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#generate();
|
|
});
|
|
|
|
// Stop button
|
|
this.#stopBtn?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.#stop();
|
|
});
|
|
|
|
// Enter key
|
|
this.#promptInput?.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
this.#generate();
|
|
}
|
|
});
|
|
|
|
// Prevent canvas drag
|
|
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
|
|
this.#statusArea?.addEventListener("pointerdown", (e) => e.stopPropagation());
|
|
|
|
// Close button
|
|
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
|
closeBtn?.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.dispatchEvent(new CustomEvent("close"));
|
|
});
|
|
|
|
return root;
|
|
}
|
|
|
|
#setState(state: AgentState) {
|
|
this.#state = state;
|
|
if (this.#stateBadge) this.#stateBadge.textContent = state.charAt(0).toUpperCase() + state.slice(1);
|
|
|
|
const isWorking = state !== "idle" && state !== "done" && state !== "error";
|
|
if (this.#generateBtn) {
|
|
this.#generateBtn.disabled = isWorking;
|
|
this.#generateBtn.innerHTML = isWorking
|
|
? '<span class="spinner"></span> Working...'
|
|
: "Generate Design";
|
|
}
|
|
if (this.#stopBtn) this.#stopBtn.style.display = isWorking ? "" : "none";
|
|
if (this.#exportRow) this.#exportRow.style.display = state === "done" ? "" : "none";
|
|
if (this.#promptInput) this.#promptInput.disabled = isWorking;
|
|
}
|
|
|
|
#addStep(icon: string, cls: string, text: string) {
|
|
if (!this.#statusArea) return;
|
|
// Remove placeholder on first step
|
|
const placeholder = this.#statusArea.querySelector(".placeholder");
|
|
if (placeholder) placeholder.remove();
|
|
|
|
const step = document.createElement("div");
|
|
step.className = "step";
|
|
step.innerHTML = `<div class="step-icon ${cls}">${icon}</div><div class="step-content">${text}</div>`;
|
|
this.#statusArea.appendChild(step);
|
|
this.#statusArea.scrollTop = this.#statusArea.scrollHeight;
|
|
}
|
|
|
|
async #generate() {
|
|
const brief = this.#promptInput?.value.trim();
|
|
if (!brief || (this.#state !== "idle" && this.#state !== "done" && this.#state !== "error")) return;
|
|
|
|
// Clear previous steps
|
|
if (this.#statusArea) this.#statusArea.innerHTML = "";
|
|
this.#setState("planning");
|
|
this.#abortController = new AbortController();
|
|
|
|
try {
|
|
const space = this.closest("[data-space]")?.getAttribute("data-space") || "demo";
|
|
const res = await fetch("/api/design-agent", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ brief, space }),
|
|
signal: this.#abortController.signal,
|
|
});
|
|
|
|
if (!res.ok || !res.body) {
|
|
this.#addStep("!", "error", `Request failed: ${res.status}`);
|
|
this.#setState("error");
|
|
return;
|
|
}
|
|
|
|
const reader = res.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
buffer += decoder.decode(value, { stream: true });
|
|
const lines = buffer.split("\n");
|
|
buffer = lines.pop() || "";
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("data:")) {
|
|
try {
|
|
const data = JSON.parse(line.slice(5).trim());
|
|
this.#processEvent(data);
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.#state !== "error") this.#setState("done");
|
|
} catch (e: any) {
|
|
if (e.name !== "AbortError") {
|
|
this.#addStep("!", "error", `Error: ${e.message}`);
|
|
this.#setState("error");
|
|
}
|
|
}
|
|
|
|
this.#abortController = null;
|
|
}
|
|
|
|
#processEvent(data: any) {
|
|
switch (data.action) {
|
|
case "starting_scribus":
|
|
this.#addStep("~", "thinking", data.status || "Starting Scribus...");
|
|
break;
|
|
case "scribus_ready":
|
|
this.#addStep("✓", "done", "Scribus ready");
|
|
break;
|
|
case "thinking":
|
|
this.#setState("planning");
|
|
this.#addStep("~", "thinking", data.status || "Thinking...");
|
|
break;
|
|
case "executing":
|
|
this.#setState("executing");
|
|
this.#addStep("▶", "executing",
|
|
`${data.status || "Executing"}: <span class="step-tool">${data.tool}</span>`);
|
|
break;
|
|
case "tool_result":
|
|
if (data.result?.error) {
|
|
this.#addStep("!", "error", `${data.tool} failed: ${data.result.error}`);
|
|
} else {
|
|
this.#addStep("✓", "done", `${data.tool} completed`);
|
|
}
|
|
break;
|
|
case "verifying":
|
|
this.#setState("verifying");
|
|
this.#addStep("~", "thinking", data.status || "Verifying...");
|
|
break;
|
|
case "complete":
|
|
this.#addStep("✓", "done", data.message || "Design complete");
|
|
break;
|
|
case "done":
|
|
this.#addStep("✓", "done", data.status || "Done!");
|
|
if (data.state?.frames) {
|
|
this.#addStep("✓", "done", `${data.state.frames.length} frame(s) in document`);
|
|
}
|
|
break;
|
|
case "error":
|
|
this.#addStep("!", "error", data.error || "Unknown error");
|
|
this.#setState("error");
|
|
break;
|
|
}
|
|
}
|
|
|
|
#stop() {
|
|
this.#abortController?.abort();
|
|
this.#setState("idle");
|
|
this.#addStep("!", "error", "Stopped by user");
|
|
}
|
|
}
|
|
|
|
if (!customElements.get(FolkDesignAgent.tagName)) {
|
|
customElements.define(FolkDesignAgent.tagName, FolkDesignAgent);
|
|
}
|