diff --git a/lib/folk-blender.ts b/lib/folk-blender.ts index ed3b03f..b62927b 100644 --- a/lib/folk-blender.ts +++ b/lib/folk-blender.ts @@ -377,6 +377,8 @@ export class FolkBlender extends FolkShape { if (!h.available && this.#generateBtn) { this.#generateBtn.disabled = true; this.#generateBtn.title = (h.issues || []).join(", ") || "Blender service unavailable"; + } else if (h.warnings?.length && this.#generateBtn) { + this.#generateBtn.title = h.warnings.join(", "); } }).catch(() => { if (this.#generateBtn) { diff --git a/server/index.ts b/server/index.ts index c618af0..fd3aba1 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1569,7 +1569,7 @@ const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || ""; app.get("/api/blender-gen/health", async (c) => { const issues: string[] = []; - if (!RUNPOD_API_KEY) issues.push("RUNPOD_API_KEY not configured"); + const warnings: string[] = []; const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; try { const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) }); @@ -1577,12 +1577,11 @@ app.get("/api/blender-gen/health", async (c) => { } catch { issues.push("Ollama unreachable"); } - return c.json({ available: issues.length === 0, issues }); + if (!RUNPOD_API_KEY) warnings.push("RunPod not configured — script-only mode"); + return c.json({ available: issues.length === 0, issues, warnings }); }); app.post("/api/blender-gen", async (c) => { - if (!RUNPOD_API_KEY) return c.json({ error: "RUNPOD_API_KEY not configured" }, 503); - const { prompt } = await c.req.json(); if (!prompt) return c.json({ error: "prompt required" }, 400); @@ -1595,7 +1594,7 @@ app.post("/api/blender-gen", async (c) => { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - model: process.env.OLLAMA_MODEL || "llama3.1", + model: process.env.OLLAMA_MODEL || "qwen2.5:14b", prompt: `Generate a Blender Python script that creates: ${prompt}\n\nThe script should:\n- Import bpy\n- Clear the default scene\n- Create the described objects with materials\n- Set up basic lighting and camera\n- Render to /tmp/render.png at 1024x1024\n\nOnly output the Python code, no explanations.`, stream: false, }), @@ -1605,18 +1604,22 @@ app.post("/api/blender-gen", async (c) => { const llmData = await llmRes.json(); script = llmData.response || ""; // Extract code block if wrapped in markdown - const codeMatch = script.match(/```python\n([\s\S]*?)```/); - if (codeMatch) script = codeMatch[1]; + const codeMatch = script.match(/```(?:python)?\n([\s\S]*?)```/); + if (codeMatch) script = codeMatch[1].trim(); } } catch (e) { console.error("[blender-gen] LLM error:", e); } if (!script) { - return c.json({ error: "Failed to generate Blender script" }, 502); + return c.json({ error: "Failed to generate Blender script — is Ollama running?" }, 502); + } + + // Step 2: Execute on RunPod (headless Blender) — optional + if (!RUNPOD_API_KEY) { + return c.json({ script, render_url: null, blend_url: null }); } - // Step 2: Execute on RunPod (headless Blender) try { const runpodRes = await fetch("https://api.runpod.ai/v2/blender/runsync", { method: "POST", @@ -1633,7 +1636,6 @@ app.post("/api/blender-gen", async (c) => { }); if (!runpodRes.ok) { - // Return just the script if RunPod fails return c.json({ script, error_detail: "RunPod execution failed" }); } @@ -1644,7 +1646,6 @@ app.post("/api/blender-gen", async (c) => { blend_url: runpodData.output?.blend_url || null, }); } catch (e) { - // Return the script even if RunPod is unavailable return c.json({ script, error_detail: "RunPod unavailable" }); } }); diff --git a/server/spaces.ts b/server/spaces.ts index 759f245..583abee 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -631,6 +631,7 @@ spaces.get("/:slug", async (c) => { slug: data.meta.slug, name: data.meta.name, visibility: data.meta.visibility, + description: data.meta.description || "", createdAt: data.meta.createdAt, ownerDID: data.meta.ownerDID, memberCount: Object.keys(data.members || {}).length, diff --git a/shared/components/rstack-space-settings.ts b/shared/components/rstack-space-settings.ts index 2bc9a2f..f54dd3d 100644 --- a/shared/components/rstack-space-settings.ts +++ b/shared/components/rstack-space-settings.ts @@ -60,6 +60,11 @@ export class RStackSpaceSettings extends HTMLElement { private _moduleSettingsValues: Record = {}; private _moduleSaveError = ""; private _moduleSaving = false; + private _visibility: "public" | "permissioned" | "private" = "public"; + private _description = ""; + private _spaceName = ""; + private _spaceSaving = false; + private _spaceSaveMsg = ""; static define() { if (!customElements.get("rstack-space-settings")) { @@ -210,6 +215,21 @@ export class RStackSpaceSettings extends HTMLElement { this._myRole = this._isOwner ? "admin" : (myMember?.role || "viewer"); } + // Load space metadata (visibility, description) + if (this._isAdmin && token) { + try { + const res = await fetch(`/api/spaces/${encodeURIComponent(this._space)}`, { + headers: { "Authorization": `Bearer ${token}` }, + }); + if (res.ok) { + const info = await res.json(); + this._visibility = info.visibility || "public"; + this._description = info.description || ""; + this._spaceName = info.name || this._space; + } + } catch {} + } + // Load invites (admin only) if (this._isAdmin && token) { try { @@ -408,6 +428,22 @@ export class RStackSpaceSettings extends HTMLElement {
${moduleSettingsHTML} + ${this._isAdmin ? ` +
+

Space Settings

+ + + + + + ${this._spaceSaveMsg ? `${this._esc(this._spaceSaveMsg)}` : ""} +
+ ` : ""} +

Members ${this._members.length}

${membersHTML}
@@ -560,6 +596,46 @@ export class RStackSpaceSettings extends HTMLElement { }); sr.getElementById("mod-cfg-save")?.addEventListener("click", () => this._saveModuleSettings()); + + // Space settings save + sr.getElementById("space-save")?.addEventListener("click", () => this._saveSpaceSettings()); + } + + private async _saveSpaceSettings() { + const sr = this.shadowRoot!; + const vis = (sr.getElementById("space-visibility") as HTMLSelectElement)?.value; + const desc = (sr.getElementById("space-description") as HTMLTextAreaElement)?.value; + const token = getToken(); + if (!token) return; + + this._spaceSaving = true; + this._spaceSaveMsg = ""; + this._render(); + + try { + const res = await fetch(`/api/spaces/${encodeURIComponent(this._space)}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + }, + body: JSON.stringify({ visibility: vis, description: desc }), + }); + if (res.ok) { + const data = await res.json(); + this._visibility = data.visibility || vis; + this._description = data.description ?? desc; + this._spaceSaveMsg = "Saved"; + } else { + const err = await res.json().catch(() => ({})) as { error?: string }; + this._spaceSaveMsg = (err as any).error || "Failed to save"; + } + } catch { + this._spaceSaveMsg = "Network error"; + } + this._spaceSaving = false; + this._render(); + setTimeout(() => { this._spaceSaveMsg = ""; this._render(); }, 3000); } private async _lookupUser(username: string) { @@ -952,6 +1028,19 @@ const PANEL_CSS = ` .input:focus { outline: none; border-color: #14b8a6; } .input::placeholder { color: var(--rs-text-muted); } +.field-label { + display: block; + font-size: 0.75rem; + color: var(--rs-text-secondary); + margin-bottom: 4px; +} + +.space-save-msg { + font-size: 0.78rem; + color: #14b8a6; + margin-left: 8px; +} + .add-row { display: flex; gap: 8px; diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 273ad3c..135d37b 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -1067,12 +1067,28 @@ export async function getSpaceInviteByToken(token: string): Promise { - const rows = await sql` - SELECT * FROM space_invites - WHERE space_slug = ${spaceSlug} - ORDER BY created_at DESC - `; - return Promise.all(rows.map(rowToInvite)); + const [spaceRows, identityRows] = await Promise.all([ + sql`SELECT * FROM space_invites WHERE space_slug = ${spaceSlug} ORDER BY created_at DESC`, + sql`SELECT * FROM identity_invites WHERE space_slug = ${spaceSlug} ORDER BY created_at DESC`, + ]); + const spaceInvites = await Promise.all(spaceRows.map(rowToInvite)); + const identityInvites = await Promise.all(identityRows.map(async (row: any) => { + const emailDecrypted = await decryptField(row.email_enc); + return { + id: row.id, + spaceSlug: row.space_slug, + email: emailDecrypted ?? row.email ?? null, + role: row.space_role || 'member', + token: row.token, + invitedBy: row.invited_by_user_id, + status: row.status === 'claimed' ? 'accepted' : row.status, + createdAt: new Date(row.created_at).getTime(), + expiresAt: new Date(row.expires_at).getTime(), + acceptedAt: row.claimed_at ? new Date(row.claimed_at).getTime() : null, + acceptedByDid: row.claimed_by_user_id || null, + } as StoredSpaceInvite; + })); + return [...spaceInvites, ...identityInvites].sort((a, b) => b.createdAt - a.createdAt); } export async function acceptSpaceInvite(token: string, acceptedByDid: string): Promise { @@ -1091,7 +1107,13 @@ export async function revokeSpaceInvite(id: string, spaceSlug: string): Promise< UPDATE space_invites SET status = 'revoked' WHERE id = ${id} AND space_slug = ${spaceSlug} AND status = 'pending' `; - return result.count > 0; + if (result.count > 0) return true; + // Also check identity_invites (email invites with space_slug) + const result2 = await sql` + UPDATE identity_invites SET status = 'revoked' + WHERE id = ${id} AND space_slug = ${spaceSlug} AND status = 'pending' + `; + return result2.count > 0; } // ============================================================================