Merge branch 'dev'
This commit is contained in:
commit
8989523646
|
|
@ -377,6 +377,8 @@ export class FolkBlender extends FolkShape {
|
||||||
if (!h.available && this.#generateBtn) {
|
if (!h.available && this.#generateBtn) {
|
||||||
this.#generateBtn.disabled = true;
|
this.#generateBtn.disabled = true;
|
||||||
this.#generateBtn.title = (h.issues || []).join(", ") || "Blender service unavailable";
|
this.#generateBtn.title = (h.issues || []).join(", ") || "Blender service unavailable";
|
||||||
|
} else if (h.warnings?.length && this.#generateBtn) {
|
||||||
|
this.#generateBtn.title = h.warnings.join(", ");
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (this.#generateBtn) {
|
if (this.#generateBtn) {
|
||||||
|
|
|
||||||
|
|
@ -1569,7 +1569,7 @@ const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || "";
|
||||||
|
|
||||||
app.get("/api/blender-gen/health", async (c) => {
|
app.get("/api/blender-gen/health", async (c) => {
|
||||||
const issues: string[] = [];
|
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";
|
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) });
|
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 {
|
} catch {
|
||||||
issues.push("Ollama unreachable");
|
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) => {
|
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();
|
const { prompt } = await c.req.json();
|
||||||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||||||
|
|
||||||
|
|
@ -1595,7 +1594,7 @@ app.post("/api/blender-gen", async (c) => {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
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.`,
|
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,
|
stream: false,
|
||||||
}),
|
}),
|
||||||
|
|
@ -1605,18 +1604,22 @@ app.post("/api/blender-gen", async (c) => {
|
||||||
const llmData = await llmRes.json();
|
const llmData = await llmRes.json();
|
||||||
script = llmData.response || "";
|
script = llmData.response || "";
|
||||||
// Extract code block if wrapped in markdown
|
// Extract code block if wrapped in markdown
|
||||||
const codeMatch = script.match(/```python\n([\s\S]*?)```/);
|
const codeMatch = script.match(/```(?:python)?\n([\s\S]*?)```/);
|
||||||
if (codeMatch) script = codeMatch[1];
|
if (codeMatch) script = codeMatch[1].trim();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[blender-gen] LLM error:", e);
|
console.error("[blender-gen] LLM error:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!script) {
|
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 {
|
try {
|
||||||
const runpodRes = await fetch("https://api.runpod.ai/v2/blender/runsync", {
|
const runpodRes = await fetch("https://api.runpod.ai/v2/blender/runsync", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -1633,7 +1636,6 @@ app.post("/api/blender-gen", async (c) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!runpodRes.ok) {
|
if (!runpodRes.ok) {
|
||||||
// Return just the script if RunPod fails
|
|
||||||
return c.json({ script, error_detail: "RunPod execution failed" });
|
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,
|
blend_url: runpodData.output?.blend_url || null,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Return the script even if RunPod is unavailable
|
|
||||||
return c.json({ script, error_detail: "RunPod unavailable" });
|
return c.json({ script, error_detail: "RunPod unavailable" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -631,6 +631,7 @@ spaces.get("/:slug", async (c) => {
|
||||||
slug: data.meta.slug,
|
slug: data.meta.slug,
|
||||||
name: data.meta.name,
|
name: data.meta.name,
|
||||||
visibility: data.meta.visibility,
|
visibility: data.meta.visibility,
|
||||||
|
description: data.meta.description || "",
|
||||||
createdAt: data.meta.createdAt,
|
createdAt: data.meta.createdAt,
|
||||||
ownerDID: data.meta.ownerDID,
|
ownerDID: data.meta.ownerDID,
|
||||||
memberCount: Object.keys(data.members || {}).length,
|
memberCount: Object.keys(data.members || {}).length,
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,11 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
private _moduleSettingsValues: Record<string, string | boolean> = {};
|
private _moduleSettingsValues: Record<string, string | boolean> = {};
|
||||||
private _moduleSaveError = "";
|
private _moduleSaveError = "";
|
||||||
private _moduleSaving = false;
|
private _moduleSaving = false;
|
||||||
|
private _visibility: "public" | "permissioned" | "private" = "public";
|
||||||
|
private _description = "";
|
||||||
|
private _spaceName = "";
|
||||||
|
private _spaceSaving = false;
|
||||||
|
private _spaceSaveMsg = "";
|
||||||
|
|
||||||
static define() {
|
static define() {
|
||||||
if (!customElements.get("rstack-space-settings")) {
|
if (!customElements.get("rstack-space-settings")) {
|
||||||
|
|
@ -210,6 +215,21 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
this._myRole = this._isOwner ? "admin" : (myMember?.role || "viewer");
|
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)
|
// Load invites (admin only)
|
||||||
if (this._isAdmin && token) {
|
if (this._isAdmin && token) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -408,6 +428,22 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
<div class="panel-content">
|
<div class="panel-content">
|
||||||
${moduleSettingsHTML}
|
${moduleSettingsHTML}
|
||||||
|
|
||||||
|
${this._isAdmin ? `
|
||||||
|
<section class="section">
|
||||||
|
<h3>Space Settings</h3>
|
||||||
|
<label class="field-label">Visibility</label>
|
||||||
|
<select class="input" id="space-visibility">
|
||||||
|
<option value="public" ${this._visibility === "public" ? "selected" : ""}>Public — anyone can read</option>
|
||||||
|
<option value="permissioned" ${this._visibility === "permissioned" ? "selected" : ""}>Permissioned — sign in to access</option>
|
||||||
|
<option value="private" ${this._visibility === "private" ? "selected" : ""}>Private — invite only</option>
|
||||||
|
</select>
|
||||||
|
<label class="field-label" style="margin-top:8px">Description</label>
|
||||||
|
<textarea class="input" id="space-description" rows="2" placeholder="Optional description…" style="resize:vertical">${this._esc(this._description)}</textarea>
|
||||||
|
<button class="add-btn" id="space-save" style="margin-top:8px" ${this._spaceSaving ? "disabled" : ""}>${this._spaceSaving ? "Saving…" : "Save"}</button>
|
||||||
|
${this._spaceSaveMsg ? `<span class="space-save-msg">${this._esc(this._spaceSaveMsg)}</span>` : ""}
|
||||||
|
</section>
|
||||||
|
` : ""}
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<h3>Members <span class="count">${this._members.length}</span></h3>
|
<h3>Members <span class="count">${this._members.length}</span></h3>
|
||||||
<div class="members-list">${membersHTML}</div>
|
<div class="members-list">${membersHTML}</div>
|
||||||
|
|
@ -560,6 +596,46 @@ export class RStackSpaceSettings extends HTMLElement {
|
||||||
});
|
});
|
||||||
|
|
||||||
sr.getElementById("mod-cfg-save")?.addEventListener("click", () => this._saveModuleSettings());
|
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) {
|
private async _lookupUser(username: string) {
|
||||||
|
|
@ -952,6 +1028,19 @@ const PANEL_CSS = `
|
||||||
.input:focus { outline: none; border-color: #14b8a6; }
|
.input:focus { outline: none; border-color: #14b8a6; }
|
||||||
.input::placeholder { color: var(--rs-text-muted); }
|
.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 {
|
.add-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
|
||||||
|
|
@ -1067,12 +1067,28 @@ export async function getSpaceInviteByToken(token: string): Promise<StoredSpaceI
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSpaceInvites(spaceSlug: string): Promise<StoredSpaceInvite[]> {
|
export async function listSpaceInvites(spaceSlug: string): Promise<StoredSpaceInvite[]> {
|
||||||
const rows = await sql`
|
const [spaceRows, identityRows] = await Promise.all([
|
||||||
SELECT * FROM space_invites
|
sql`SELECT * FROM space_invites WHERE space_slug = ${spaceSlug} ORDER BY created_at DESC`,
|
||||||
WHERE space_slug = ${spaceSlug}
|
sql`SELECT * FROM identity_invites WHERE space_slug = ${spaceSlug} ORDER BY created_at DESC`,
|
||||||
ORDER BY created_at DESC
|
]);
|
||||||
`;
|
const spaceInvites = await Promise.all(spaceRows.map(rowToInvite));
|
||||||
return Promise.all(rows.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<StoredSpaceInvite | null> {
|
export async function acceptSpaceInvite(token: string, acceptedByDid: string): Promise<StoredSpaceInvite | null> {
|
||||||
|
|
@ -1091,7 +1107,13 @@ export async function revokeSpaceInvite(id: string, spaceSlug: string): Promise<
|
||||||
UPDATE space_invites SET status = 'revoked'
|
UPDATE space_invites SET status = 'revoked'
|
||||||
WHERE id = ${id} AND space_slug = ${spaceSlug} AND status = 'pending'
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue