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:
|
networks:
|
||||||
- rspace-internal
|
- rspace-internal
|
||||||
|
|
||||||
# ── Scribus noVNC (rDesign DTP workspace) ──
|
# ── Scribus noVNC (rDesign DTP workspace) — on-demand sidecar ──
|
||||||
scribus-novnc:
|
scribus-novnc:
|
||||||
build:
|
build:
|
||||||
context: ./docker/scribus-novnc
|
context: ./docker/scribus-novnc
|
||||||
container_name: scribus-novnc
|
container_name: scribus-novnc
|
||||||
restart: unless-stopped
|
restart: "no"
|
||||||
|
profiles: ["sidecar"]
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
cpus: 1
|
cpus: 1
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -342,22 +343,15 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
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:
|
networks:
|
||||||
- traefik-public
|
|
||||||
- rspace-internal
|
- rspace-internal
|
||||||
|
|
||||||
# ── Open Notebook (NotebookLM-like RAG service) ──
|
# ── Open Notebook (NotebookLM-like RAG service) — on-demand sidecar ──
|
||||||
open-notebook:
|
open-notebook:
|
||||||
image: ghcr.io/lfnovo/open-notebook:v1-latest-single
|
image: ghcr.io/lfnovo/open-notebook:v1-latest-single
|
||||||
container_name: open-notebook
|
container_name: open-notebook
|
||||||
restart: always
|
restart: "no"
|
||||||
|
profiles: ["sidecar"]
|
||||||
mem_limit: 1g
|
mem_limit: 1g
|
||||||
cpus: 1
|
cpus: 1
|
||||||
env_file: ./open-notebook.env
|
env_file: ./open-notebook.env
|
||||||
|
|
@ -365,21 +359,8 @@ services:
|
||||||
- open-notebook-data:/app/data
|
- open-notebook-data:/app/data
|
||||||
- open-notebook-db:/mydata
|
- open-notebook-db:/mydata
|
||||||
networks:
|
networks:
|
||||||
- traefik-public
|
- rspace-internal
|
||||||
- ai-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:
|
volumes:
|
||||||
rspace-data:
|
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 ──
|
// ── Design Agent Tool ──
|
||||||
registry.push({
|
registry.push({
|
||||||
declaration: {
|
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,22 +468,41 @@ export class FolkVideoGen extends FolkShape {
|
||||||
? { image: this.#sourceImage, prompt, duration }
|
? { image: this.#sourceImage, prompt, duration }
|
||||||
: { prompt, duration };
|
: { prompt, duration };
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
const submitRes = await fetch(endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!submitRes.ok) {
|
||||||
throw new Error(`Generation failed: ${response.statusText}`);
|
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();
|
||||||
|
|
||||||
|
// Poll for job completion (up to 5 minutes)
|
||||||
|
const jobId = submitData.job_id;
|
||||||
|
if (!jobId) throw new Error("No job ID returned");
|
||||||
|
|
||||||
|
const deadline = Date.now() + 300_000;
|
||||||
|
let elapsed = 0;
|
||||||
|
|
||||||
|
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 = {
|
const video: GeneratedVideo = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
prompt,
|
prompt,
|
||||||
url: result.url || result.video_url,
|
url: pollData.url || pollData.video_url,
|
||||||
sourceImage: this.#mode === "i2v" ? this.#sourceImage || undefined : undefined,
|
sourceImage: this.#mode === "i2v" ? this.#sourceImage || undefined : undefined,
|
||||||
duration,
|
duration,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
|
|
@ -492,9 +511,16 @@ export class FolkVideoGen extends FolkShape {
|
||||||
this.#videos.unshift(video);
|
this.#videos.unshift(video);
|
||||||
this.#renderVideos();
|
this.#renderVideos();
|
||||||
this.dispatchEvent(new CustomEvent("video-generated", { detail: { video } }));
|
this.dispatchEvent(new CustomEvent("video-generated", { detail: { video } }));
|
||||||
|
|
||||||
// Clear input
|
|
||||||
if (this.#promptInput) this.#promptInput.value = "";
|
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) {
|
} catch (error) {
|
||||||
this.#error = error instanceof Error ? error.message : "Generation failed";
|
this.#error = error instanceof Error ? error.message : "Generation failed";
|
||||||
this.#renderError();
|
this.#renderError();
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export * from "./folk-drawfast";
|
||||||
export * from "./folk-freecad";
|
export * from "./folk-freecad";
|
||||||
export * from "./folk-kicad";
|
export * from "./folk-kicad";
|
||||||
export * from "./folk-design-agent";
|
export * from "./folk-design-agent";
|
||||||
|
export * from "./folk-ascii-gen";
|
||||||
|
|
||||||
// Advanced Shapes
|
// Advanced Shapes
|
||||||
export * from "./folk-video-chat";
|
export * from "./folk-video-chat";
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ const SHAPE_ICONS: Record<string, { icon: string; label: string }> = {
|
||||||
"folk-freecad": { icon: "🔧", label: "CAD" },
|
"folk-freecad": { icon: "🔧", label: "CAD" },
|
||||||
"folk-kicad": { icon: "🔌", label: "PCB" },
|
"folk-kicad": { icon: "🔌", label: "PCB" },
|
||||||
"folk-design-agent": { icon: "🖨️", label: "Design" },
|
"folk-design-agent": { icon: "🖨️", label: "Design" },
|
||||||
|
"folk-ascii-gen": { icon: "▦", label: "ASCII Art" },
|
||||||
// Social
|
// Social
|
||||||
"folk-social-post": { icon: "📣", label: "Social Post" },
|
"folk-social-post": { icon: "📣", label: "Social Post" },
|
||||||
"folk-social-thread": { icon: "🧵", label: "Thread" },
|
"folk-social-thread": { icon: "🧵", label: "Thread" },
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { renderShell } from "../../server/shell";
|
||||||
import { getModuleInfoList } from "../../shared/module";
|
import { getModuleInfoList } from "../../shared/module";
|
||||||
import type { RSpaceModule } from "../../shared/module";
|
import type { RSpaceModule } from "../../shared/module";
|
||||||
import { designAgentRoutes } from "./design-agent-route";
|
import { designAgentRoutes } from "./design-agent-route";
|
||||||
|
import { ensureSidecar } from "../../server/sidecar-manager";
|
||||||
|
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ routes.get("/api/health", (c) => {
|
||||||
|
|
||||||
// Proxy bridge API calls from rspace to the Scribus container
|
// Proxy bridge API calls from rspace to the Scribus container
|
||||||
routes.all("/api/bridge/*", async (c) => {
|
routes.all("/api/bridge/*", async (c) => {
|
||||||
|
await ensureSidecar("scribus-novnc");
|
||||||
const path = c.req.path.replace(/^.*\/api\/bridge/, "/api/scribus");
|
const path = c.req.path.replace(/^.*\/api\/bridge/, "/api/scribus");
|
||||||
const bridgeSecret = process.env.SCRIBUS_BRIDGE_SECRET || "";
|
const bridgeSecret = process.env.SCRIBUS_BRIDGE_SECRET || "";
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
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);
|
}, 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;
|
let splatMailTransport: ReturnType<typeof createTransport> | null = null;
|
||||||
if (process.env.SMTP_PASS) {
|
if (process.env.SMTP_PASS) {
|
||||||
splatMailTransport = createTransport({
|
splatMailTransport = createTransport({
|
||||||
|
|
@ -1496,48 +1614,67 @@ app.post("/api/image-gen/img2img", async (c) => {
|
||||||
return c.json({ error: `Unknown provider: ${provider}` }, 400);
|
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) => {
|
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();
|
const { prompt } = await c.req.json();
|
||||||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||||||
|
|
||||||
const { generateVideoViaFal } = await import("./mi-media");
|
const jobId = crypto.randomUUID();
|
||||||
const result = await generateVideoViaFal(prompt);
|
const job: VideoGenJob = {
|
||||||
if (!result.ok) return c.json({ error: result.error }, 502);
|
id: jobId, status: "pending", type: "t2v",
|
||||||
return c.json({ url: result.url, video_url: result.url });
|
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) => {
|
app.post("/api/video-gen/i2v", async (c) => {
|
||||||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
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);
|
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", {
|
// Stage the source image if it's a data URL
|
||||||
method: "POST",
|
let imageUrl = image;
|
||||||
headers: {
|
if (image.startsWith("data:")) {
|
||||||
Authorization: `Key ${FAL_KEY}`,
|
const url = await saveDataUrlToDisk(image, "vid-src");
|
||||||
"Content-Type": "application/json",
|
imageUrl = publicUrl(c, url);
|
||||||
},
|
} else if (image.startsWith("/")) {
|
||||||
body: JSON.stringify({
|
imageUrl = publicUrl(c, image);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const jobId = crypto.randomUUID();
|
||||||
const videoUrl = data.video?.url || data.output?.url;
|
const job: VideoGenJob = {
|
||||||
if (!videoUrl) return c.json({ error: "No video returned" }, 502);
|
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)
|
// 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)
|
// KiCAD PCB design — MCP StreamableHTTP bridge (sidecar container)
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.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 };
|
return { ok: true, url: videoUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text-to-video via WAN 2.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/v2.1", {
|
const res = await fetch("https://fal.run/fal-ai/wan-t2v", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Key ${FAL_KEY}`,
|
Authorization: `Key ${FAL_KEY}`,
|
||||||
|
|
@ -173,7 +173,7 @@ export async function generateVideoViaFal(prompt: string, source_image?: string)
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt,
|
prompt,
|
||||||
num_frames: 49,
|
num_frames: 81,
|
||||||
resolution: "480p",
|
resolution: "480p",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,18 @@ const SIDECARS: Record<string, SidecarConfig> = {
|
||||||
port: 11434,
|
port: 11434,
|
||||||
healthTimeout: 30_000,
|
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>();
|
const lastUsed = new Map<string, number>();
|
||||||
|
|
@ -61,14 +73,17 @@ try {
|
||||||
|
|
||||||
// ── Docker Engine API over Unix socket ──
|
// ── 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) => {
|
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(
|
const req = http.request(
|
||||||
{
|
{
|
||||||
socketPath: DOCKER_SOCKET,
|
socketPath: DOCKER_SOCKET,
|
||||||
path: `/v1.43${path}`,
|
path: `/v1.43${path}`,
|
||||||
method,
|
method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
},
|
},
|
||||||
(res) => {
|
(res) => {
|
||||||
let data = "";
|
let data = "";
|
||||||
|
|
@ -100,10 +115,11 @@ async function isContainerRunning(name: string): Promise<boolean> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startContainer(name: string): Promise<void> {
|
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
|
// 204 = started, 304 = already running
|
||||||
if (status !== 204 && status !== 304) {
|
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;
|
effectiveSpace: string;
|
||||||
spaceRole: string;
|
spaceRole: string;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
|
isSubdomain: boolean;
|
||||||
x402Payment: string;
|
x402Payment: string;
|
||||||
x402Scheme: string;
|
x402Scheme: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue