1import log from "../../seed/log.js";
2import fs from "fs";
3import { getUserMeta, setUserMeta } from "../../seed/tree/userMetadata.js";
4
5// Services wired from init() via setServices()
6let User = null;
7let Node = null;
8let assignConnection = async () => {};
9
10export function setServices({ models, llmConnections }) {
11 User = models.User;
12 Node = models.Node;
13 if (llmConnections) assignConnection = llmConnections;
14}
15
16const TEXT_NOTE_CHARS_PER_ENERGY = 1000;
17const TEXT_NOTE_MIN = 1;
18const TEXT_NOTE_MAX = 5;
19
20const FILE_MIN_COST = 5;
21const FILE_BASE_RATE = 1.5;
22const FILE_MID_RATE = 3;
23
24const SOFT_LIMIT_MB = 100;
25const HARD_LIMIT_MB = 1024;
26
27export const DAILY_LIMITS = {
28 basic: 350,
29 standard: 1500,
30 premium: 8000,
31 god: 10_000_000_000,
32};
33
34export function calculateFileEnergy(sizeMB) {
35 if (sizeMB <= SOFT_LIMIT_MB) {
36 return Math.max(FILE_MIN_COST, Math.ceil(sizeMB * FILE_BASE_RATE));
37 }
38
39 if (sizeMB <= HARD_LIMIT_MB) {
40 const base = SOFT_LIMIT_MB * FILE_BASE_RATE;
41 const extra = (sizeMB - SOFT_LIMIT_MB) * FILE_MID_RATE;
42 return Math.ceil(base + extra);
43 }
44
45 const base =
46 SOFT_LIMIT_MB * FILE_BASE_RATE +
47 (HARD_LIMIT_MB - SOFT_LIMIT_MB) * FILE_MID_RATE;
48
49 const overGB = sizeMB - HARD_LIMIT_MB;
50
51 return Math.ceil(base + Math.pow(overGB / 50, 2) * 50);
52}
53
54const BASE_ACTION_COSTS = {
55 editStatus: 1,
56 editValue: 1,
57 removeNote: 1,
58 editSchedule: 1,
59 editGoal: 1,
60 editName: 1,
61 editType: 1,
62 updateParent: 1,
63 updateChild: 1,
64 branchLifecycle: 1,
65 invite: 1,
66 delete: 1,
67
68 prestige: 2,
69 executeScript: 2,
70 transaction: 2,
71
72 chatError: 2,
73 proxyLlm: 2,
74
75 create: 3,
76};
77
78const CONTENT_ACTIONS = new Set(["note", "rawIdea", "editScript"]);
79
80const VARIABLE_ACTIONS = new Set(["understanding"]);
81
82const customActions = new Map();
83
84export function registerAction(action, costFn) {
85 if (typeof costFn !== "function") {
86 throw new Error(`registerAction: costFn must be a function for "${action}"`);
87 }
88 customActions.set(action, costFn);
89}
90
91export function calculateEnergyCost(action, payload) {
92 if (payload?.type === "file") {
93 const sizeMB = payload.sizeMB;
94
95 if (typeof sizeMB !== "number" || isNaN(sizeMB) || sizeMB < 0) {
96 throw new Error("Invalid file size");
97 }
98
99 return calculateFileEnergy(sizeMB);
100 }
101
102 if (CONTENT_ACTIONS.has(action)) {
103 let length = 0;
104
105 if (typeof payload === "string") {
106 length = payload.length;
107 } else if (typeof payload === "number") {
108 length = payload;
109 } else if (payload?.content) {
110 length = payload.content.length;
111 } else if (payload?.type === "text") {
112 length = (payload.content || "").length;
113 }
114
115 return Math.min(
116 TEXT_NOTE_MAX,
117 Math.max(
118 TEXT_NOTE_MIN,
119 1 + Math.floor(length / TEXT_NOTE_CHARS_PER_ENERGY),
120 ),
121 );
122 }
123
124 if (VARIABLE_ACTIONS.has(action)) {
125 const amount = typeof payload === "number" ? payload : 1;
126 return Math.max(2, amount * 2);
127 }
128
129 if (customActions.has(action)) {
130 return customActions.get(action)(payload);
131 }
132
133 const cost = BASE_ACTION_COSTS[action];
134 if (!cost) {
135 throw new Error(`Unknown energy action: ${action}`);
136 }
137
138 return cost;
139}
140
141export function maybeResetEnergy(user) {
142 const energy = getUserMeta(user, "energy");
143 if (!energy.available) return false;
144
145 const now = Date.now();
146 const DAY_MS = 24 * 60 * 60 * 1000;
147
148 const billing = getUserMeta(user, "billing");
149 const expiresAt = billing.planExpiresAt?.getTime?.() || (typeof billing.planExpiresAt === "number" ? billing.planExpiresAt : 0);
150
151 const currentPlan = getUserMeta(user, "tiers").plan || "basic";
152 if (
153 currentPlan !== "basic" &&
154 !user.isAdmin &&
155 expiresAt > 0 &&
156 now > expiresAt
157 ) {
158 setUserMeta(user, "tiers", { plan: "basic" });
159 setUserMeta(user, "billing", { ...billing, planExpiresAt: null });
160
161 energy.available.amount = DAILY_LIMITS.basic ?? DAILY_LIMITS["basic"];
162 energy.available.lastResetAt = new Date();
163 setUserMeta(user, "energy", energy);
164 assignConnection(user._id, "main", null);
165 assignConnection(user._id, "rawIdea", null);
166 Node.updateMany(
167 { rootOwner: user._id },
168 { $set: {
169 "llmAssignments.default": null,
170 "llmAssignments.placement": null,
171 "llmAssignments.understanding": null,
172 "llmAssignments.respond": null,
173 "llmAssignments.notes": null,
174 "llmAssignments.cleanup": null,
175 "llmAssignments.drain": null,
176 "llmAssignments.notification": null,
177 } },
178 ).catch(function (e) {
179 log.error("Energy", "Failed to clear root LLM on downgrade:", e.message);
180 });
181 }
182
183 const lastReset = energy.available.lastResetAt?.getTime?.() || 0;
184
185 if (now - lastReset < DAY_MS) return false;
186
187 const resetPlan = getUserMeta(user, "tiers").plan || "basic";
188 const limit = DAILY_LIMITS[resetPlan] ?? DAILY_LIMITS.basic;
189
190 energy.available.amount = limit;
191 energy.available.lastResetAt = new Date();
192 setUserMeta(user, "energy", energy);
193
194 return true;
195}
196
197export class EnergyError extends Error {
198 constructor(message, meta = {}) {
199 super(message);
200 this.name = "EnergyError";
201 Object.assign(this, meta);
202 }
203}
204
205const MAX_FILE_MB_STANDARD = 1024; // 1 GB
206
207export async function useEnergy({
208 userId,
209 action,
210 payload = null,
211 file = null,
212}) {
213 if (!userId) {
214 throw new EnergyError("Not authenticated");
215 }
216
217 const user = await User.findById(userId);
218 if (!user) {
219 if (file?.path) {
220 await fs.promises.unlink(file.path).catch(() => {});
221 }
222 throw new EnergyError("User not found");
223 }
224
225 maybeResetEnergy(user);
226
227 if (
228 (action === "note" || action === "rawIdea") &&
229 payload?.type === "file" &&
230 (getUserMeta(user, "tiers").plan || "basic") === "basic"
231 ) {
232 if (file?.path) {
233 await fs.promises.unlink(file.path).catch(() => {});
234 }
235
236 throw new EnergyError("File uploads are not available on the Basic plan", {
237 code: "PLAN_RESTRICTION",
238 });
239 }
240
241 if (
242 payload?.type === "file" &&
243 (getUserMeta(user, "tiers").plan || "basic") === "standard" &&
244 payload.sizeMB > MAX_FILE_MB_STANDARD
245 ) {
246 if (file?.path) {
247 await fs.promises.unlink(file.path).catch(() => {});
248 }
249
250 throw new EnergyError("File exceeds 1 GB limit for Standard plan", {
251 code: "FILE_TOO_LARGE",
252 limitMB: MAX_FILE_MB_STANDARD,
253 });
254 }
255
256 const cost = calculateEnergyCost(action, payload);
257
258 const energy = getUserMeta(user, "energy");
259 const baseEnergy = energy.available.amount || 0;
260 const extraEnergy = energy.additional?.amount || 0;
261 const totalEnergy = baseEnergy + extraEnergy;
262
263 if (totalEnergy < cost) {
264 if (file?.path) {
265 await fs.promises.unlink(file.path).catch(() => {});
266 }
267
268 throw new EnergyError("Energy limit reached", {
269 code: "INSUFFICIENT_ENERGY",
270 required: cost,
271 remaining: totalEnergy,
272 });
273 }
274
275 let remainingCost = cost;
276
277 if (energy.available.amount >= remainingCost) {
278 energy.available.amount -= remainingCost;
279 remainingCost = 0;
280 } else {
281 remainingCost -= energy.available.amount;
282 energy.available.amount = 0;
283 }
284
285 if (remainingCost > 0) {
286 energy.additional.amount -= remainingCost;
287 remainingCost = 0;
288 }
289
290 // Use atomic $set to avoid clobbering other metadata namespaces
291 // that may have been modified concurrently (e.g. navigation adding roots).
292 const { batchSetUserMeta } = await import("../../seed/tree/userMetadata.js");
293 await batchSetUserMeta(userId, "energy", {
294 available: energy.available,
295 additional: energy.additional,
296 });
297
298 return {
299 energyUsed: cost,
300 remainingEnergy: energy.available.amount + energy.additional.amount,
301 remainingBaseEnergy: energy.available.amount,
302 remainingAdditionalEnergy: energy.additional.amount,
303 };
304}
305
1import { setServices, maybeResetEnergy, useEnergy, registerAction, DAILY_LIMITS } from "./core.js";
2import log from "../../seed/log.js";
3
4export async function init(core) {
5 setServices({ models: core.models });
6
7 const { default: router, setModels, resolveHtmlAuth } = await import("./routes.js");
8 setModels(core.models);
9 resolveHtmlAuth();
10 // Register lifecycle hooks for energy metering
11 core.hooks.register("beforeNote", async (data) => {
12 try { await useEnergy({ userId: data.userId, action: "note" }); } catch (err) { log.debug("Energy", "note metering failed:", err.message); }
13 }, "energy");
14
15 core.hooks.register("beforeStatusChange", async (data) => {
16 try { await useEnergy({ userId: data.userId, action: "editStatus" }); } catch (err) { log.debug("Energy", "editStatus metering failed:", err.message); }
17 }, "energy");
18
19 core.hooks.register("afterNodeCreate", async (data) => {
20 try { await useEnergy({ userId: data.userId, action: "create" }); } catch (err) { log.debug("Energy", "create metering failed:", err.message); }
21 }, "energy");
22
23 core.hooks.register("beforeNodeDelete", async (data) => {
24 try { await useEnergy({ userId: data.userId, action: "branchLifecycle" }); } catch (err) { log.debug("Energy", "branchLifecycle metering failed:", err.message); }
25 }, "energy");
26
27
28 // Replace the no-op energy service with the real one
29 core.energy = { useEnergy, maybeResetEnergy, registerAction, DAILY_LIMITS };
30
31 // Register energy display on user profile
32 try {
33 const { getExtension } = await import("../loader.js");
34 const { getUserMeta } = await import("../../seed/tree/userMetadata.js");
35 const treeos = getExtension("treeos-base");
36 treeos?.exports?.registerSlot?.("user-profile-energy", "energy", ({ userId, queryString, user }) => {
37 maybeResetEnergy(user);
38 const energyData = getUserMeta(user, "energy");
39 const amount = (energyData.available?.amount ?? 0) + (energyData.additional?.amount ?? 0);
40 const lastReset = energyData.available?.lastResetAt;
41 const nextReset = lastReset ? new Date(new Date(lastReset).getTime() + 86400000) : null;
42 const resetLabel = nextReset
43 ? nextReset.toLocaleString("en-US", { hour: "numeric", minute: "2-digit", hour12: true, timeZoneName: "short" })
44 : "...";
45 return `<span class="meta-item">
46 <a href="/api/v1/user/${userId}/energy${queryString}">\u26A1 ${amount} \u00B7 resets ${resetLabel}</a>
47 </span>`;
48 }, { priority: 10 });
49 } catch {}
50
51 return {
52 router,
53 exports: { maybeResetEnergy, useEnergy, DAILY_LIMITS },
54 };
55}
56
1export default {
2 name: "energy",
3 version: "1.0.4",
4 builtFor: "TreeOS",
5 description:
6 "Every action in TreeOS costs energy. Creating a node costs 3. Writing a note costs " +
7 "1 to 5 depending on length. Changing a status costs 1. Running an understanding " +
8 "pass, executing a script, completing a prestige version: each has a defined cost. " +
9 "Energy prevents runaway usage, establishes fair limits across tiers, and gives land " +
10 "operators a metering layer that works whether or not billing is installed. " +
11 "\n\n" +
12 "Each user has a daily energy budget that resets every 24 hours. The budget depends " +
13 "on their tier: Basic gets 350, Standard gets 1,500, Premium gets 8,000. The reset " +
14 "check runs lazily on profile load rather than on a cron schedule. If 24 hours have " +
15 "passed since the last reset, energy refills to the tier limit. If the user's paid " +
16 "plan has expired, they are automatically downgraded to Basic, their energy is reset " +
17 "to the Basic limit, and all LLM slot assignments on their trees and user profile " +
18 "are cleared. " +
19 "\n\n" +
20 "Energy deduction is atomic per action. The useEnergy function loads the user, checks " +
21 "the daily reset, validates tier restrictions (Basic users cannot upload files, " +
22 "Standard users have a 1 GB file size cap), calculates the cost, deducts from the " +
23 "daily pool first and the additional (purchased) pool second, then saves. If total " +
24 "available energy is below the cost, the action is rejected with an EnergyError. " +
25 "File uploads that fail the check have their temporary files cleaned up immediately. " +
26 "\n\n" +
27 "Cost calculation has three tiers. Fixed-cost actions (create, delete, status change, " +
28 "prestige, script execution) use a lookup table. Content actions (notes, raw ideas, " +
29 "script edits) scale with text length at 1 energy per 1,000 characters, capped " +
30 "between 1 and 5. File actions use a progressive rate: 1.5 energy per MB up to 100 " +
31 "MB, 3 energy per MB from 100 MB to 1 GB, and quadratic scaling beyond 1 GB. " +
32 "\n\n" +
33 "The extension registers four lifecycle hooks for automatic metering: beforeNote, " +
34 "beforeStatusChange, afterNodeCreate, and beforeNodeDelete. Other extensions that " +
35 "need energy metering declare energy as an optional service and call core.energy " +
36 "directly. Extensions can also register custom actions with registerAction, providing " +
37 "a cost function that receives the payload and returns the energy cost. If energy is " +
38 "not installed, core.energy is undefined and all metering is silently skipped.",
39
40 needs: {
41 services: ["hooks"],
42 models: ["User", "Node"],
43 },
44
45 optional: {
46 extensions: ["html-rendering", "treeos-base"],
47 },
48
49 provides: {
50 models: {},
51 routes: "./routes.js",
52 tools: false,
53 jobs: false,
54 orchestrator: false,
55 sessionTypes: {},
56 cli: [
57 { command: "energy", scope: ["home"], description: "Show your energy balance and reset time", method: "GET", endpoint: "/user/:userId/energy" },
58 ],
59
60 hooks: {
61 fires: [],
62 listens: ["beforeNote", "beforeStatusChange", "afterNodeCreate", "beforeNodeDelete"],
63 },
64
65 // Documented exports (available via core.energy or getExtension("energy")?.exports)
66 //
67 // core.energy.useEnergy({ userId, action }) - Deduct energy for an action. Throws EnergyError if insufficient.
68 // core.energy.maybeResetEnergy(user) - Reset daily energy if 24h have passed. Called on user profile load.
69 // core.energy.DAILY_LIMITS - { basic: 350, standard: 1500, premium: 8000, god: 10000000000 }
70 //
71 // Extensions that want energy metering declare: optional: { services: ["energy"] }
72 // Then in init(core): if (core.energy) setEnergyService(core.energy);
73 // If energy is not installed, core.energy is undefined and all checks safely skip.
74 },
75};
76
1import { page } from "../../html-rendering/html/layout.js";
2import { getExtension } from "../../loader.js";
3
4function energyResolveSlots(slotName, ctx) {
5 try {
6 return getExtension("treeos-base")?.exports?.resolveSlots?.(slotName, ctx) || "";
7 } catch { return ""; }
8}
9
10export function renderEnergy({ userId, user, energyAmount, additionalEnergy, plan, planExpiresAt, llmConnections, mainAssignment, rawIdeaAssignment, activeConn, hasLlm, connectionCount, isBasic, qs }) {
11 const css = `
12 /* =========================================================
13 ENERGY STATUS
14 ========================================================= */
15 .energy-grid {
16 display: grid;
17 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
18 gap: 14px;
19 }
20
21 .energy-stat {
22 padding: 18px 20px;
23 background: rgba(255, 255, 255, 0.1);
24 border: 1px solid rgba(255, 255, 255, 0.2);
25 border-radius: 14px;
26 text-align: center;
27 position: relative;
28 overflow: hidden;
29 }
30
31 .energy-stat::before {
32 content: "";
33 position: absolute;
34 inset: 0;
35 border-radius: inherit;
36 background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent);
37 pointer-events: none;
38 }
39
40 .energy-stat-label {
41 font-size: 12px;
42 font-weight: 600;
43 text-transform: uppercase;
44 letter-spacing: 0.5px;
45 color: rgba(255, 255, 255, 0.6);
46 margin-bottom: 6px;
47 }
48
49 .energy-stat-value {
50 font-size: 28px;
51 font-weight: 700;
52 color: white;
53 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
54 }
55
56 .energy-stat-sub {
57 font-size: 12px;
58 color: rgba(255, 255, 255, 0.5);
59 margin-top: 4px;
60 }
61
62 .energy-stat.plan-basic {
63 background: rgba(255, 255, 255, 0.12);
64 border-color: rgba(255, 255, 255, 0.2);
65 }
66
67 .energy-stat.plan-standard {
68 background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(37, 99, 235, 0.2));
69 border-color: rgba(96, 165, 250, 0.3);
70 }
71
72 .energy-stat.plan-premium {
73 background: linear-gradient(135deg, rgba(168, 85, 247, 0.2), rgba(124, 58, 237, 0.2));
74 border-color: rgba(168, 85, 247, 0.3);
75 }
76
77 .energy-stat.plan-god {
78 background: linear-gradient(135deg, rgba(250, 204, 21, 0.2), rgba(245, 158, 11, 0.2));
79 border-color: rgba(250, 204, 21, 0.3);
80 }
81
82 /* =========================================================
83 PLAN CARDS
84 ========================================================= */
85 .plan-grid {
86 display: grid;
87 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
88 gap: 14px;
89 }
90
91 .plan-box {
92 padding: 24px 20px;
93 border-radius: 14px;
94 text-align: center;
95 cursor: pointer;
96 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
97 position: relative;
98 overflow: hidden;
99 }
100
101 .plan-box::before {
102 content: "";
103 position: absolute;
104 inset: 0;
105 border-radius: inherit;
106 pointer-events: none;
107 transition: all 0.3s;
108 }
109
110 .plan-box[data-plan="basic"] {
111 background: rgba(255, 255, 255, 0.2);
112 border: 2px solid rgba(255, 255, 255, 0.18);
113 }
114 .plan-box[data-plan="basic"]::before {
115 background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent);
116 }
117
118 .plan-box[data-plan="standard"] {
119 background: rgba(96, 165, 250, 0.08);
120 border: 2px solid rgba(96, 165, 250, 0.25);
121 }
122 .plan-box[data-plan="standard"]::before {
123 background: linear-gradient(180deg, rgba(96, 165, 250, 0.1), transparent);
124 }
125
126 .plan-box[data-plan="premium"] {
127 background: rgba(168, 85, 247, 0.08);
128 border: 2px solid rgba(168, 85, 247, 0.25);
129 }
130 .plan-box[data-plan="premium"]::before {
131 background: linear-gradient(180deg, rgba(168, 85, 247, 0.1), transparent);
132 }
133
134 .plan-box:hover:not(.disabled) {
135 transform: translateY(-4px);
136 box-shadow: 0 8px 28px rgba(0, 0, 0, 0.15);
137 }
138
139 .plan-box[data-plan="standard"]:hover:not(.disabled) {
140 background: rgba(96, 165, 250, 0.16);
141 border-color: rgba(96, 165, 250, 0.4);
142 }
143
144 .plan-box[data-plan="premium"]:hover:not(.disabled) {
145 background: rgba(168, 85, 247, 0.16);
146 border-color: rgba(168, 85, 247, 0.4);
147 }
148
149 .plan-box.selected {
150 transform: translateY(-4px);
151 box-shadow: 0 0 0 3px rgba(72, 187, 178, 0.6), 0 8px 28px rgba(0, 0, 0, 0.15), 0 0 30px rgba(72, 187, 178, 0.15);
152 }
153
154 .plan-box[data-plan="standard"].selected {
155 border-color: rgba(72, 187, 178, 0.9);
156 background: rgba(96, 165, 250, 0.18);
157 }
158
159 .plan-box[data-plan="premium"].selected {
160 border-color: rgba(72, 187, 178, 0.9);
161 background: rgba(168, 85, 247, 0.18);
162 }
163
164 .plan-box.disabled {
165 opacity: 0.65;
166 cursor: not-allowed;
167 }
168
169 .plan-box.current-plan {
170 border-color: rgba(255, 255, 255, 0.6);
171 border-width: 3px;
172 box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15), 0 0 20px rgba(255, 255, 255, 0.08);
173 }
174
175 .plan-name {
176 font-size: 20px;
177 font-weight: 700;
178 color: white;
179 margin-bottom: 6px;
180 }
181
182 .plan-price {
183 font-size: 24px;
184 font-weight: 700;
185 color: white;
186 margin-bottom: 4px;
187 }
188
189 .plan-period {
190 font-size: 13px;
191 color: rgba(255, 255, 255, 0.55);
192 }
193
194 .plan-current-tag {
195 display: inline-block;
196 margin-top: 10px;
197 padding: 4px 12px;
198 border-radius: 980px;
199 font-size: 11px;
200 font-weight: 600;
201 text-transform: uppercase;
202 letter-spacing: 0.5px;
203 background: rgba(255, 255, 255, 0.2);
204 border: 1px solid rgba(255, 255, 255, 0.25);
205 }
206
207 .plan-features {
208 margin-top: 14px;
209 display: flex;
210 flex-direction: column;
211 gap: 6px;
212 }
213
214 .plan-feature {
215 font-size: 13px;
216 font-weight: 500;
217 color: rgba(255, 255, 255, 0.75);
218 }
219
220 .plan-feature.dim { color: rgba(255, 255, 255, 0.4); }
221
222 .plan-feature.highlight {
223 color: rgba(72, 187, 178, 0.95);
224 font-weight: 600;
225 }
226
227 .plan-renew-note {
228 margin-top: 14px;
229 text-align: center;
230 font-size: 13px;
231 color: rgba(255, 255, 255, 0.55);
232 font-style: italic;
233 }
234
235 /* =========================================================
236 ENERGY BUY
237 ========================================================= */
238 .energy-btns {
239 display: flex;
240 gap: 10px;
241 flex-wrap: wrap;
242 }
243
244 .energy-buy-btn {
245 padding: 12px 20px;
246 border-radius: 980px;
247 border: 1px solid rgba(255, 255, 255, 0.28);
248 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
249 backdrop-filter: blur(22px) saturate(140%);
250 -webkit-backdrop-filter: blur(22px) saturate(140%);
251 color: white;
252 font-weight: 600;
253 font-size: 14px;
254 font-family: inherit;
255 cursor: pointer;
256 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
257 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
258 inset 0 1px 0 rgba(255, 255, 255, 0.25);
259 position: relative;
260 overflow: hidden;
261 }
262
263 .energy-buy-btn::before {
264 content: "";
265 position: absolute;
266 inset: -40%;
267 background:
268 radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%),
269 linear-gradient(120deg, transparent 30%, rgba(255, 255, 255, 0.25), transparent 70%);
270 opacity: 0;
271 transform: translateX(-30%) translateY(-10%);
272 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
273 pointer-events: none;
274 }
275
276 .energy-buy-btn:hover {
277 background: rgba(var(--glass-water-rgb), var(--glass-alpha-hover));
278 transform: translateY(-2px);
279 }
280
281 .energy-buy-btn:hover::before {
282 opacity: 1;
283 transform: translateX(30%) translateY(10%);
284 }
285
286 .energy-buy-btn:active {
287 background: rgba(var(--glass-water-rgb), 0.45);
288 transform: translateY(0);
289 }
290
291 /* =========================================================
292 CHECKOUT
293 ========================================================= */
294 .checkout-summary {
295 display: flex;
296 flex-direction: column;
297 gap: 10px;
298 }
299
300 .checkout-row {
301 display: flex;
302 justify-content: space-between;
303 align-items: center;
304 padding: 14px 16px;
305 background: rgba(255, 255, 255, 0.08);
306 border: 1px solid rgba(255, 255, 255, 0.15);
307 border-radius: 12px;
308 transition: background 0.2s;
309 }
310
311 .checkout-row:hover {
312 background: rgba(255, 255, 255, 0.12);
313 }
314
315 .checkout-row-left {
316 display: flex;
317 align-items: center;
318 gap: 12px;
319 flex: 1;
320 min-width: 0;
321 }
322
323 .checkout-row-icon {
324 width: 36px;
325 height: 36px;
326 border-radius: 10px;
327 display: flex;
328 align-items: center;
329 justify-content: center;
330 font-size: 16px;
331 flex-shrink: 0;
332 }
333
334 .checkout-row-icon.plan-icon {
335 background: rgba(168, 85, 247, 0.2);
336 border: 1px solid rgba(168, 85, 247, 0.3);
337 }
338
339 .checkout-row-icon.energy-icon {
340 background: rgba(250, 204, 21, 0.2);
341 border: 1px solid rgba(250, 204, 21, 0.3);
342 }
343
344 .checkout-row-info {
345 display: flex;
346 flex-direction: column;
347 gap: 2px;
348 min-width: 0;
349 }
350
351 .checkout-row-label {
352 font-size: 14px;
353 font-weight: 600;
354 color: white;
355 }
356
357 .checkout-row-desc {
358 font-size: 12px;
359 color: rgba(255, 255, 255, 0.5);
360 }
361
362 .checkout-row-right {
363 display: flex;
364 align-items: center;
365 gap: 12px;
366 flex-shrink: 0;
367 }
368
369 .checkout-row-value {
370 font-size: 16px;
371 font-weight: 700;
372 color: white;
373 }
374
375 .checkout-remove {
376 width: 28px;
377 height: 28px;
378 border-radius: 50%;
379 border: 1px solid rgba(255, 255, 255, 0.2);
380 background: rgba(239, 68, 68, 0.15);
381 color: rgba(255, 255, 255, 0.7);
382 font-size: 14px;
383 cursor: pointer;
384 display: inline-flex;
385 align-items: center;
386 justify-content: center;
387 transition: all 0.2s;
388 line-height: 1;
389 }
390
391 .checkout-remove:hover {
392 background: rgba(239, 68, 68, 0.35);
393 border-color: rgba(239, 68, 68, 0.5);
394 color: white;
395 }
396
397 .checkout-divider {
398 height: 1px;
399 background: rgba(255, 255, 255, 0.1);
400 margin: 4px 0;
401 }
402
403 .checkout-total {
404 display: flex;
405 justify-content: space-between;
406 align-items: center;
407 padding: 18px 20px;
408 background: linear-gradient(135deg, rgba(72, 187, 178, 0.2), rgba(56, 163, 155, 0.15));
409 border: 1px solid rgba(72, 187, 178, 0.35);
410 border-radius: 14px;
411 }
412
413 .checkout-total-label {
414 font-size: 16px;
415 font-weight: 600;
416 color: white;
417 }
418
419 .checkout-total-value {
420 font-size: 28px;
421 font-weight: 700;
422 color: white;
423 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
424 }
425
426 .checkout-btn {
427 width: 100%;
428 padding: 18px;
429 border-radius: 980px;
430 border: 1px solid rgba(72, 187, 178, 0.5);
431 background: linear-gradient(135deg, rgba(72, 187, 178, 0.4), rgba(56, 163, 155, 0.35));
432 backdrop-filter: blur(22px) saturate(140%);
433 -webkit-backdrop-filter: blur(22px) saturate(140%);
434 color: white;
435 font-size: 17px;
436 font-weight: 700;
437 font-family: inherit;
438 cursor: pointer;
439 margin-top: 16px;
440 transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
441 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
442 0 0 20px rgba(72, 187, 178, 0.1),
443 inset 0 1px 0 rgba(255, 255, 255, 0.2);
444 position: relative;
445 overflow: hidden;
446 letter-spacing: -0.2px;
447 }
448
449 .checkout-btn::before {
450 content: "";
451 position: absolute;
452 inset: -40%;
453 background:
454 radial-gradient(120% 60% at 0% 0%, rgba(255, 255, 255, 0.35), transparent 60%),
455 linear-gradient(120deg, transparent 30%, rgba(255, 255, 255, 0.25), transparent 70%);
456 opacity: 0;
457 transform: translateX(-30%) translateY(-10%);
458 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
459 pointer-events: none;
460 }
461
462 .checkout-btn:hover {
463 background: linear-gradient(135deg, rgba(72, 187, 178, 0.55), rgba(56, 163, 155, 0.5));
464 transform: translateY(-2px);
465 box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18),
466 0 0 30px rgba(72, 187, 178, 0.2);
467 }
468
469 .checkout-btn:hover::before {
470 opacity: 1;
471 transform: translateX(30%) translateY(10%);
472 }
473
474 .checkout-btn:active { transform: translateY(0); }
475
476 .checkout-btn:disabled {
477 opacity: 0.4;
478 cursor: not-allowed;
479 transform: none;
480 }
481
482 .checkout-btn:disabled:hover {
483 background: linear-gradient(135deg, rgba(72, 187, 178, 0.4), rgba(56, 163, 155, 0.35));
484 transform: none;
485 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
486 }
487
488 .checkout-legal {
489 text-align: center;
490 margin-top: 14px;
491 font-size: 12px;
492 color: rgba(255, 255, 255, 0.45);
493 line-height: 1.5;
494 }
495
496 .checkout-legal-link {
497 color: rgba(255, 255, 255, 0.7);
498 text-decoration: underline;
499 text-underline-offset: 2px;
500 cursor: pointer;
501 transition: color 0.2s;
502 }
503
504 .checkout-legal-link:hover { color: white; }
505
506 .checkout-note {
507 text-align: center;
508 margin-top: 10px;
509 font-size: 13px;
510 color: rgba(255, 255, 255, 0.45);
511 font-style: italic;
512 }
513
514 .checkout-empty {
515 text-align: center;
516 padding: 28px 20px;
517 color: rgba(255, 255, 255, 0.4);
518 font-style: italic;
519 font-size: 14px;
520 border: 2px dashed rgba(255, 255, 255, 0.12);
521 border-radius: 14px;
522 }
523
524 /* =========================================================
525 LLM SECTION
526 ========================================================= */
527 .llm-section-wrapper {
528 position: relative;
529 }
530
531 .llm-section-wrapper.locked .llm-section-content {
532 opacity: 0.2;
533 pointer-events: none;
534 filter: blur(2px);
535 }
536
537 .llm-upgrade-overlay {
538 display: none;
539 position: absolute;
540 inset: 0;
541 z-index: 5;
542 border-radius: inherit;
543 align-items: center;
544 justify-content: center;
545 flex-direction: column;
546 gap: 8px;
547 }
548
549 .llm-section-wrapper.locked .llm-upgrade-overlay {
550 display: flex;
551 }
552
553 .llm-upgrade-text {
554 font-size: 16px;
555 font-weight: 600;
556 color: white;
557 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
558 }
559
560 .llm-upgrade-sub {
561 font-size: 13px;
562 color: rgba(255, 255, 255, 0.7);
563 }
564
565 .llm-sub {
566 font-size: 14px;
567 color: rgba(255, 255, 255, 0.6);
568 line-height: 1.5;
569 margin-bottom: 16px;
570 }
571
572 .llm-toggle-row {
573 display: flex;
574 align-items: center;
575 justify-content: space-between;
576 gap: 14px;
577 margin-bottom: 16px;
578 padding: 14px 16px;
579 background: rgba(255, 255, 255, 0.06);
580 border: 1px solid rgba(255, 255, 255, 0.12);
581 border-radius: 12px;
582 }
583
584 .llm-toggle-label {
585 font-size: 14px;
586 font-weight: 600;
587 color: rgba(255, 255, 255, 0.8);
588 }
589
590 .glass-toggle {
591 position: relative;
592 width: 54px;
593 height: 28px;
594 border-radius: 999px;
595 background: rgba(255, 255, 255, 0.2);
596 border: 1px solid rgba(255, 255, 255, 0.3);
597 backdrop-filter: blur(18px);
598 cursor: pointer;
599 transition: all 0.25s ease;
600 flex-shrink: 0;
601 }
602
603 .glass-toggle.active {
604 background: rgba(72, 187, 178, 0.45);
605 box-shadow: 0 0 16px rgba(72, 187, 178, 0.35);
606 }
607
608 .glass-toggle-knob {
609 position: absolute;
610 top: 4px;
611 left: 4px;
612 width: 20px;
613 height: 20px;
614 border-radius: 50%;
615 background: white;
616 transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
617 }
618
619 .glass-toggle.active .glass-toggle-knob {
620 left: 28px;
621 }
622
623 .llm-connected-badge {
624 display: flex;
625 align-items: center;
626 gap: 10px;
627 padding: 12px 16px;
628 background: rgba(72, 187, 120, 0.15);
629 border: 1px solid rgba(72, 187, 120, 0.3);
630 border-radius: 10px;
631 margin-bottom: 16px;
632 }
633
634 .llm-connected-dot {
635 width: 8px; height: 8px;
636 border-radius: 50%;
637 background: rgba(72, 187, 120, 0.9);
638 box-shadow: 0 0 8px rgba(72, 187, 120, 0.5);
639 flex-shrink: 0;
640 }
641
642 .llm-connected-text {
643 font-size: 13px;
644 font-weight: 600;
645 color: rgba(72, 187, 120, 0.9);
646 }
647
648 .llm-connected-detail {
649 font-size: 12px;
650 color: rgba(255, 255, 255, 0.45);
651 margin-left: auto;
652 font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
653 overflow: hidden;
654 text-overflow: ellipsis;
655 white-space: nowrap;
656 max-width: 300px;
657 }
658
659 .llm-fields {
660 display: flex;
661 flex-direction: column;
662 gap: 12px;
663 transition: opacity 0.3s;
664 }
665
666 .llm-fields.disabled {
667 opacity: 0.35;
668 pointer-events: none;
669 }
670
671 .llm-field-row {
672 display: flex;
673 flex-direction: column;
674 gap: 4px;
675 }
676
677 .llm-field-label {
678 font-size: 12px;
679 font-weight: 600;
680 text-transform: uppercase;
681 letter-spacing: 0.5px;
682 color: rgba(255, 255, 255, 0.55);
683 }
684
685 .llm-input {
686 padding: 14px 16px;
687 font-size: 15px;
688 border-radius: 12px;
689 border: 2px solid rgba(255, 255, 255, 0.3);
690 background: rgba(255, 255, 255, 0.15);
691 color: white;
692 font-family: inherit;
693 font-weight: 500;
694 transition: all 0.2s;
695 width: 100%;
696 }
697
698 .llm-input::placeholder { color: rgba(255, 255, 255, 0.35); }
699
700 .llm-input:focus {
701 outline: none;
702 border-color: rgba(255, 255, 255, 0.6);
703 background: rgba(255, 255, 255, 0.25);
704 box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.15);
705 transform: translateY(-2px);
706 }
707
708 /* Custom dropdown (replaces native <select> to avoid iframe glitch on mobile) */
709 .custom-select {
710 position: relative;
711 width: 100%;
712 }
713 .custom-select-trigger {
714 padding: 8px 10px;
715 font-size: 15px;
716 border-radius: 12px;
717 border: 2px solid rgba(255, 255, 255, 0.3);
718 background: rgba(255, 255, 255, 0.15);
719 color: white;
720 font-family: inherit;
721 font-weight: 500;
722 cursor: pointer;
723 display: flex;
724 align-items: center;
725 justify-content: space-between;
726 gap: 8px;
727 transition: border-color 0.2s, background 0.2s;
728 -webkit-user-select: none;
729 user-select: none;
730 }
731 .custom-select-trigger::after {
732 content: "\u25BE";
733 font-size: 12px;
734 opacity: 0.6;
735 flex-shrink: 0;
736 }
737 .custom-select.open .custom-select-trigger {
738 border-color: rgba(255, 255, 255, 0.6);
739 background: rgba(255, 255, 255, 0.25);
740 }
741 .custom-select.open .custom-select-trigger::after { content: "\u25B4"; }
742 .custom-select-options {
743 display: none;
744 position: absolute;
745 left: 0; right: 0;
746 bottom: calc(100% + 4px);
747 background: rgba(30, 20, 50, 0.97);
748 border: 1px solid rgba(255, 255, 255, 0.25);
749 border-radius: 10px;
750 overflow: hidden;
751 z-index: 100;
752 max-height: 220px;
753 overflow-y: auto;
754 backdrop-filter: blur(12px);
755 -webkit-backdrop-filter: blur(12px);
756 box-shadow: 0 -4px 20px rgba(0,0,0,0.4);
757 }
758 .custom-select.open .custom-select-options { display: block; }
759 .custom-select-option {
760 padding: 10px 12px;
761 font-size: 14px;
762 color: rgba(255, 255, 255, 0.8);
763 cursor: pointer;
764 transition: background 0.15s;
765 }
766 .custom-select-option:hover,
767 .custom-select-option:focus { background: rgba(255, 255, 255, 0.12); }
768 .custom-select-option.selected {
769 background: rgba(72, 187, 178, 0.2);
770 color: white;
771 font-weight: 600;
772 }
773
774 .llm-btn-row {
775 display: flex;
776 gap: 12px;
777 margin-top: 4px;
778 }
779
780 .llm-save-btn,
781 .llm-disconnect-btn {
782 padding: 14px 24px;
783 border-radius: 980px;
784 border: 1px solid;
785 color: white;
786 font-weight: 600;
787 font-size: 15px;
788 font-family: inherit;
789 cursor: pointer;
790 transition: all 0.3s;
791 box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12),
792 inset 0 1px 0 rgba(255, 255, 255, 0.2);
793 background: none;
794 }
795
796 .llm-save-btn {
797 flex: 1;
798 border-color: rgba(72, 187, 178, 0.4);
799 background: rgba(72, 187, 178, 0.3);
800 }
801
802 .llm-save-btn:hover {
803 background: rgba(72, 187, 178, 0.45);
804 transform: translateY(-2px);
805 }
806
807 .llm-disconnect-btn {
808 border-color: rgba(239, 68, 68, 0.4);
809 background: rgba(239, 68, 68, 0.25);
810 }
811
812 .llm-disconnect-btn:hover {
813 background: rgba(239, 68, 68, 0.4);
814 transform: translateY(-2px);
815 }
816
817 .llm-status {
818 margin-top: 10px;
819 font-size: 13px;
820 font-weight: 600;
821 display: none;
822 }
823
824 /* =========================================================
825 MODAL (Terms / Privacy)
826 ========================================================= */
827 .modal-overlay {
828 display: none;
829 position: fixed;
830 inset: 0;
831 z-index: 1000;
832 background: rgba(0, 0, 0, 0.6);
833 backdrop-filter: blur(8px);
834 -webkit-backdrop-filter: blur(8px);
835 align-items: center;
836 justify-content: center;
837 padding: 20px;
838 }
839
840 .modal-overlay.show { display: flex; }
841
842 .modal-container {
843 width: 100%;
844 max-width: 720px;
845 height: 85vh;
846 height: 85dvh;
847 background: rgba(var(--glass-water-rgb), 0.35);
848 backdrop-filter: blur(22px) saturate(140%);
849 -webkit-backdrop-filter: blur(22px) saturate(140%);
850 border-radius: 20px;
851 border: 1px solid rgba(255, 255, 255, 0.28);
852 box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
853 display: flex;
854 flex-direction: column;
855 overflow: hidden;
856 }
857
858 .modal-header {
859 display: flex;
860 align-items: center;
861 justify-content: space-between;
862 padding: 16px 20px;
863 border-bottom: 1px solid rgba(255, 255, 255, 0.15);
864 flex-shrink: 0;
865 }
866
867 .modal-title {
868 font-size: 16px;
869 font-weight: 600;
870 color: white;
871 }
872
873 .modal-close {
874 width: 32px;
875 height: 32px;
876 border-radius: 50%;
877 border: 1px solid rgba(255, 255, 255, 0.25);
878 background: rgba(255, 255, 255, 0.15);
879 color: white;
880 font-size: 18px;
881 cursor: pointer;
882 display: inline-flex;
883 align-items: center;
884 justify-content: center;
885 transition: background 0.2s;
886 line-height: 1;
887 }
888
889 .modal-close:hover {
890 background: rgba(255, 255, 255, 0.3);
891 }
892
893 .modal-body {
894 flex: 1;
895 overflow: hidden;
896 }
897
898 .modal-body iframe {
899 width: 100%;
900 height: 100%;
901 border: none;
902 }
903
904 /* =========================================================
905 RESPONSIVE
906 ========================================================= */
907 @media (max-width: 640px) {
908 body { padding: 16px; }
909 .container { max-width: 100%; }
910 .glass-card { padding: 20px; }
911
912 .energy-grid { grid-template-columns: 1fr; }
913 .plan-grid { grid-template-columns: 1fr; }
914 .energy-btns { flex-direction: column; }
915 .energy-buy-btn { width: 100%; }
916 .llm-btn-row { flex-direction: column; }
917 .llm-save-btn, .llm-disconnect-btn { width: 100%; text-align: center; }
918 .llm-connected-detail { max-width: 140px; }
919 .modal-container { height: 90vh; height: 90dvh; border-radius: 16px; }
920 .modal-overlay { padding: 10px; }
921 }`;
922
923 const body = `
924<div class="container">
925
926 <div class="back-nav">
927 <a href="/api/v1/user/${userId}${qs}" class="back-link">\u2190 Back to Profile</a>
928 </div>
929
930 <!-- Energy Status -->
931 <div class="glass-card" style="animation-delay: 0.1s;">
932 <h2>\u26A1 Energy</h2>
933 <div class="energy-grid">
934 <div class="energy-stat">
935 <div class="energy-stat-label">Plan Energy</div>
936 <div class="energy-stat-value">${energyAmount}</div>
937 <div class="energy-stat-sub">Resets every 24 hours</div>
938 </div>
939 <div class="energy-stat plan-${plan}">
940 <div class="energy-stat-label">Current Plan</div>
941 <div class="energy-stat-value" style="font-size: 22px; text-transform: capitalize;">${plan}</div>
942 ${!isBasic && planExpiresAt ? '<div class="energy-stat-sub">Expires ' + new Date(planExpiresAt).toLocaleDateString() + "</div>" : ""}
943 </div>
944 <div class="energy-stat">
945 <div class="energy-stat-label">Additional Energy</div>
946 <div class="energy-stat-value">${additionalEnergy}</div>
947 <div class="energy-stat-sub">Used after plan energy</div>
948 </div>
949 </div>
950 </div>
951
952 <!-- Payment sections (plans, energy purchase, checkout) -- registered by billing extension -->
953 ${energyResolveSlots("energy-payment", { userId, plan, planExpiresAt }) || '<div class="glass-card" style="animation-delay:0.15s;"><p style="color:rgba(255,255,255,0.4);font-size:0.9rem;">No payment system installed. Install the billing extension to enable plan upgrades and energy purchases.</p></div>'}
954
955 <!-- Custom LLM -->
956 <div class="glass-card" style="animation-delay: 0.3s;">
957 <h2>\uD83E\uDD16 Custom LLM Endpoints <span style="opacity:0.5;font-size:0.7em">(${connectionCount}/15)</span></h2>
958 <div class="llm-section-wrapper">
959 <div class="llm-section-content">
960 <div class="llm-sub">Connect your own OpenAI API-compatible LLMs. Assign them to different areas below.</div>
961
962 ${
963 connectionCount > 0
964 ? '<div style="display:flex;gap:12px;margin-bottom:14px;flex-wrap:wrap;">' +
965 '<div style="flex:1;min-width:180px;">' +
966 '<label class="llm-field-label" style="margin-bottom:4px;display:block;">Profile (Chat)</label>' +
967 '<div class="custom-select" id="llmAssignMain" data-slot="main">' +
968 '<div class="custom-select-trigger">' +
969 (mainAssignment
970 ? llmConnections
971 .filter(function (c) {
972 return c._id === mainAssignment;
973 })
974 .map(function (c) {
975 return c.name + " (" + c.model + ")";
976 })[0] || "None selected"
977 : "None selected") +
978 "</div>" +
979 '<div class="custom-select-options">' +
980 '<div class="custom-select-option' +
981 (!mainAssignment ? " selected" : "") +
982 '" data-value="">None</div>' +
983 llmConnections
984 .map(function (c) {
985 return (
986 '<div class="custom-select-option' +
987 (mainAssignment === c._id ? " selected" : "") +
988 '" data-value="' +
989 c._id +
990 '">' +
991 c.name +
992 " (" +
993 c.model +
994 ")</div>"
995 );
996 })
997 .join("") +
998 "</div>" +
999 "</div>" +
1000 "</div>" +
1001 '<div style="flex:1;min-width:180px;">' +
1002 '<label class="llm-field-label" style="margin-bottom:4px;display:block;">Raw Ideas</label>' +
1003 '<div class="custom-select" id="llmAssignRawIdea" data-slot="rawIdea">' +
1004 '<div class="custom-select-trigger">' +
1005 (rawIdeaAssignment
1006 ? llmConnections
1007 .filter(function (c) {
1008 return c._id === rawIdeaAssignment;
1009 })
1010 .map(function (c) {
1011 return c.name + " (" + c.model + ")";
1012 })[0] || "Uses main"
1013 : "Uses main") +
1014 "</div>" +
1015 '<div class="custom-select-options">' +
1016 '<div class="custom-select-option' +
1017 (!rawIdeaAssignment ? " selected" : "") +
1018 '" data-value="">Uses main</div>' +
1019 llmConnections
1020 .map(function (c) {
1021 return (
1022 '<div class="custom-select-option' +
1023 (rawIdeaAssignment === c._id ? " selected" : "") +
1024 '" data-value="' +
1025 c._id +
1026 '">' +
1027 c.name +
1028 " (" +
1029 c.model +
1030 ")</div>"
1031 );
1032 })
1033 .join("") +
1034 "</div>" +
1035 "</div>" +
1036 "</div>" +
1037 "</div>"
1038 : ""
1039 }
1040
1041 <div id="llmConnectionsList">
1042 ${
1043 connectionCount === 0
1044 ? '<div class="llm-empty-state" style="text-align:center;padding:18px 0;opacity:0.5;">No connections yet</div>'
1045 : llmConnections
1046 .map(function (c) {
1047 return (
1048 '<div class="llm-conn-card" data-id="' +
1049 c._id +
1050 '" style="border:1px solid var(--glass-border-light);border-radius:10px;padding:12px 14px;margin-bottom:8px;background:rgba(255,255,255,0.03);">' +
1051 '<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;">' +
1052 '<div style="flex:1;min-width:0;">' +
1053 '<div style="font-weight:600;font-size:0.95em;">' +
1054 c.name +
1055 "</div>" +
1056 '<div style="font-size:0.8em;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' +
1057 c.model +
1058 " \u00B7 " +
1059 c.baseUrl +
1060 "</div>" +
1061 "</div>" +
1062 '<div style="display:flex;gap:6px;flex-shrink:0;">' +
1063 '<button class="llm-save-btn" style="font-size:0.75em;padding:4px 10px;" onclick="editConnection(\'' +
1064 c._id +
1065 "')\">Edit</button>" +
1066 '<button class="llm-disconnect-btn" style="font-size:0.75em;padding:4px 10px;" onclick="deleteConnection(\'' +
1067 c._id +
1068 "')\">Delete</button>" +
1069 "</div>" +
1070 "</div>" +
1071 "</div>"
1072 );
1073 })
1074 .join("")
1075 }
1076 </div>
1077
1078 <div id="llmAddSection" style="margin-top:12px;">
1079 <button class="llm-save-btn" id="llmAddToggle" onclick="toggleAddForm()" style="width:100%;">+ Add Connection</button>
1080 <div class="llm-fields" id="llmAddForm" style="display:none;margin-top:10px;">
1081 <div class="llm-field-row">
1082 <label class="llm-field-label">Name</label>
1083 <input type="text" class="llm-input" id="llmName" placeholder="e.g. Groq, OpenRouter" />
1084 </div>
1085 <div class="llm-field-row">
1086 <label class="llm-field-label">Endpoint URL</label>
1087 <input type="text" class="llm-input" id="llmBaseUrl" placeholder="https://api.groq.com/openai/v1/chat/completions" />
1088 </div>
1089 <div class="llm-field-row">
1090 <label class="llm-field-label">API Key</label>
1091 <input type="password" class="llm-input" id="llmApiKey" placeholder="gsk_abc123..." />
1092 </div>
1093 <div class="llm-field-row">
1094 <label class="llm-field-label">Model</label>
1095 <input type="text" class="llm-input" id="llmModel" placeholder="openai/gpt-oss-120b" />
1096 </div>
1097 <div class="llm-btn-row">
1098 <button class="llm-save-btn" onclick="addConnection()">Save Connection</button>
1099 </div>
1100 </div>
1101 </div>
1102
1103 <div id="llmEditSection" style="display:none;margin-top:12px;">
1104 <div style="font-weight:600;margin-bottom:8px;">Edit Connection</div>
1105 <input type="hidden" id="llmEditId" />
1106 <div class="llm-fields">
1107 <div class="llm-field-row">
1108 <label class="llm-field-label">Name</label>
1109 <input type="text" class="llm-input" id="llmEditName" />
1110 </div>
1111 <div class="llm-field-row">
1112 <label class="llm-field-label">Endpoint URL</label>
1113 <input type="text" class="llm-input" id="llmEditBaseUrl" />
1114 </div>
1115 <div class="llm-field-row">
1116 <label class="llm-field-label">API Key</label>
1117 <input type="password" class="llm-input" id="llmEditApiKey" placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022 (leave blank to keep)" />
1118 </div>
1119 <div class="llm-field-row">
1120 <label class="llm-field-label">Model</label>
1121 <input type="text" class="llm-input" id="llmEditModel" />
1122 </div>
1123 <div class="llm-btn-row">
1124 <button class="llm-save-btn" onclick="updateConnection()">Update</button>
1125 <button class="llm-disconnect-btn" onclick="cancelEdit()">Cancel</button>
1126 </div>
1127 </div>
1128 </div>
1129
1130 <div class="llm-status" id="llmStatus"></div>
1131
1132 <!-- Failover Stack -->
1133 <div style="margin-top:20px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);">
1134 <div style="font-weight:600;margin-bottom:8px;">Failover Stack</div>
1135 <div class="llm-sub" style="margin-bottom:10px;">If your default LLM fails (rate limit, timeout), the system tries these backups in order.</div>
1136 <div id="failoverStack" style="min-height:30px;">
1137 <div style="opacity:0.4;font-size:0.85rem;">Loading...</div>
1138 </div>
1139 <div style="margin-top:8px;display:flex;gap:8px;align-items:center;">
1140 <select id="failoverSelect" class="llm-input" style="flex:1;">
1141 <option value="">Select a backup connection...</option>
1142 ${llmConnections.map(c => '<option value="' + c._id + '">' + c.name + ' (' + c.model + ')</option>').join("")}
1143 </select>
1144 <button class="llm-save-btn" onclick="pushFailover()" style="white-space:nowrap;">Add Backup</button>
1145 </div>
1146 </div>
1147
1148 </div>
1149 </div>
1150 </div>
1151
1152</div>
1153
1154<!-- Terms Modal -->
1155<div class="modal-overlay" id="termsModal">
1156 <div class="modal-container">
1157 <div class="modal-header">
1158 <span class="modal-title">Terms of Service</span>
1159 <span class="modal-close" onclick="closeModal('terms')">\u2715</span>
1160 </div>
1161 <div class="modal-body">
1162 <iframe src="/terms" title="Terms of Service"></iframe>
1163 </div>
1164 </div>
1165</div>
1166
1167<!-- Privacy Modal -->
1168<div class="modal-overlay" id="privacyModal">
1169 <div class="modal-container">
1170 <div class="modal-header">
1171 <span class="modal-title">Privacy Policy</span>
1172 <span class="modal-close" onclick="closeModal('privacy')">\u2715</span>
1173 </div>
1174 <div class="modal-body">
1175 <iframe src="/privacy" title="Privacy Policy"></iframe>
1176 </div>
1177 </div>
1178</div>`;
1179
1180 const js = `
1181function loadFailoverStack() {
1182 fetch("/api/v1/user/${userId}/llm-failover${qs}", { credentials: "include" })
1183 .then(r => r.json())
1184 .then(data => {
1185 const inner = data.data || data;
1186 const el = document.getElementById("failoverStack");
1187 const stack = inner.stack || [];
1188 if (stack.length === 0) {
1189 el.innerHTML = '<div style="opacity:0.4;font-size:0.85rem;">No backups configured. Add connections above to build your failover stack.</div>';
1190 return;
1191 }
1192 const conns = ${JSON.stringify(llmConnections.map(c => ({ id: c._id, name: c.name, model: c.model })))};
1193 el.innerHTML = stack.map((id, i) => {
1194 const c = conns.find(x => x.id === id);
1195 const label = c ? c.name + " (" + c.model + ")" : id;
1196 return '<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid rgba(255,255,255,0.05);">' +
1197 '<span style="opacity:0.4;font-size:0.8rem;width:20px;">' + (i+1) + '.</span>' +
1198 '<span style="flex:1;">' + label + '</span>' +
1199 '<button onclick="removeFailover(\\''+id+'\\','+i+')" style="background:none;border:none;color:rgba(255,100,100,0.7);cursor:pointer;font-size:0.8rem;">remove</button>' +
1200 '</div>';
1201 }).join("");
1202 })
1203 .catch(() => {
1204 document.getElementById("failoverStack").innerHTML = '<div style="color:rgba(255,100,100,0.7);">Failed to load</div>';
1205 });
1206}
1207
1208function pushFailover() {
1209 const select = document.getElementById("failoverSelect");
1210 const connectionId = select.value;
1211 if (!connectionId) return;
1212 fetch("/api/v1/user/${userId}/llm-failover${qs}", {
1213 method: "POST",
1214 headers: { "Content-Type": "application/json" },
1215 credentials: "include",
1216 body: JSON.stringify({ connectionId })
1217 })
1218 .then(r => r.json())
1219 .then(data => {
1220 if (data.error) return alert((data.error && data.error.message) || data.error || "Failed to add");
1221 select.value = "";
1222 loadFailoverStack();
1223 });
1224}
1225
1226function removeFailover(connId, index) {
1227 fetch("/api/v1/user/${userId}/llm-failover/" + encodeURIComponent(connId) + "${qs}", {
1228 method: "DELETE",
1229 credentials: "include",
1230 })
1231 .then(r => r.json())
1232 .then(data => {
1233 if (data.error) return alert((data.error && data.error.message) || data.error || "Failed to remove");
1234 loadFailoverStack();
1235 });
1236}
1237
1238loadFailoverStack();
1239
1240var userId = "${userId}";
1241var currentPlan = "${plan}";
1242var PLAN_PRICE = { basic: 0, standard: 20, premium: 100 };
1243var PLAN_ORDER = ["basic", "standard", "premium"];
1244var ENERGY_RATE = 0.01;
1245
1246var state = {
1247 energyAdded: 0,
1248 selectedPlan: null
1249};
1250
1251// =====================
1252// MODAL
1253// =====================
1254function openModal(type) {
1255 var id = type === "terms" ? "termsModal" : "privacyModal";
1256 document.getElementById(id).classList.add("show");
1257 document.body.style.overflow = "hidden";
1258}
1259
1260function closeModal(type) {
1261 var id = type === "terms" ? "termsModal" : "privacyModal";
1262 document.getElementById(id).classList.remove("show");
1263 document.body.style.overflow = "";
1264}
1265
1266document.querySelectorAll(".modal-overlay").forEach(function(overlay) {
1267 overlay.addEventListener("click", function(e) {
1268 if (e.target === overlay) {
1269 overlay.classList.remove("show");
1270 document.body.style.overflow = "";
1271 }
1272 });
1273});
1274
1275document.addEventListener("keydown", function(e) {
1276 if (e.key === "Escape") {
1277 document.querySelectorAll(".modal-overlay.show").forEach(function(m) {
1278 m.classList.remove("show");
1279 });
1280 document.body.style.overflow = "";
1281 }
1282});
1283
1284// =====================
1285// URL STATE
1286// =====================
1287function readURL() {
1288 var p = new URLSearchParams(location.search);
1289 if (p.get("energy")) state.energyAdded = parseInt(p.get("energy")) || 0;
1290 if (p.get("plan") && p.get("plan") !== currentPlan) {
1291 state.selectedPlan = p.get("plan");
1292 }
1293}
1294
1295function writeURL() {
1296 var p = new URLSearchParams(location.search);
1297 p.delete("energy");
1298 p.delete("plan");
1299 if (!p.has("html")) p.set("html", "");
1300 if (state.energyAdded > 0) p.set("energy", state.energyAdded);
1301 if (state.selectedPlan) p.set("plan", state.selectedPlan);
1302 history.replaceState(null, "", "?" + p.toString());
1303}
1304
1305// =====================
1306// PLAN LOGIC
1307// =====================
1308function canSelectPlan(plan) {
1309 if (plan === "basic") return false;
1310 var cur = PLAN_ORDER.indexOf(currentPlan);
1311 var next = PLAN_ORDER.indexOf(plan);
1312 return next >= cur;
1313}
1314
1315function renderPlans() {
1316 document.querySelectorAll(".plan-box").forEach(function(box) {
1317 var plan = box.dataset.plan;
1318 var isSelected = state.selectedPlan === plan;
1319 var isCurrent = plan === currentPlan && !state.selectedPlan;
1320
1321 box.classList.toggle("selected", isSelected);
1322 box.classList.toggle("current-plan", isCurrent);
1323 box.classList.toggle("disabled", !canSelectPlan(plan));
1324 });
1325
1326 var note = document.getElementById("planNote");
1327 if (state.selectedPlan) {
1328 if (state.selectedPlan === currentPlan) {
1329 note.textContent = "Renewing " + state.selectedPlan + " for 30 more days";
1330 } else {
1331 note.textContent = "Upgrading to " + state.selectedPlan + " for 30 days";
1332 }
1333 note.style.display = "block";
1334 } else {
1335 note.style.display = "none";
1336 }
1337}
1338
1339// =====================
1340// ENERGY
1341// =====================
1342function renderEnergy() {
1343 var el = document.getElementById("energyAdded");
1344 var val = document.getElementById("energyAddedVal");
1345 if (state.energyAdded > 0) {
1346 el.style.display = "block";
1347 val.textContent = "+" + state.energyAdded + " ($" + (state.energyAdded * ENERGY_RATE).toFixed(2) + ")";
1348 } else {
1349 el.style.display = "none";
1350 }
1351}
1352
1353function resetEnergy() {
1354 state.energyAdded = 0;
1355 writeURL();
1356 renderEnergy();
1357 renderCheckout();
1358}
1359
1360function removePlan() {
1361 state.selectedPlan = null;
1362 writeURL();
1363 renderPlans();
1364 renderCheckout();
1365}
1366
1367// =====================
1368// CHECKOUT
1369// =====================
1370function renderCheckout() {
1371 var container = document.getElementById("checkoutContent");
1372 var energyCost = state.energyAdded * ENERGY_RATE;
1373 var planCost = state.selectedPlan ? (PLAN_PRICE[state.selectedPlan] || 0) : 0;
1374 var total = energyCost + planCost;
1375
1376 if (total <= 0) {
1377 container.innerHTML = '<div class="checkout-empty">Select a plan or add energy to continue</div>';
1378 return;
1379 }
1380
1381 var rows = "";
1382
1383 if (state.selectedPlan) {
1384 var label = state.selectedPlan === currentPlan
1385 ? "Renew " + state.selectedPlan
1386 : "Upgrade to " + state.selectedPlan;
1387 var desc = state.selectedPlan === currentPlan
1388 ? "+30 days added to remaining time"
1389 : "30-day plan starts immediately";
1390
1391 rows +=
1392 '<div class="checkout-row">' +
1393 '<div class="checkout-row-left">' +
1394 '<div class="checkout-row-icon plan-icon">\uD83D\uDCCB</div>' +
1395 '<div class="checkout-row-info">' +
1396 '<div class="checkout-row-label">' + label + '</div>' +
1397 '<div class="checkout-row-desc">' + desc + '</div>' +
1398 '</div>' +
1399 '</div>' +
1400 '<div class="checkout-row-right">' +
1401 '<div class="checkout-row-value">$' + planCost.toFixed(2) + '</div>' +
1402 '<span class="checkout-remove" onclick="removePlan()">\u2715</span>' +
1403 '</div>' +
1404 '</div>';
1405 }
1406
1407 if (state.energyAdded > 0) {
1408 rows +=
1409 '<div class="checkout-row">' +
1410 '<div class="checkout-row-left">' +
1411 '<div class="checkout-row-icon energy-icon">\uD83D\uDD25</div>' +
1412 '<div class="checkout-row-info">' +
1413 '<div class="checkout-row-label">+' + state.energyAdded + ' Additional Energy</div>' +
1414 '<div class="checkout-row-desc">Reserve \u2014 used after plan energy</div>' +
1415 '</div>' +
1416 '</div>' +
1417 '<div class="checkout-row-right">' +
1418 '<div class="checkout-row-value">$' + energyCost.toFixed(2) + '</div>' +
1419 '<span class="checkout-remove" onclick="resetEnergy()">\u2715</span>' +
1420 '</div>' +
1421 '</div>';
1422 }
1423
1424 container.innerHTML =
1425 '<div class="checkout-summary">' +
1426 rows +
1427 '<div class="checkout-divider"></div>' +
1428 '<div class="checkout-total">' +
1429 '<div class="checkout-total-label">Total</div>' +
1430 '<div class="checkout-total-value">$' + total.toFixed(2) + '</div>' +
1431 '</div>' +
1432 '</div>' +
1433 '<button class="checkout-btn" onclick="handleCheckout()">Pay with Stripe</button>' +
1434 '<div class="checkout-legal">' +
1435 'By purchasing, you agree to our ' +
1436 '<span class="checkout-legal-link" onclick="openModal('terms')">Terms of Service</span>' +
1437 ' and ' +
1438 '<span class="checkout-legal-link" onclick="openModal('privacy')">Privacy Policy</span>.' +
1439 '</div>' +
1440 '<div class="checkout-note">No recurring charges \u00B7 No refunds \u00B7 Renew manually</div>';
1441}
1442
1443// =====================
1444// STRIPE CHECKOUT
1445// =====================
1446async function handleCheckout() {
1447 var btn = document.querySelector(".checkout-btn");
1448 btn.disabled = true;
1449 btn.textContent = "Processing\u2026";
1450
1451 try {
1452 var body = {
1453 userId: userId,
1454 energyAmount: state.energyAdded > 0 ? state.energyAdded : 0,
1455 plan: state.selectedPlan || null,
1456 currentPlan: currentPlan,
1457 };
1458
1459 var res = await fetch("/api/v1/user/" + userId + "/purchase", {
1460 method: "POST",
1461 headers: { "Content-Type": "application/json" },
1462 body: JSON.stringify(body),
1463 });
1464
1465 var data = await res.json();
1466 var inner = data.data || data;
1467
1468 if (inner.url) {
1469 if (window.top !== window.self) {
1470 window.top.location.href = inner.url;
1471 } else {
1472 window.location.href = inner.url;
1473 }
1474 } else if (data.error || inner.error) {
1475 alert((data.error && data.error.message) || data.error || inner.error || "Payment failed");
1476 btn.disabled = false;
1477 btn.textContent = "Pay with Stripe";
1478 }
1479 } catch (err) {
1480 alert("Something went wrong. Please try again.");
1481 btn.disabled = false;
1482 btn.textContent = "Pay with Stripe";
1483 }
1484}
1485
1486// =====================
1487// CUSTOM LLM
1488// =====================
1489var llmConnections = ${JSON.stringify(llmConnections)};
1490
1491function showLlmStatus(msg, ok) {
1492 var el = document.getElementById("llmStatus");
1493 el.style.display = "block";
1494 el.textContent = msg;
1495 el.style.color = ok ? "rgba(72, 187, 120, 0.9)" : "rgba(255, 107, 107, 0.9)";
1496 if (ok) setTimeout(function() { el.style.display = "none"; }, 3000);
1497}
1498
1499function toggleAddForm() {
1500 var form = document.getElementById("llmAddForm");
1501 form.style.display = form.style.display === "none" ? "block" : "none";
1502 document.getElementById("llmEditSection").style.display = "none";
1503}
1504
1505async function addConnection() {
1506 var name = document.getElementById("llmName").value.trim();
1507 var baseUrl = document.getElementById("llmBaseUrl").value.trim();
1508 var apiKey = document.getElementById("llmApiKey").value.trim();
1509 var model = document.getElementById("llmModel").value.trim();
1510
1511 if (!name || !baseUrl || !apiKey || !model) {
1512 showLlmStatus("All fields are required", false);
1513 return;
1514 }
1515
1516 try {
1517 var res = await fetch("/api/v1/user/" + userId + "/custom-llm", {
1518 method: "POST",
1519 headers: { "Content-Type": "application/json" },
1520 body: JSON.stringify({ name: name, baseUrl: baseUrl, apiKey: apiKey, model: model }),
1521 });
1522 if (res.ok) {
1523 showLlmStatus("\u2713 Connection added", true);
1524 setTimeout(function() { location.reload(); }, 1000);
1525 } else {
1526 var data = await res.json().catch(function() { return {}; });
1527 showLlmStatus("\u2715 " + (data.error?.message || data.error || "Failed to save"), false);
1528 }
1529 } catch (err) {
1530 showLlmStatus("\u2715 Network error", false);
1531 }
1532}
1533
1534function editConnection(connId) {
1535 var conn = llmConnections.find(function(c) { return c._id === connId; });
1536 if (!conn) return;
1537 document.getElementById("llmEditId").value = connId;
1538 document.getElementById("llmEditName").value = conn.name;
1539 document.getElementById("llmEditBaseUrl").value = conn.baseUrl;
1540 document.getElementById("llmEditApiKey").value = "";
1541 document.getElementById("llmEditModel").value = conn.model;
1542 document.getElementById("llmEditSection").style.display = "block";
1543 document.getElementById("llmAddForm").style.display = "none";
1544}
1545
1546function cancelEdit() {
1547 document.getElementById("llmEditSection").style.display = "none";
1548}
1549
1550async function updateConnection() {
1551 var connId = document.getElementById("llmEditId").value;
1552 var name = document.getElementById("llmEditName").value.trim();
1553 var baseUrl = document.getElementById("llmEditBaseUrl").value.trim();
1554 var apiKey = document.getElementById("llmEditApiKey").value.trim();
1555 var model = document.getElementById("llmEditModel").value.trim();
1556
1557 if (!baseUrl || !model) {
1558 showLlmStatus("Endpoint URL and Model are required", false);
1559 return;
1560 }
1561
1562 var payload = { baseUrl: baseUrl, model: model };
1563 if (name) payload.name = name;
1564 if (apiKey) payload.apiKey = apiKey;
1565
1566 try {
1567 var res = await fetch("/api/v1/user/" + userId + "/custom-llm/" + connId, {
1568 method: "PUT",
1569 headers: { "Content-Type": "application/json" },
1570 body: JSON.stringify(payload),
1571 });
1572 if (res.ok) {
1573 showLlmStatus("\u2713 Connection updated", true);
1574 setTimeout(function() { location.reload(); }, 1000);
1575 } else {
1576 var data = await res.json().catch(function() { return {}; });
1577 showLlmStatus("\u2715 " + (data.error?.message || data.error || "Failed to update"), false);
1578 }
1579 } catch (err) {
1580 showLlmStatus("\u2715 Network error", false);
1581 }
1582}
1583
1584async function deleteConnection(connId) {
1585 if (!confirm("Delete this connection? This cannot be undone.")) return;
1586 try {
1587 var res = await fetch("/api/v1/user/" + userId + "/custom-llm/" + connId, {
1588 method: "DELETE",
1589 });
1590 if (res.ok) {
1591 showLlmStatus("\u2713 Deleted", true);
1592 setTimeout(function() { location.reload(); }, 1000);
1593 } else {
1594 showLlmStatus("\u2715 Failed to delete", false);
1595 }
1596 } catch (err) {
1597 showLlmStatus("\u2715 Network error", false);
1598 }
1599}
1600
1601async function assignSlot(slot, connId) {
1602 try {
1603 var res = await fetch("/api/v1/user/" + userId + "/llm-assign", {
1604 method: "POST",
1605 headers: { "Content-Type": "application/json" },
1606 body: JSON.stringify({ slot: slot, connectionId: connId || null }),
1607 });
1608 if (res.ok) {
1609 var label = slot === "main" ? "Chat" : "Raw Ideas";
1610 showLlmStatus(connId ? "\u2713 Assigned to " + label : "\u2713 " + label + " \u2192 default LLM", true);
1611 } else {
1612 showLlmStatus("\u2715 Failed to assign", false);
1613 }
1614 } catch (err) {
1615 showLlmStatus("\u2715 Network error", false);
1616 }
1617}
1618
1619// =====================
1620// CUSTOM DROPDOWNS
1621// =====================
1622(function() {
1623 document.querySelectorAll(".custom-select").forEach(function(sel) {
1624 var trigger = sel.querySelector(".custom-select-trigger");
1625 trigger.addEventListener("click", function(e) {
1626 e.stopPropagation();
1627 var wasOpen = sel.classList.contains("open");
1628 // close all others
1629 document.querySelectorAll(".custom-select.open").forEach(function(s) { s.classList.remove("open"); });
1630 if (!wasOpen) sel.classList.add("open");
1631 });
1632 sel.querySelectorAll(".custom-select-option").forEach(function(opt) {
1633 opt.addEventListener("click", function(e) {
1634 e.stopPropagation();
1635 sel.querySelectorAll(".custom-select-option").forEach(function(o) { o.classList.remove("selected"); });
1636 opt.classList.add("selected");
1637 trigger.textContent = opt.textContent;
1638 sel.classList.remove("open");
1639 var val = opt.getAttribute("data-value");
1640 var slot = sel.getAttribute("data-slot");
1641 if (slot) assignSlot(slot, val);
1642 });
1643 });
1644 });
1645 document.addEventListener("click", function() {
1646 document.querySelectorAll(".custom-select.open").forEach(function(s) { s.classList.remove("open"); });
1647 });
1648})();
1649
1650// =====================
1651// EVENTS
1652// =====================
1653document.querySelectorAll(".plan-box").forEach(function(box) {
1654 box.onclick = function() {
1655 var plan = box.dataset.plan;
1656 if (!canSelectPlan(plan)) return;
1657
1658 if (state.selectedPlan === plan) {
1659 state.selectedPlan = null;
1660 } else {
1661 state.selectedPlan = plan;
1662 }
1663
1664 writeURL();
1665 renderPlans();
1666 renderCheckout();
1667 };
1668});
1669
1670document.querySelectorAll(".energy-buy-btn:not(#customEnergyBtn)").forEach(function(btn) {
1671 btn.onclick = function() {
1672 state.energyAdded += parseInt(btn.dataset.amount);
1673 writeURL();
1674 renderEnergy();
1675 renderCheckout();
1676 };
1677});
1678
1679document.getElementById("customEnergyBtn").onclick = function() {
1680 var val = parseInt(prompt("Enter energy amount:"));
1681 if (!val || val <= 0) return;
1682 state.energyAdded += val;
1683 writeURL();
1684 renderEnergy();
1685 renderCheckout();
1686};
1687
1688// =====================
1689// INIT
1690// =====================
1691readURL();
1692renderPlans();
1693renderEnergy();
1694renderCheckout();`;
1695
1696 return page({
1697 title: `Energy \u00B7 @${user.username}`,
1698 css,
1699 body,
1700 js,
1701 });
1702}
1703
1import log from "../../seed/log.js";
2import express from "express";
3import { sendOk, sendError, ERR } from "../../seed/protocol.js";
4import authenticate from "../../seed/middleware/authenticate.js";
5import { getExtension } from "../loader.js";
6let htmlAuth = authenticate;
7export function resolveHtmlAuth() {
8 const htmlExt = getExtension("html-rendering");
9 if (htmlExt?.exports?.urlAuth) htmlAuth = htmlExt.exports.urlAuth;
10}
11
12import { renderEnergy } from "./pages/energy.js";
13
14import { getUserMeta } from "../../seed/tree/userMetadata.js";
15import { getConnectionsForUser } from "../../seed/llm/connections.js";
16
17// Models wired from init via setModels
18let _User = null;
19export function setModels(models) { _User = models.User; }
20
21const router = express.Router();
22
23function buildQueryString(req) {
24 const allowedParams = ["token", "html"];
25 const filtered = Object.entries(req.query)
26 .filter(([key]) => allowedParams.includes(key))
27 .map(([key, val]) =>
28 val === "" ? key : `${key}=${encodeURIComponent(val)}`,
29 )
30 .join("&");
31 return filtered ? `?${filtered}` : "";
32}
33
34router.get("/user/:userId/energy", htmlAuth, async (req, res) => {
35 try {
36 const { userId } = req.params;
37 const qs = buildQueryString(req);
38 let user = await _User.findById(userId).exec();
39 if (!user) {
40 return sendError(res, 404, ERR.USER_NOT_FOUND, "User not found");
41 }
42
43 const energy = getUserMeta(user, "energy");
44 const energyAmount = energy.available?.amount ?? 0;
45 const additionalEnergy = energy.additional?.amount ?? 0;
46 const plan = (getUserMeta(user, "tiers").plan || "basic").toLowerCase();
47 const billing = getUserMeta(user, "billing");
48 const planExpiresAt = billing.planExpiresAt || null;
49
50 const llmConnections = await getConnectionsForUser(userId);
51 const mainAssignment = user.llmDefault || null;
52 const userLlmSlots = getUserMeta(user, "userLlm")?.slots || {};
53 const rawIdeaAssignment = userLlmSlots.rawIdea || null;
54 const activeConn = mainAssignment
55 ? llmConnections.find((c) => c._id === mainAssignment)
56 : null;
57 const hasLlm = !!activeConn;
58 const connectionCount = llmConnections.length;
59 const isBasic = plan === "basic";
60
61 const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
62
63 const htmlExt = getExtension("html-rendering");
64 if (!wantHtml || !htmlExt) {
65 return sendOk(res, {
66 userId: user._id,
67 plan,
68 energy: energy.available,
69 additionalEnergy: energy.additional,
70 hasCustomLlm: hasLlm,
71 });
72 }
73
74 return res.send(
75 renderEnergy({
76 userId,
77 user,
78 energyAmount,
79 additionalEnergy,
80 plan,
81 planExpiresAt,
82 llmConnections,
83 mainAssignment,
84 rawIdeaAssignment,
85 activeConn,
86 hasLlm,
87 connectionCount,
88 isBasic,
89 qs,
90 }),
91 );
92 } catch (err) {
93 log.error("Energy", "Energy page error:", err);
94 sendError(res, 500, ERR.INTERNAL, err.message);
95 }
96});
97
98export default router;
99
Loading comments...