EXTENSION for TreeOS
energy
Every action in TreeOS costs energy. Creating a node costs 3. Writing a note costs 1 to 5 depending on length. Changing a status costs 1. Running an understanding pass, executing a script, completing a prestige version: each has a defined cost. Energy prevents runaway usage, establishes fair limits across tiers, and gives land operators a metering layer that works whether or not billing is installed. Each user has a daily energy budget that resets every 24 hours. The budget depends on their tier: Basic gets 350, Standard gets 1,500, Premium gets 8,000. The reset check runs lazily on profile load rather than on a cron schedule. If 24 hours have passed since the last reset, energy refills to the tier limit. If the user's paid plan has expired, they are automatically downgraded to Basic, their energy is reset to the Basic limit, and all LLM slot assignments on their trees and user profile are cleared. Energy deduction is atomic per action. The useEnergy function loads the user, checks the daily reset, validates tier restrictions (Basic users cannot upload files, Standard users have a 1 GB file size cap), calculates the cost, deducts from the daily pool first and the additional (purchased) pool second, then saves. If total available energy is below the cost, the action is rejected with an EnergyError. File uploads that fail the check have their temporary files cleaned up immediately. Cost calculation has three tiers. Fixed-cost actions (create, delete, status change, prestige, script execution) use a lookup table. Content actions (notes, raw ideas, script edits) scale with text length at 1 energy per 1,000 characters, capped between 1 and 5. File actions use a progressive rate: 1.5 energy per MB up to 100 MB, 3 energy per MB from 100 MB to 1 GB, and quadratic scaling beyond 1 GB. The extension registers four lifecycle hooks for automatic metering: beforeNote, beforeStatusChange, afterNodeCreate, and beforeNodeDelete. Other extensions that need energy metering declare energy as an optional service and call core.energy directly. Extensions can also register custom actions with registerAction, providing a cost function that receives the payload and returns the energy cost. If energy is not installed, core.energy is undefined and all metering is silently skipped.
v1.0.4 by TreeOS Site 0 downloads 5 files 2,239 lines 66.9 KB published 38d ago
treeos ext install energy
View changelog

Manifest

Provides

  • routes
  • 1 CLI commands

Requires

  • services: hooks
  • models: User, Node

Optional

  • extensions: html-rendering, treeos-base
SHA256: 1ad3426b2f8b629a0b2dd912b99ee08f46c67332d1f15dc4c8a6200abb19d9f5

CLI Commands

CommandMethodDescription
energyGETShow your energy balance and reset time

Hooks

Listens To

  • beforeNote
  • beforeStatusChange
  • afterNodeCreate
  • beforeNodeDelete

Source Code

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(&#39;terms&#39;)">Terms of Service</span>' +
1437      ' and ' +
1438      '<span class="checkout-legal-link" onclick="openModal(&#39;privacy&#39;)">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

Versions

Version Published Downloads
1.0.4 38d ago 0
1.0.3 47d ago 0
1.0.2 47d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star energy

Comments

Loading comments...

Post comments from the CLI: treeos ext comment energy "your comment"
Max 3 comments per extension. One star and one flag per user.