diff --git a/docker-compose.yml b/docker-compose.yml
index 4121c44..75a5d9e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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:
diff --git a/lib/canvas-tools.ts b/lib/canvas-tools.ts
index 45c4261..4efd152 100644
--- a/lib/canvas-tools.ts
+++ b/lib/canvas-tools.ts
@@ -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: {
diff --git a/lib/folk-ascii-gen.ts b/lib/folk-ascii-gen.ts
new file mode 100644
index 0000000..6e69051
--- /dev/null
+++ b/lib/folk-ascii-gen.ts
@@ -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`
+
+
+
+
+
+ ▦
+ Pick a pattern and click Generate
+
+
+
+
+
+
+
+ `;
+
+ 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 = ``;
+ if (this.#actionsBar) this.#actionsBar.style.display = "none";
+ }
+
+ #renderError() {
+ if (!this.#previewArea) return;
+ this.#previewArea.innerHTML = `${this.#error}
`;
+ if (this.#actionsBar) this.#actionsBar.style.display = "none";
+ }
+
+ #renderResult() {
+ if (!this.#previewArea) return;
+ if (this.#htmlOutput) {
+ this.#previewArea.innerHTML = `${this.#htmlOutput}
`;
+ } 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 = `No output received
`;
+ }
+ if (this.#actionsBar) this.#actionsBar.style.display = "flex";
+ }
+}
diff --git a/lib/folk-video-gen.ts b/lib/folk-video-gen.ts
index 207988b..8a62021 100644
--- a/lib/folk-video-gen.ts
+++ b/lib/folk-video-gen.ts
@@ -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();
diff --git a/lib/index.ts b/lib/index.ts
index 3d6f9d7..d293d0e 100644
--- a/lib/index.ts
+++ b/lib/index.ts
@@ -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";
diff --git a/lib/mi-triage-panel.ts b/lib/mi-triage-panel.ts
index 35960a1..2f5ec91 100644
--- a/lib/mi-triage-panel.ts
+++ b/lib/mi-triage-panel.ts
@@ -39,6 +39,7 @@ const SHAPE_ICONS: Record = {
"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" },
diff --git a/modules/rdesign/mod.ts b/modules/rdesign/mod.ts
index 93020c6..c2f8e81 100644
--- a/modules/rdesign/mod.ts
+++ b/modules/rdesign/mod.ts
@@ -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 = { "Content-Type": "application/json" };
diff --git a/server/index.ts b/server/index.ts
index 9ae3b22..9b91c3e 100644
--- a/server/index.ts
+++ b/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();
+
+// 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 | 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 = {
+ 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";
diff --git a/server/mi-media.ts b/server/mi-media.ts
index 3b2c696..3d3e237 100644
--- a/server/mi-media.ts
+++ b/server/mi-media.ts
@@ -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",
}),
});
diff --git a/server/sidecar-manager.ts b/server/sidecar-manager.ts
index ca0da6e..3b47fa2 100644
--- a/server/sidecar-manager.ts
+++ b/server/sidecar-manager.ts
@@ -45,6 +45,18 @@ const SIDECARS: Record = {
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();
@@ -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 = {};
+ // 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 {
}
async function startContainer(name: string): Promise {
- 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}`);
}
}
diff --git a/types/hono.d.ts b/types/hono.d.ts
index fa5b728..f1c9623 100644
--- a/types/hono.d.ts
+++ b/types/hono.d.ts
@@ -5,6 +5,7 @@ declare module 'hono' {
effectiveSpace: string;
spaceRole: string;
isOwner: boolean;
+ isSubdomain: boolean;
x402Payment: string;
x402Scheme: string;
}