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:
Jeff Emmett 2026-04-03 14:25:48 -07:00
parent 801de38b4a
commit 46c326278a
11 changed files with 765 additions and 78 deletions

View File

@ -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:

View File

@ -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: {

433
lib/folk-ascii-gen.ts Normal file
View File

@ -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">&times;</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">&times;</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";
}
}

View File

@ -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();

View File

@ -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";

View File

@ -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" },

View File

@ -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" };

View File

@ -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";

View File

@ -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",
}),
});

View File

@ -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}`);
}
}

1
types/hono.d.ts vendored
View File

@ -5,6 +5,7 @@ declare module 'hono' {
effectiveSpace: string;
spaceRole: string;
isOwner: boolean;
isSubdomain: boolean;
x402Payment: string;
x402Scheme: string;
}