feat: ASCII art canvas tool, video gen fixes, scribus/notebook sidecars
- Add folk-ascii-gen canvas shape with pattern/palette selectors
- Add POST /api/ascii-gen proxy to ascii-art service
- Register create_ascii_art in canvas tools + triage panel
- Fix WAN 2.1 t2v endpoint URL (fal-ai/wan/v2.1 → fal-ai/wan-t2v)
- Convert video gen to async job queue (avoids Cloudflare timeouts)
- Fix Docker API Content-Type bug in sidecar-manager
- Convert scribus-novnc and open-notebook to on-demand sidecars
- Add ensureSidecar("scribus-novnc") to rDesign bridge proxy
- Fix Hono ContextVariableMap and handleTransakMessage type errors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
801de38b4a
commit
46c326278a
|
|
@ -318,12 +318,13 @@ services:
|
|||
networks:
|
||||
- rspace-internal
|
||||
|
||||
# ── Scribus noVNC (rDesign DTP workspace) ──
|
||||
# ── Scribus noVNC (rDesign DTP workspace) — on-demand sidecar ──
|
||||
scribus-novnc:
|
||||
build:
|
||||
context: ./docker/scribus-novnc
|
||||
container_name: scribus-novnc
|
||||
restart: unless-stopped
|
||||
restart: "no"
|
||||
profiles: ["sidecar"]
|
||||
mem_limit: 512m
|
||||
cpus: 1
|
||||
volumes:
|
||||
|
|
@ -342,22 +343,15 @@ services:
|
|||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.scribus-novnc.rule=Host(`design.rspace.online`)"
|
||||
- "traefik.http.routers.scribus-novnc.entrypoints=web"
|
||||
- "traefik.http.routers.scribus-novnc.priority=150"
|
||||
- "traefik.http.services.scribus-novnc.loadbalancer.server.port=6080"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
networks:
|
||||
- traefik-public
|
||||
- rspace-internal
|
||||
|
||||
# ── Open Notebook (NotebookLM-like RAG service) ──
|
||||
# ── Open Notebook (NotebookLM-like RAG service) — on-demand sidecar ──
|
||||
open-notebook:
|
||||
image: ghcr.io/lfnovo/open-notebook:v1-latest-single
|
||||
container_name: open-notebook
|
||||
restart: always
|
||||
restart: "no"
|
||||
profiles: ["sidecar"]
|
||||
mem_limit: 1g
|
||||
cpus: 1
|
||||
env_file: ./open-notebook.env
|
||||
|
|
@ -365,21 +359,8 @@ services:
|
|||
- open-notebook-data:/app/data
|
||||
- open-notebook-db:/mydata
|
||||
networks:
|
||||
- traefik-public
|
||||
- rspace-internal
|
||||
- ai-internal
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
# Frontend UI
|
||||
- "traefik.http.routers.rspace-notebook.rule=Host(`notebook.rspace.online`)"
|
||||
- "traefik.http.routers.rspace-notebook.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-notebook.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.rspace-notebook.loadbalancer.server.port=8502"
|
||||
# API endpoint (used by rNotes integration)
|
||||
- "traefik.http.routers.rspace-notebook-api.rule=Host(`notebook-api.rspace.online`)"
|
||||
- "traefik.http.routers.rspace-notebook-api.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-notebook-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.rspace-notebook-api.loadbalancer.server.port=5055"
|
||||
|
||||
volumes:
|
||||
rspace-data:
|
||||
|
|
|
|||
|
|
@ -470,6 +470,42 @@ registry.push(
|
|||
},
|
||||
);
|
||||
|
||||
// ── ASCII Art Tool ──
|
||||
registry.push({
|
||||
declaration: {
|
||||
name: "create_ascii_art",
|
||||
description: "Generate ASCII art from patterns like plasma, mandelbrot, spiral, waves, nebula, kaleidoscope, aurora, lava, crystals, or fractal_tree.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
prompt: { type: "string", description: "Pattern name or description of what to generate" },
|
||||
pattern: {
|
||||
type: "string",
|
||||
description: "Pattern type",
|
||||
enum: ["plasma", "mandelbrot", "spiral", "waves", "nebula", "kaleidoscope", "aurora", "lava", "crystals", "fractal_tree", "random"],
|
||||
},
|
||||
palette: {
|
||||
type: "string",
|
||||
description: "Character palette to use",
|
||||
enum: ["classic", "blocks", "braille", "dots", "shades", "emoji", "cosmic", "runes", "geometric", "kanji", "hieroglyph", "alchemical"],
|
||||
},
|
||||
width: { type: "number", description: "Width in characters (default 80)" },
|
||||
height: { type: "number", description: "Height in characters (default 40)" },
|
||||
},
|
||||
required: ["prompt"],
|
||||
},
|
||||
},
|
||||
tagName: "folk-ascii-gen",
|
||||
buildProps: (args) => ({
|
||||
prompt: args.prompt,
|
||||
...(args.pattern ? { pattern: args.pattern } : {}),
|
||||
...(args.palette ? { palette: args.palette } : {}),
|
||||
...(args.width ? { width: args.width } : {}),
|
||||
...(args.height ? { height: args.height } : {}),
|
||||
}),
|
||||
actionLabel: (args) => `Generating ASCII art: ${args.prompt?.slice(0, 50) || args.pattern || "random"}`,
|
||||
});
|
||||
|
||||
// ── Design Agent Tool ──
|
||||
registry.push({
|
||||
declaration: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,433 @@
|
|||
import { FolkShape } from "./folk-shape";
|
||||
import { css, html } from "./tags";
|
||||
|
||||
const PATTERNS = [
|
||||
"plasma", "mandelbrot", "spiral", "waves", "nebula", "kaleidoscope",
|
||||
"aurora", "lava", "crystals", "fractal_tree", "random",
|
||||
] as const;
|
||||
|
||||
const PALETTES = [
|
||||
"classic", "blocks", "braille", "dots", "shades", "hires", "ultra", "dense",
|
||||
"emoji", "cosmic", "mystic", "runes", "geometric", "flora", "weather",
|
||||
"wingdings", "zodiac", "chess", "arrows", "music", "box", "math",
|
||||
"kanji", "thai", "arabic", "devanagari", "hieroglyph", "cuneiform",
|
||||
"alchemical", "dominos", "mahjong", "dingbats", "playing", "yijing",
|
||||
] as const;
|
||||
|
||||
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: 380px;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: linear-gradient(135deg, #22c55e, #14b8a6);
|
||||
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;
|
||||
}
|
||||
|
||||
.prompt-area {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--rs-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.prompt-input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 2px solid var(--rs-input-border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
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: #14b8a6;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.controls select {
|
||||
padding: 5px 8px;
|
||||
border: 2px solid var(--rs-input-border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
background: var(--rs-input-bg, #fff);
|
||||
color: var(--rs-input-text, inherit);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.size-input {
|
||||
width: 52px;
|
||||
padding: 5px 6px;
|
||||
border: 2px solid var(--rs-input-border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
background: var(--rs-input-bg, #fff);
|
||||
color: var(--rs-input-text, inherit);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
padding: 6px 14px;
|
||||
background: linear-gradient(135deg, #22c55e, #14b8a6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.generate-btn:hover { opacity: 0.9; }
|
||||
.generate-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.preview-area {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.placeholder-icon { font-size: 48px; opacity: 0.5; }
|
||||
|
||||
.ascii-output {
|
||||
font-family: "Courier New", Consolas, monospace;
|
||||
font-size: 10px;
|
||||
line-height: 1.1;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.ascii-output.light-bg {
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--rs-border, #e2e8f0);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--rs-border, #e2e8f0);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
background: var(--rs-bg-surface, #fff);
|
||||
color: var(--rs-text-primary, #1e293b);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--rs-bg-hover, #f1f5f9);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top-color: #14b8a6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
padding: 12px;
|
||||
background: #fef2f2;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-ascii-gen": FolkAsciiGen;
|
||||
}
|
||||
}
|
||||
|
||||
export class FolkAsciiGen extends FolkShape {
|
||||
static override tagName = "folk-ascii-gen";
|
||||
|
||||
static override portDescriptors = [
|
||||
{ name: "prompt", type: "text" as const, direction: "input" as const },
|
||||
{ name: "ascii", type: "text" as const, direction: "output" as const },
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
#isLoading = false;
|
||||
#error: string | null = null;
|
||||
#htmlOutput: string | null = null;
|
||||
#textOutput: string | null = null;
|
||||
#promptInput: HTMLTextAreaElement | null = null;
|
||||
#patternSelect: HTMLSelectElement | null = null;
|
||||
#paletteSelect: HTMLSelectElement | null = null;
|
||||
#widthInput: HTMLInputElement | null = null;
|
||||
#heightInput: HTMLInputElement | null = null;
|
||||
#previewArea: HTMLElement | null = null;
|
||||
#generateBtn: HTMLButtonElement | null = null;
|
||||
#actionsBar: HTMLElement | null = null;
|
||||
|
||||
override createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.innerHTML = html`
|
||||
<div class="header">
|
||||
<span class="header-title">
|
||||
<span style="font-size:14px">▦</span>
|
||||
<span>ASCII Art</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button class="close-btn" title="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="prompt-area">
|
||||
<textarea class="prompt-input" placeholder="Pattern name or describe what to generate..." rows="2"></textarea>
|
||||
<div class="controls">
|
||||
<select class="pattern-select">
|
||||
${PATTERNS.map((p) => `<option value="${p}">${p}</option>`).join("")}
|
||||
</select>
|
||||
<select class="palette-select">
|
||||
${PALETTES.map((p) => `<option value="${p}"${p === "classic" ? " selected" : ""}>${p}</option>`).join("")}
|
||||
</select>
|
||||
<input class="size-input" type="number" min="20" max="300" value="80" title="Width" placeholder="W" />
|
||||
<span style="line-height:28px;color:#94a3b8;font-size:11px">×</span>
|
||||
<input class="size-input" type="number" min="10" max="150" value="40" title="Height" placeholder="H" />
|
||||
<button class="generate-btn">Generate</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-area">
|
||||
<div class="placeholder">
|
||||
<span class="placeholder-icon">▦</span>
|
||||
<span>Pick a pattern and click Generate</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions-bar" style="display:none">
|
||||
<button class="action-btn copy-text-btn">Copy Text</button>
|
||||
<button class="action-btn copy-html-btn">Copy HTML</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const slot = root.querySelector("slot");
|
||||
const containerDiv = slot?.parentElement as HTMLElement;
|
||||
if (containerDiv) containerDiv.replaceWith(wrapper);
|
||||
|
||||
this.#promptInput = wrapper.querySelector(".prompt-input");
|
||||
this.#patternSelect = wrapper.querySelector(".pattern-select");
|
||||
this.#paletteSelect = wrapper.querySelector(".palette-select");
|
||||
this.#widthInput = wrapper.querySelector('input[title="Width"]');
|
||||
this.#heightInput = wrapper.querySelector('input[title="Height"]');
|
||||
this.#previewArea = wrapper.querySelector(".preview-area");
|
||||
this.#generateBtn = wrapper.querySelector(".generate-btn");
|
||||
this.#actionsBar = wrapper.querySelector(".actions-bar");
|
||||
|
||||
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
|
||||
const copyTextBtn = wrapper.querySelector(".copy-text-btn") as HTMLButtonElement;
|
||||
const copyHtmlBtn = wrapper.querySelector(".copy-html-btn") as HTMLButtonElement;
|
||||
|
||||
this.#generateBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#generate();
|
||||
});
|
||||
|
||||
this.#promptInput?.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.#generate();
|
||||
}
|
||||
});
|
||||
|
||||
copyTextBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.#textOutput) navigator.clipboard.writeText(this.#textOutput);
|
||||
});
|
||||
|
||||
copyHtmlBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.#htmlOutput) navigator.clipboard.writeText(this.#htmlOutput);
|
||||
});
|
||||
|
||||
closeBtn?.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
|
||||
// Prevent canvas drag
|
||||
this.#previewArea?.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
async #generate() {
|
||||
if (this.#isLoading) return;
|
||||
|
||||
const pattern = this.#patternSelect?.value || "plasma";
|
||||
const prompt = this.#promptInput?.value.trim() || pattern;
|
||||
const palette = this.#paletteSelect?.value || "classic";
|
||||
const width = parseInt(this.#widthInput?.value || "80") || 80;
|
||||
const height = parseInt(this.#heightInput?.value || "40") || 40;
|
||||
|
||||
this.#isLoading = true;
|
||||
this.#error = null;
|
||||
if (this.#generateBtn) this.#generateBtn.disabled = true;
|
||||
this.#renderLoading();
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/ascii-gen", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt, pattern, palette, width, height, output_format: "html" }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.#htmlOutput = data.html || null;
|
||||
this.#textOutput = data.text || null;
|
||||
this.#renderResult();
|
||||
|
||||
// Emit to output port
|
||||
if (this.#textOutput) {
|
||||
this.dispatchEvent(new CustomEvent("port-output", {
|
||||
detail: { port: "ascii", value: this.#textOutput },
|
||||
bubbles: true,
|
||||
}));
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.#error = e.message || "Generation failed";
|
||||
this.#renderError();
|
||||
} finally {
|
||||
this.#isLoading = false;
|
||||
if (this.#generateBtn) this.#generateBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
#renderLoading() {
|
||||
if (!this.#previewArea) return;
|
||||
this.#previewArea.innerHTML = `<div class="loading"><div class="spinner"></div><span style="font-size:12px;color:#64748b">Generating...</span></div>`;
|
||||
if (this.#actionsBar) this.#actionsBar.style.display = "none";
|
||||
}
|
||||
|
||||
#renderError() {
|
||||
if (!this.#previewArea) return;
|
||||
this.#previewArea.innerHTML = `<div class="error">${this.#error}</div>`;
|
||||
if (this.#actionsBar) this.#actionsBar.style.display = "none";
|
||||
}
|
||||
|
||||
#renderResult() {
|
||||
if (!this.#previewArea) return;
|
||||
if (this.#htmlOutput) {
|
||||
this.#previewArea.innerHTML = `<div class="ascii-output">${this.#htmlOutput}</div>`;
|
||||
} else if (this.#textOutput) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "ascii-output";
|
||||
el.textContent = this.#textOutput;
|
||||
this.#previewArea.innerHTML = "";
|
||||
this.#previewArea.appendChild(el);
|
||||
} else {
|
||||
this.#previewArea.innerHTML = `<div class="placeholder"><span>No output received</span></div>`;
|
||||
}
|
||||
if (this.#actionsBar) this.#actionsBar.style.display = "flex";
|
||||
}
|
||||
}
|
||||
|
|
@ -468,33 +468,59 @@ export class FolkVideoGen extends FolkShape {
|
|||
? { image: this.#sourceImage, prompt, duration }
|
||||
: { prompt, duration };
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
const submitRes = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Generation failed: ${response.statusText}`);
|
||||
if (!submitRes.ok) {
|
||||
const err = await submitRes.json().catch(() => ({}));
|
||||
throw new Error(err.error || `Generation failed: ${submitRes.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const submitData = await submitRes.json();
|
||||
|
||||
const video: GeneratedVideo = {
|
||||
id: crypto.randomUUID(),
|
||||
prompt,
|
||||
url: result.url || result.video_url,
|
||||
sourceImage: this.#mode === "i2v" ? this.#sourceImage || undefined : undefined,
|
||||
duration,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
// Poll for job completion (up to 5 minutes)
|
||||
const jobId = submitData.job_id;
|
||||
if (!jobId) throw new Error("No job ID returned");
|
||||
|
||||
this.#videos.unshift(video);
|
||||
this.#renderVideos();
|
||||
this.dispatchEvent(new CustomEvent("video-generated", { detail: { video } }));
|
||||
const deadline = Date.now() + 300_000;
|
||||
let elapsed = 0;
|
||||
|
||||
// Clear input
|
||||
if (this.#promptInput) this.#promptInput.value = "";
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
elapsed += 3;
|
||||
this.#progress = Math.min(90, (elapsed / 120) * 100);
|
||||
this.#renderLoading();
|
||||
|
||||
const pollRes = await fetch(`/api/video-gen/${jobId}`);
|
||||
if (!pollRes.ok) continue;
|
||||
const pollData = await pollRes.json();
|
||||
|
||||
if (pollData.status === "complete") {
|
||||
const video: GeneratedVideo = {
|
||||
id: crypto.randomUUID(),
|
||||
prompt,
|
||||
url: pollData.url || pollData.video_url,
|
||||
sourceImage: this.#mode === "i2v" ? this.#sourceImage || undefined : undefined,
|
||||
duration,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
this.#videos.unshift(video);
|
||||
this.#renderVideos();
|
||||
this.dispatchEvent(new CustomEvent("video-generated", { detail: { video } }));
|
||||
if (this.#promptInput) this.#promptInput.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (pollData.status === "failed") {
|
||||
throw new Error(pollData.error || "Video generation failed");
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Video generation timed out");
|
||||
} catch (error) {
|
||||
this.#error = error instanceof Error ? error.message : "Generation failed";
|
||||
this.#renderError();
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export * from "./folk-drawfast";
|
|||
export * from "./folk-freecad";
|
||||
export * from "./folk-kicad";
|
||||
export * from "./folk-design-agent";
|
||||
export * from "./folk-ascii-gen";
|
||||
|
||||
// Advanced Shapes
|
||||
export * from "./folk-video-chat";
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const SHAPE_ICONS: Record<string, { icon: string; label: string }> = {
|
|||
"folk-freecad": { icon: "🔧", label: "CAD" },
|
||||
"folk-kicad": { icon: "🔌", label: "PCB" },
|
||||
"folk-design-agent": { icon: "🖨️", label: "Design" },
|
||||
"folk-ascii-gen": { icon: "▦", label: "ASCII Art" },
|
||||
// Social
|
||||
"folk-social-post": { icon: "📣", label: "Social Post" },
|
||||
"folk-social-thread": { icon: "🧵", label: "Thread" },
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { renderShell } from "../../server/shell";
|
|||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { designAgentRoutes } from "./design-agent-route";
|
||||
import { ensureSidecar } from "../../server/sidecar-manager";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ routes.get("/api/health", (c) => {
|
|||
|
||||
// Proxy bridge API calls from rspace to the Scribus container
|
||||
routes.all("/api/bridge/*", async (c) => {
|
||||
await ensureSidecar("scribus-novnc");
|
||||
const path = c.req.path.replace(/^.*\/api\/bridge/, "/api/scribus");
|
||||
const bridgeSecret = process.env.SCRIBUS_BRIDGE_SECRET || "";
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
|
|
|
|||
246
server/index.ts
246
server/index.ts
|
|
@ -1046,6 +1046,124 @@ setInterval(() => {
|
|||
}
|
||||
}, 30 * 60 * 1000);
|
||||
|
||||
// ── Video generation job queue (async to avoid Cloudflare timeouts) ──
|
||||
|
||||
interface VideoGenJob {
|
||||
id: string;
|
||||
status: "pending" | "processing" | "complete" | "failed";
|
||||
type: "t2v" | "i2v";
|
||||
prompt: string;
|
||||
sourceImage?: string;
|
||||
resultUrl?: string;
|
||||
error?: string;
|
||||
createdAt: number;
|
||||
completedAt?: number;
|
||||
}
|
||||
|
||||
const videoGenJobs = new Map<string, VideoGenJob>();
|
||||
|
||||
// Clean up old video jobs every 30 minutes (keep for 6h)
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - 6 * 60 * 60 * 1000;
|
||||
for (const [id, job] of videoGenJobs) {
|
||||
if (job.createdAt < cutoff) videoGenJobs.delete(id);
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
|
||||
async function processVideoGenJob(job: VideoGenJob) {
|
||||
job.status = "processing";
|
||||
const falHeaders = { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" };
|
||||
|
||||
try {
|
||||
const MODEL = job.type === "i2v"
|
||||
? "fal-ai/kling-video/v1/standard/image-to-video"
|
||||
: "fal-ai/wan-t2v";
|
||||
|
||||
const body = job.type === "i2v"
|
||||
? { image_url: job.sourceImage, prompt: job.prompt || "", duration: 5, aspect_ratio: "16:9" }
|
||||
: { prompt: job.prompt, num_frames: 81, resolution: "480p" };
|
||||
|
||||
// Submit to fal.ai queue
|
||||
const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, {
|
||||
method: "POST",
|
||||
headers: falHeaders,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!submitRes.ok) {
|
||||
const errText = await submitRes.text();
|
||||
console.error(`[video-gen] fal.ai submit error (${job.type}):`, submitRes.status, errText);
|
||||
job.status = "failed";
|
||||
job.error = "Video generation failed to start";
|
||||
job.completedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const { request_id } = await submitRes.json() as { request_id: string };
|
||||
|
||||
// Poll for completion (up to 5 min)
|
||||
const deadline = Date.now() + 300_000;
|
||||
let responseUrl = "";
|
||||
let completed = false;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
const statusRes = await fetch(
|
||||
`https://queue.fal.run/${MODEL}/requests/${request_id}/status`,
|
||||
{ headers: falHeaders },
|
||||
);
|
||||
if (!statusRes.ok) continue;
|
||||
const statusData = await statusRes.json() as { status: string; response_url?: string };
|
||||
console.log(`[video-gen] Poll ${job.id}: status=${statusData.status}`);
|
||||
if (statusData.response_url) responseUrl = statusData.response_url;
|
||||
if (statusData.status === "COMPLETED") { completed = true; break; }
|
||||
if (statusData.status === "FAILED") {
|
||||
job.status = "failed";
|
||||
job.error = "Video generation failed on fal.ai";
|
||||
job.completedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!completed) {
|
||||
job.status = "failed";
|
||||
job.error = "Video generation timed out";
|
||||
job.completedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch result
|
||||
const resultUrl = responseUrl || `https://queue.fal.run/${MODEL}/requests/${request_id}`;
|
||||
const resultRes = await fetch(resultUrl, { headers: falHeaders });
|
||||
if (!resultRes.ok) {
|
||||
job.status = "failed";
|
||||
job.error = "Failed to retrieve video";
|
||||
job.completedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await resultRes.json();
|
||||
const videoUrl = data.video?.url || data.output?.url;
|
||||
if (!videoUrl) {
|
||||
console.error(`[video-gen] No video URL in response:`, JSON.stringify(data).slice(0, 500));
|
||||
job.status = "failed";
|
||||
job.error = "No video returned";
|
||||
job.completedAt = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
job.status = "complete";
|
||||
job.resultUrl = videoUrl;
|
||||
job.completedAt = Date.now();
|
||||
console.log(`[video-gen] Job ${job.id} complete: ${videoUrl}`);
|
||||
} catch (e: any) {
|
||||
console.error("[video-gen] error:", e.message);
|
||||
job.status = "failed";
|
||||
job.error = "Video generation failed";
|
||||
job.completedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
let splatMailTransport: ReturnType<typeof createTransport> | null = null;
|
||||
if (process.env.SMTP_PASS) {
|
||||
splatMailTransport = createTransport({
|
||||
|
|
@ -1496,48 +1614,67 @@ app.post("/api/image-gen/img2img", async (c) => {
|
|||
return c.json({ error: `Unknown provider: ${provider}` }, 400);
|
||||
});
|
||||
|
||||
// Text-to-video via fal.ai WAN 2.1 (delegates to shared helper)
|
||||
// Text-to-video via fal.ai WAN 2.1 (async job queue to avoid Cloudflare timeouts)
|
||||
app.post("/api/video-gen/t2v", async (c) => {
|
||||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
||||
|
||||
const { prompt } = await c.req.json();
|
||||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||||
|
||||
const { generateVideoViaFal } = await import("./mi-media");
|
||||
const result = await generateVideoViaFal(prompt);
|
||||
if (!result.ok) return c.json({ error: result.error }, 502);
|
||||
return c.json({ url: result.url, video_url: result.url });
|
||||
const jobId = crypto.randomUUID();
|
||||
const job: VideoGenJob = {
|
||||
id: jobId, status: "pending", type: "t2v",
|
||||
prompt, createdAt: Date.now(),
|
||||
};
|
||||
videoGenJobs.set(jobId, job);
|
||||
processVideoGenJob(job);
|
||||
return c.json({ job_id: jobId, status: "pending" });
|
||||
});
|
||||
|
||||
// Image-to-video via fal.ai Kling
|
||||
// Image-to-video via fal.ai Kling (async job queue)
|
||||
app.post("/api/video-gen/i2v", async (c) => {
|
||||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
||||
|
||||
const { image, prompt, duration } = await c.req.json();
|
||||
const { image, prompt } = await c.req.json();
|
||||
if (!image) return c.json({ error: "image required" }, 400);
|
||||
|
||||
const res = await fetch("https://fal.run/fal-ai/kling-video/v1/standard/image-to-video", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Key ${FAL_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
image_url: image,
|
||||
prompt: prompt || "",
|
||||
duration: duration === "5s" ? "5" : "5",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
console.error("[video-gen/i2v] fal.ai error:", err);
|
||||
return c.json({ error: "Video generation failed" }, 502);
|
||||
// Stage the source image if it's a data URL
|
||||
let imageUrl = image;
|
||||
if (image.startsWith("data:")) {
|
||||
const url = await saveDataUrlToDisk(image, "vid-src");
|
||||
imageUrl = publicUrl(c, url);
|
||||
} else if (image.startsWith("/")) {
|
||||
imageUrl = publicUrl(c, image);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const videoUrl = data.video?.url || data.output?.url;
|
||||
if (!videoUrl) return c.json({ error: "No video returned" }, 502);
|
||||
const jobId = crypto.randomUUID();
|
||||
const job: VideoGenJob = {
|
||||
id: jobId, status: "pending", type: "i2v",
|
||||
prompt: prompt || "", sourceImage: imageUrl, createdAt: Date.now(),
|
||||
};
|
||||
videoGenJobs.set(jobId, job);
|
||||
processVideoGenJob(job);
|
||||
return c.json({ job_id: jobId, status: "pending" });
|
||||
});
|
||||
|
||||
return c.json({ url: videoUrl, video_url: videoUrl });
|
||||
// Poll video generation job status
|
||||
app.get("/api/video-gen/:jobId", async (c) => {
|
||||
const jobId = c.req.param("jobId");
|
||||
const job = videoGenJobs.get(jobId);
|
||||
if (!job) return c.json({ error: "Job not found" }, 404);
|
||||
|
||||
const response: Record<string, any> = {
|
||||
job_id: job.id, status: job.status, created_at: job.createdAt,
|
||||
};
|
||||
if (job.status === "complete") {
|
||||
response.url = job.resultUrl;
|
||||
response.video_url = job.resultUrl;
|
||||
response.completed_at = job.completedAt;
|
||||
} else if (job.status === "failed") {
|
||||
response.error = job.error;
|
||||
response.completed_at = job.completedAt;
|
||||
}
|
||||
return c.json(response);
|
||||
});
|
||||
|
||||
// Stage image for 3D generation (binary upload → HTTPS URL for fal.ai)
|
||||
|
|
@ -1749,6 +1886,59 @@ Output ONLY the Python code, no explanations or comments outside the code.`);
|
|||
}
|
||||
});
|
||||
|
||||
// ── ASCII Art Generation (proxies to ascii-art service on rspace-internal) ──
|
||||
const ASCII_ART_URL = process.env.ASCII_ART_URL || "http://ascii-art:8000";
|
||||
|
||||
app.post("/api/ascii-gen", async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { prompt, width, height, palette, output_format } = body;
|
||||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${ASCII_ART_URL}/api/pattern`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
width: width || 80,
|
||||
height: height || 40,
|
||||
palette: palette || "ansi",
|
||||
output_format: output_format || "html",
|
||||
}),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return c.json({ error: `ASCII art service error: ${err}` }, res.status as any);
|
||||
}
|
||||
const data = await res.json();
|
||||
return c.json(data);
|
||||
} catch (e: any) {
|
||||
console.error("[ascii-gen] error:", e);
|
||||
return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/ascii-gen/render", async (c) => {
|
||||
try {
|
||||
const res = await fetch(`${ASCII_ART_URL}/api/render`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": c.req.header("Content-Type") || "application/json" },
|
||||
body: await c.req.arrayBuffer(),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
return c.json({ error: `ASCII render error: ${err}` }, res.status as any);
|
||||
}
|
||||
const data = await res.json();
|
||||
return c.json(data);
|
||||
} catch (e: any) {
|
||||
console.error("[ascii-gen] render error:", e);
|
||||
return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// KiCAD PCB design — MCP StreamableHTTP bridge (sidecar container)
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
|
|
|
|||
|
|
@ -164,8 +164,8 @@ export async function generateVideoViaFal(prompt: string, source_image?: string)
|
|||
return { ok: true, url: videoUrl };
|
||||
}
|
||||
|
||||
// Text-to-video via WAN 2.1
|
||||
const res = await fetch("https://fal.run/fal-ai/wan/v2.1", {
|
||||
// Text-to-video via WAN 2.1 (fal.ai renamed endpoint from wan/v2.1 to wan-t2v)
|
||||
const res = await fetch("https://fal.run/fal-ai/wan-t2v", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Key ${FAL_KEY}`,
|
||||
|
|
@ -173,7 +173,7 @@ export async function generateVideoViaFal(prompt: string, source_image?: string)
|
|||
},
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
num_frames: 49,
|
||||
num_frames: 81,
|
||||
resolution: "480p",
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -45,6 +45,18 @@ const SIDECARS: Record<string, SidecarConfig> = {
|
|||
port: 11434,
|
||||
healthTimeout: 30_000,
|
||||
},
|
||||
"scribus-novnc": {
|
||||
container: "scribus-novnc",
|
||||
host: "scribus-novnc",
|
||||
port: 8765,
|
||||
healthTimeout: 30_000,
|
||||
},
|
||||
"open-notebook": {
|
||||
container: "open-notebook",
|
||||
host: "open-notebook",
|
||||
port: 5055,
|
||||
healthTimeout: 45_000,
|
||||
},
|
||||
};
|
||||
|
||||
const lastUsed = new Map<string, number>();
|
||||
|
|
@ -61,14 +73,17 @@ try {
|
|||
|
||||
// ── Docker Engine API over Unix socket ──
|
||||
|
||||
function dockerApi(method: string, path: string): Promise<{ status: number; body: any }> {
|
||||
function dockerApi(method: string, path: string, sendBody?: boolean): Promise<{ status: number; body: any }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers: Record<string, string> = {};
|
||||
// Only set Content-Type when we actually send a JSON body
|
||||
if (sendBody) headers["Content-Type"] = "application/json";
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath: DOCKER_SOCKET,
|
||||
path: `/v1.43${path}`,
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers,
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
|
|
@ -100,10 +115,11 @@ async function isContainerRunning(name: string): Promise<boolean> {
|
|||
}
|
||||
|
||||
async function startContainer(name: string): Promise<void> {
|
||||
const { status } = await dockerApi("POST", `/containers/${name}/start`);
|
||||
const { status, body } = await dockerApi("POST", `/containers/${name}/start`);
|
||||
// 204 = started, 304 = already running
|
||||
if (status !== 204 && status !== 304) {
|
||||
throw new Error(`Failed to start ${name}: HTTP ${status}`);
|
||||
const detail = typeof body === "object" ? JSON.stringify(body) : body;
|
||||
throw new Error(`Failed to start ${name}: HTTP ${status} — ${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ declare module 'hono' {
|
|||
effectiveSpace: string;
|
||||
spaceRole: string;
|
||||
isOwner: boolean;
|
||||
isSubdomain: boolean;
|
||||
x402Payment: string;
|
||||
x402Scheme: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue