EXTENSION for TreeOS
user-queries
Cross-tree queries scoped to a single user. The kernel stores notes, contributions, and chats per node, but users need to see their own activity across all trees in one place. This extension provides three endpoints: /user/:userId/notes returns all notes the user has written across every tree they contribute to, with full-text search, date filtering, and a 200-note cap per request. /user/:userId/contributions returns the user's audit trail across all trees with the same filtering. /user/:userId/chats returns conversation history grouped by session (up to 10 sessions per request), filterable by session ID or date range. All three endpoints support HTML rendering when the html-rendering extension is installed. Notes render with edit and delete controls, file notes show download links, and every entry links back to its source node and version. The extension reads from kernel data (notes.js, contributions.js, chatHistory.js) and adds no models of its own. Pure query layer. No writes. No hooks. No tools. Just the ability to ask 'what have I done' across the entire land.
v1.0.1 by TreeOS Site 0 downloads 6 files 1,885 lines 65.2 KB published 38d ago
treeos ext install user-queries
View changelog

Manifest

Provides

  • routes
  • 1 CLI commands

Requires

  • models: User, Node

Optional

  • extensions: html-rendering, treeos-base
SHA256: 80e11441aa4c46cb88499792b7476b4df777c7953b561d53034d226f82af2c01

CLI Commands

CommandMethodDescription
contributionsGETList your contributions across all trees

Source Code

1import createRouter from "./routes.js";
2import { getExtension } from "../loader.js";
3
4export async function init(core) {
5  // Register quick links on the user profile page
6  const treeos = getExtension("treeos-base");
7  if (treeos?.exports?.registerSlot) {
8    const { registerSlot } = treeos.exports;
9    registerSlot("user-quick-links", "user-queries", ({ userId, queryString }) =>
10      `<li><a href="/api/v1/user/${userId}/notes${queryString}">Notes</a></li>
11       <li><a href="/api/v1/user/${userId}/chats${queryString}">AI Chats</a></li>
12       <li><a href="/api/v1/user/${userId}/contributions${queryString}">Contributions</a></li>
13       <li><a href="/api/v1/user/${userId}/tags${queryString}">Mail</a></li>`,
14      { priority: 20 }
15    );
16    registerSlot("tree-quick-links", "user-queries", ({ rootId, queryString }) =>
17      `<a href="/api/v1/root/${rootId}/chats${queryString}" class="back-link">AI Chats</a>`,
18      { priority: 40 }
19    );
20  }
21
22  return {
23    router: createRouter(core),
24  };
25}
26
1export default {
2  name: "user-queries",
3  version: "1.0.1",
4  builtFor: "TreeOS",
5  description:
6    "Cross-tree queries scoped to a single user. The kernel stores notes, contributions, " +
7    "and chats per node, but users need to see their own activity across all trees in one " +
8    "place. This extension provides three endpoints: /user/:userId/notes returns all notes " +
9    "the user has written across every tree they contribute to, with full-text search, date " +
10    "filtering, and a 200-note cap per request. /user/:userId/contributions returns the " +
11    "user's audit trail across all trees with the same filtering. /user/:userId/chats " +
12    "returns conversation history grouped by session (up to 10 sessions per request), " +
13    "filterable by session ID or date range.\n\n" +
14    "All three endpoints support HTML rendering when the html-rendering extension is " +
15    "installed. Notes render with edit and delete controls, file notes show download links, " +
16    "and every entry links back to its source node and version. The extension reads from " +
17    "kernel data (notes.js, contributions.js, chatHistory.js) and adds no models of its " +
18    "own. Pure query layer. No writes. No hooks. No tools. Just the ability to ask " +
19    "'what have I done' across the entire land.",
20
21  needs: {
22    models: ["User", "Node"],
23  },
24
25  optional: {
26    extensions: ["html-rendering", "treeos-base"],
27  },
28
29  provides: {
30    models: {},
31    routes: "./routes.js",
32    tools: false,
33    jobs: false,
34    orchestrator: false,
35    energyActions: {},
36    sessionTypes: {},
37    cli: [
38      { command: "contributions", scope: ["tree", "home"], description: "List your contributions across all trees", method: "GET", endpoint: "/user/:userId/contributions" },
39    ],
40  },
41};
42
1import { page } from "../../html-rendering/html/layout.js";
2import {
3  esc,
4  truncate,
5  formatTime,
6  formatDuration,
7  actionColorHex,
8  actionLabel,
9  groupIntoChains,
10  modeLabel,
11  sourceLabel,
12} from "../../html-rendering/html/utils.js";
13
14export function renderChats({ userId, chats, sessions, username, token, sessionId }) {
15  const tokenQS = token ? `?token=${encodeURIComponent(token)}&html` : `?html`;
16
17  const linkifyNodeIds = (html) =>
18    html.replace(
19      /Placed on node ([0-9a-f-]{36})/g,
20      (_, id) =>
21        `Placed on node <a class="node-link" href="/api/v1/root/${id}${token ? `?token=${encodeURIComponent(token)}&html` : "?html"}">${id}</a>`,
22    );
23
24  const formatContent = (str) => {
25    if (!str) return "";
26    const s = String(str).trim();
27    if (
28      (s.startsWith("{") && s.endsWith("}")) ||
29      (s.startsWith("[") && s.endsWith("]"))
30    ) {
31      try {
32        const parsed = JSON.parse(s);
33        const pretty = JSON.stringify(parsed, null, 2);
34        return `<span class="chain-json">${esc(pretty)}</span>`;
35      } catch (_) {}
36    }
37    return esc(s);
38  };
39
40    // ── Tree context helpers ───────────────────────────────
41
42    const renderTreeContext = (tc) => {
43      if (!tc) return "";
44      const parts = [];
45
46      const nodeId = tc.targetNodeId?._id || tc.targetNodeId;
47      const nodeName = tc.targetNodeId?.name || tc.targetNodeName;
48      if (nodeId && nodeName && typeof nodeId === "string") {
49        parts.push(
50          `<a href="/api/v1/node/${nodeId}${tokenQS}" class="tree-target-link">\uD83C\uDFAF ${esc(nodeName)}</a>`,
51        );
52      } else if (nodeName) {
53        parts.push(`<span class="tree-target-name">\uD83C\uDFAF ${esc(nodeName)}</span>`);
54      } else if (tc.targetPath) {
55        const pathParts = tc.targetPath.split(" / ");
56        const last = pathParts[pathParts.length - 1];
57        parts.push(`<span class="tree-target-name">\uD83C\uDFAF ${esc(last)}</span>`);
58      }
59
60      if (tc.planStepIndex != null && tc.planTotalSteps != null) {
61        parts.push(
62          `<span class="badge badge-step">${tc.planStepIndex}/${tc.planTotalSteps}</span>`,
63        );
64      }
65
66      if (tc.stepResult) {
67        const resultClasses = {
68          success: "badge-done",
69          failed: "badge-stopped",
70          skipped: "badge-skipped",
71          pending: "badge-pending",
72        };
73        const resultIcons = {
74          success: "\u2713",
75          failed: "\u2717",
76          skipped: "\u2298",
77          pending: "\u23F3",
78        };
79        parts.push(
80          `<span class="badge ${resultClasses[tc.stepResult] || "badge-pending"}">${resultIcons[tc.stepResult] || ""} ${tc.stepResult}</span>`,
81        );
82      }
83
84      if (parts.length === 0) return "";
85      return `<div class="tree-context-bar">${parts.join("")}</div>`;
86    };
87
88    const renderDirective = (tc) => {
89      if (!tc?.directive) return "";
90      return `<div class="tree-directive">${esc(tc.directive)}</div>`;
91    };
92
93    const getTargetName = (tc) => {
94      if (!tc) return null;
95      return tc.targetNodeId?.name || tc.targetNodeName || null;
96    };
97
98    const sessionGroups = sessions;
99
100    // ── Phase grouping ─────────────────────────────────────
101
102    const groupStepsIntoPhases = (steps) => {
103      const phases = [];
104      let currentPlan = null;
105      for (const step of steps) {
106        const mode = step.aiContext?.mode || "";
107        if (mode === "translator") {
108          currentPlan = null;
109          phases.push({ type: "translate", step });
110        } else if (mode.startsWith("tree:orchestrator:plan:")) {
111          currentPlan = { type: "plan", marker: step, substeps: [] };
112          phases.push(currentPlan);
113        } else if (mode === "tree:respond") {
114          currentPlan = null;
115          phases.push({ type: "respond", step });
116        } else if (currentPlan) {
117          currentPlan.substeps.push(step);
118        } else {
119          phases.push({ type: "step", step });
120        }
121      }
122      return phases;
123    };
124
125    // ── Model badge helper ─────────────────────────────────
126
127    const renderModelBadge = (chat) => {
128      const connName = chat.llmProvider?.connectionId?.name;
129      const model = connName || chat.llmProvider?.model;
130      if (!model) return "";
131      return `<span class="chain-model">${esc(model)}</span>`;
132    };
133
134    // ── Render substep ─────────────────────────────────────
135
136    const renderSubstep = (chat) => {
137      const duration = formatDuration(
138        chat.startMessage?.time,
139        chat.endMessage?.time,
140      );
141      const stopped = chat.endMessage?.stopped;
142      const tc = chat.treeContext;
143
144      const dotClass = stopped
145        ? "chain-dot-stopped"
146        : tc?.stepResult === "failed"
147          ? "chain-dot-stopped"
148          : tc?.stepResult === "skipped"
149            ? "chain-dot-skipped"
150            : chat.endMessage?.time
151              ? "chain-dot-done"
152              : "chain-dot-pending";
153
154      const targetName = getTargetName(tc);
155      const inputFull = formatContent(chat.startMessage?.content);
156      const outputFull = formatContent(chat.endMessage?.content);
157
158      return `
159      <details class="chain-substep">
160        <summary class="chain-substep-summary">
161          <span class="chain-dot ${dotClass}"></span>
162          <span class="chain-step-mode">${modeLabel(chat.aiContext?.mode)}</span>
163          ${targetName ? `<span class="chain-step-target">${esc(targetName)}</span>` : ""}
164          ${tc?.stepResult === "failed" ? `<span class="chain-step-failed">FAILED</span>` : ""}
165          ${tc?.resultDetail && tc.stepResult === "failed" ? `<span class="chain-step-fail-reason">${truncate(tc.resultDetail, 60)}</span>` : ""}
166          ${renderModelBadge(chat)}
167          ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
168        </summary>
169        <div class="chain-step-body">
170          ${renderTreeContext(tc)}
171          ${renderDirective(tc)}
172          <div class="chain-step-input"><span class="chain-io-label chain-io-in">IN</span>${inputFull}</div>
173          ${outputFull ? `<div class="chain-step-output"><span class="chain-io-label chain-io-out">OUT</span>${outputFull}</div>` : ""}
174        </div>
175      </details>`;
176    };
177
178    // ── Render phases ──────────────────────────────────────
179
180    const renderPhases = (steps) => {
181      const phases = groupStepsIntoPhases(steps);
182      if (phases.length === 0) return "";
183
184      const phaseHtml = phases
185        .map((phase) => {
186          if (phase.type === "translate") {
187            const s = phase.step;
188            const tc = s.treeContext;
189            const duration = formatDuration(
190              s.startMessage?.time,
191              s.endMessage?.time,
192            );
193            const outputFull = formatContent(s.endMessage?.content);
194            return `
195          <details class="chain-phase chain-phase-translate">
196            <summary class="chain-phase-summary">
197              <span class="chain-phase-icon">\uD83D\uDD04</span>
198              <span class="chain-phase-label">Translator</span>
199              ${tc?.planTotalSteps ? `<span class="chain-step-counter">${tc.planTotalSteps}-step plan</span>` : ""}
200              ${tc?.directive ? `<span class="chain-plan-summary-text">${truncate(tc.directive, 80)}</span>` : ""}
201              ${renderModelBadge(s)}
202              ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
203            </summary>
204            ${outputFull ? `<div class="chain-step-body"><div class="chain-step-output"><span class="chain-io-label chain-io-out">PLAN</span>${outputFull}</div></div>` : ""}
205          </details>`;
206          }
207
208          if (phase.type === "plan") {
209            const m = phase.marker;
210            const tc = m.treeContext;
211            const targetName = getTargetName(tc);
212            const hasSubsteps = phase.substeps.length > 0;
213
214            // Count results from substeps that have execution modes
215            const counts = { success: 0, failed: 0, skipped: 0 };
216            for (const sub of phase.substeps) {
217              const r = sub.treeContext?.stepResult;
218              if (r && counts[r] !== undefined) counts[r]++;
219            }
220            const countBadges = [
221              counts.success > 0
222                ? `<span class="badge badge-done">${counts.success} \u2713</span>`
223                : "",
224              counts.failed > 0
225                ? `<span class="badge badge-stopped">${counts.failed} \u2717</span>`
226                : "",
227              counts.skipped > 0
228                ? `<span class="badge badge-skipped">${counts.skipped} \u2298</span>`
229                : "",
230            ]
231              .filter(Boolean)
232              .join("");
233
234            // Use directive from treeContext if available, otherwise fall back to input
235            const directiveText = tc?.directive || "";
236            const inputFull = directiveText
237              ? esc(directiveText)
238              : formatContent(m.startMessage?.content);
239
240            return `
241          <div class="chain-phase chain-phase-plan">
242            <div class="chain-phase-header">
243              <span class="chain-phase-icon">\uD83D\uDCCB</span>
244              <span class="chain-phase-label">${modeLabel(m.aiContext?.mode)}</span>
245              ${targetName ? `<span class="chain-step-target">${esc(targetName)}</span>` : ""}
246              ${
247                tc?.planStepIndex != null && tc?.planTotalSteps != null
248                  ? `<span class="chain-step-counter">Step ${tc.planStepIndex} of ${tc.planTotalSteps}</span>`
249                  : ""
250              }
251              ${countBadges}
252              ${renderModelBadge(m)}
253            </div>
254            <div class="chain-plan-directive">${inputFull}</div>
255            ${hasSubsteps ? `<div class="chain-substeps">${phase.substeps.map(renderSubstep).join("")}</div>` : ""}
256          </div>`;
257          }
258
259          if (phase.type === "respond") {
260            const s = phase.step;
261            const tc = s.treeContext;
262            const duration = formatDuration(
263              s.startMessage?.time,
264              s.endMessage?.time,
265            );
266            const inputFull = formatContent(s.startMessage?.content);
267            const outputFull = formatContent(s.endMessage?.content);
268            return `
269          <details class="chain-phase chain-phase-respond">
270            <summary class="chain-phase-summary">
271              <span class="chain-phase-icon">\uD83D\uDCAC</span>
272              <span class="chain-phase-label">${modeLabel(s.aiContext?.mode)}</span>
273              ${renderModelBadge(s)}
274              ${duration ? `<span class="chain-step-duration">${duration}</span>` : ""}
275            </summary>
276            <div class="chain-step-body">
277              ${renderTreeContext(tc)}
278              ${inputFull ? `<div class="chain-step-input"><span class="chain-io-label chain-io-in">IN</span>${inputFull}</div>` : ""}
279              ${outputFull ? `<div class="chain-step-output"><span class="chain-io-label chain-io-out">OUT</span>${outputFull}</div>` : ""}
280            </div>
281          </details>`;
282          }
283
284          return renderSubstep(phase.step);
285        })
286        .join("");
287
288      const summaryParts = phases
289        .map((p) => {
290          if (p.type === "translate") {
291            const tc = p.step.treeContext;
292            return tc?.planTotalSteps ? `\uD83D\uDD04 ${tc.planTotalSteps}-step` : "\uD83D\uDD04";
293          }
294          if (p.type === "plan") {
295            const tc = p.marker.treeContext;
296            const targetName = getTargetName(tc);
297            const sub = p.substeps
298              .map((s) => {
299                const stc = s.treeContext;
300                const icon =
301                  stc?.stepResult === "failed"
302                    ? "\u274C "
303                    : stc?.stepResult === "skipped"
304                      ? "\u2298 "
305                      : stc?.stepResult === "success"
306                        ? "\u2713 "
307                        : "";
308                return `${icon}${modeLabel(s.aiContext?.mode)}`;
309              })
310              .join(" \u2192 ");
311            const label = targetName ? `\uD83D\uDCCB ${esc(targetName)}` : "\uD83D\uDCCB";
312            return sub ? `${label}: ${sub}` : label;
313          }
314          if (p.type === "respond") return "\uD83D\uDCAC";
315          return modeLabel(p.step?.aiContext?.mode);
316        })
317        .join("  ");
318
319      return `
320      <details class="chain-dropdown">
321        <summary class="chain-summary">
322          ${phases.length} phase${phases.length !== 1 ? "s" : ""}
323          <span class="chain-modes">${summaryParts}</span>
324        </summary>
325        <div class="chain-phases">${phaseHtml}</div>
326      </details>`;
327    };
328
329    // ── Render chain ───────────────────────────────────────
330
331    const renderChain = (chain) => {
332      const chat = chain.root;
333      const steps = chain.steps;
334      const duration = formatDuration(
335        chat.startMessage?.time,
336        chat.endMessage?.time,
337      );
338      const stopped = chat.endMessage?.stopped;
339      const contribs = chat.contributions || [];
340      const hasContribs = contribs.length > 0;
341      const hasSteps = steps.length > 0;
342
343      const modelName =
344        chat.llmProvider?.connectionId?.name ||
345        chat.llmProvider?.model ||
346        "unknown";
347
348      const tc = chat.treeContext;
349      const treeNodeId = tc?.targetNodeId?._id || tc?.targetNodeId;
350      const treeNodeName = tc?.targetNodeId?.name || tc?.targetNodeName;
351      const treeLink =
352        treeNodeId && treeNodeName
353          ? `<a href="/api/v1/node/${treeNodeId}${tokenQS}" class="tree-target-link">\uD83C\uDF33 ${esc(treeNodeName)}</a>`
354          : treeNodeName
355            ? `<span class="tree-target-name">\uD83C\uDF33 ${esc(treeNodeName)}</span>`
356            : "";
357
358      const statusBadge = stopped
359        ? `<span class="badge badge-stopped">Stopped</span>`
360        : chat.endMessage?.time
361          ? `<span class="badge badge-done">Done</span>`
362          : `<span class="badge badge-pending">Pending</span>`;
363
364      const endContent = chat.endMessage?.content || "";
365      const wasNoLlm = endContent.startsWith("No LLM connection");
366      const wasError = endContent.startsWith("Error:");
367      const chatEnergyUsed =
368        wasError || wasNoLlm || chat.llmProvider?.isCustom === false ? 2 : 0;
369      const energyBadge =
370        chatEnergyUsed > 0
371          ? `<span class="badge badge-energy">\u26A1${chatEnergyUsed}</span>`
372          : "";
373
374      const contribRows = contribs
375        .map((c) => {
376          const nId = c.nodeId?._id || c.nodeId;
377          const nName = c.nodeId?.name || nId || "\u2014";
378          const nodeRef = nId
379            ? `<a href="/api/v1/node/${nId}${tokenQS}">${esc(nName)}</a>`
380            : `<span style="opacity:0.5">\u2014</span>`;
381          const aiBadge = c.wasAi
382            ? `<span class="mini-badge mini-ai">AI</span>`
383            : "";
384          const cEnergyBadge =
385            c.energyUsed > 0
386              ? `<span class="mini-badge mini-energy">\u26A1${c.energyUsed}</span>`
387              : "";
388          const understandingLink =
389            c.action === "understanding" &&
390            c.understandingMeta?.understandingRunId &&
391            c.understandingMeta?.rootNodeId
392              ? ` <a class="understanding-link" href="/api/v1/root/${c.understandingMeta.rootNodeId}/understandings/run/${c.understandingMeta.understandingRunId}${tokenQS}">\uD83E\uDDE0 View run \u2192</a>`
393              : "";
394          const color = actionColorHex(c.action);
395          return `
396        <tr class="contrib-row">
397          <td><span class="action-dot" style="background:${color}"></span>${esc(actionLabel(c.action))}${understandingLink}</td>
398          <td>${nodeRef}</td>
399          <td>${aiBadge}${cEnergyBadge}</td>
400          <td class="contrib-time">${formatTime(c.date)}</td>
401        </tr>`;
402        })
403        .join("");
404
405      const stepsHtml = hasSteps ? renderPhases(steps) : "";
406
407      return `
408      <li class="note-card">
409        <div class="chat-header">
410          <div class="chat-header-left">
411            <span class="chat-mode">${modeLabel(chat.aiContext?.mode)}</span>
412            ${treeLink}
413            <span class="chat-model">${esc(modelName)}</span>
414          </div>
415          <div class="chat-badges">
416            ${energyBadge}
417            ${statusBadge}
418            ${duration ? `<span class="badge badge-duration">${duration}</span>` : ""}
419            <span class="badge badge-source">${sourceLabel(chat.startMessage?.source)}</span>
420          </div>
421        </div>
422
423        <div class="note-content">
424          <div class="chat-message chat-user">
425            <span class="msg-label">You</span>
426            <div class="msg-text msg-clamp">${esc(chat.startMessage?.content || "")}</div>
427            ${(chat.startMessage?.content || "").length > 300 ? `<button class="expand-btn" onclick="toggleExpand(this)">Show more</button>` : ""}
428          </div>
429          ${
430            chat.endMessage?.content
431              ? `
432          <div class="chat-message chat-ai">
433            <span class="msg-label">AI</span>
434            <div class="msg-text msg-clamp">${linkifyNodeIds(esc(chat.endMessage.content))}</div>
435            ${chat.endMessage.content.length > 300 ? `<button class="expand-btn" onclick="toggleExpand(this)">Show more</button>` : ""}
436          </div>`
437              : ""
438          }
439        </div>
440
441        ${stepsHtml}
442
443        ${
444          hasContribs
445            ? `
446        <details class="contrib-dropdown">
447          <summary class="contrib-summary">
448            ${contribs.length} contribution${contribs.length !== 1 ? "s" : ""} during this chat
449          </summary>
450          <div class="contrib-table-wrap">
451            <table class="contrib-table">
452              <thead><tr><th>Action</th><th>Node</th><th></th><th>Time</th></tr></thead>
453              <tbody>${contribRows}</tbody>
454            </table>
455          </div>
456        </details>`
457            : ""
458        }
459
460        <div class="note-meta">
461          ${formatTime(chat.startMessage?.time)}
462          <span class="meta-separator">\u00B7</span>
463          <code class="contribution-id">${esc(chat._id)}</code>
464        </div>
465      </li>`;
466    };
467
468    const renderedSections = sessionGroups
469      .map((group) => {
470        const chatCount = group.chatCount;
471        const sessionTime = formatTime(group.startTime);
472        const shortId = group.sessionId.slice(0, 8);
473        const chains = groupIntoChains(group.chats);
474        const chatCards = chains.map(renderChain).join("");
475
476        return `
477      <div class="session-group">
478        <div class="session-pane">
479          <div class="session-pane-header">
480            <div class="session-header-left">
481              <span class="session-id">${esc(shortId)}</span>
482              <span class="session-info">${chatCount} chat${chatCount !== 1 ? "s" : ""}</span>
483            </div>
484            <span class="session-time">${sessionTime}</span>
485          </div>
486          <ul class="notes-list">${chatCards}</ul>
487        </div>
488      </div>`;
489      })
490      .join("");
491
492  const css = `
493
494/* ── Session Pane ───────────────────────────────── */
495.session-group { margin-bottom: 20px; animation: fadeInUp 0.6s ease-out both; }
496.session-pane {
497  background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
498  border-radius: 20px; overflow: hidden; backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
499}
500.session-pane-header {
501  display: flex; align-items: center; justify-content: space-between; padding: 14px 20px;
502  background: rgba(255,255,255,0.08); border-bottom: 1px solid rgba(255,255,255,0.1);
503}
504.session-header-left { display: flex; align-items: center; gap: 10px; }
505.session-id {
506  font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; font-weight: 600;
507  color: rgba(255,255,255,0.55); background: rgba(255,255,255,0.1); padding: 3px 8px;
508  border-radius: 6px; border: 1px solid rgba(255,255,255,0.12);
509}
510.session-info { font-size: 13px; color: rgba(255,255,255,0.7); font-weight: 600; }
511.session-time { font-size: 12px; color: rgba(255,255,255,0.4); font-weight: 500; }
512
513/* ── Chat Header ────────────────────────────────── */
514.chat-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
515.chat-header-left { display: flex; align-items: center; gap: 8px; }
516.chat-mode {
517  font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.1);
518  padding: 3px 10px; border-radius: 980px; border: 1px solid rgba(255,255,255,0.15);
519}
520.chat-model {
521  font-size: 11px; font-weight: 500; color: rgba(255,255,255,0.45);
522  font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden;
523  text-overflow: ellipsis; white-space: nowrap; max-width: 200px;
524}
525.chat-badges { display: flex; flex-wrap: wrap; gap: 6px; }
526
527.chat-message { display: flex; gap: 10px; align-items: flex-start; }
528.msg-label {
529  flex-shrink: 0; font-weight: 700; font-size: 10px; text-transform: uppercase;
530  letter-spacing: 0.5px; padding: 3px 10px; border-radius: 980px; margin-top: 3px;
531}
532.chat-user .msg-label { background: rgba(255,255,255,0.2); color: white; }
533.chat-ai .msg-label   { background: rgba(100,220,255,0.25); color: white; }
534.msg-text { color: rgba(255,255,255,0.95); word-wrap: break-word; min-width: 0; font-size: 15px; line-height: 1.65; font-weight: 400; }
535.msg-clamp {
536  display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical;
537  overflow: hidden; max-height: calc(1.65em * 4);
538  transition: max-height 0.3s ease;
539}
540.msg-clamp.expanded { -webkit-line-clamp: unset; max-height: none; overflow: visible; }
541.expand-btn {
542  background: none; border: none; color: rgba(100,220,255,0.9); cursor: pointer;
543  font-size: 12px; font-weight: 600; padding: 2px 0; margin-top: 2px;
544  transition: color 0.2s;
545}
546.expand-btn:hover { color: rgba(100,220,255,1); text-decoration: underline; }
547.node-link { color: #7effc0; text-decoration: none; background: rgba(50,220,120,0.15); padding: 1px 6px; border-radius: 4px; font-family: monospace; font-size: 13px; }
548.node-link:hover { background: rgba(50,220,120,0.3); }
549.understanding-link {
550  color: rgba(100,100,210,0.9); text-decoration: none; font-size: 11px; font-weight: 500;
551  margin-left: 4px; transition: color 0.2s;
552}
553.understanding-link:hover { color: rgba(130,130,255,1); text-decoration: underline; }
554.chat-user .msg-text { font-weight: 500; }
555
556/* ── Chain: outer dropdown ──────────────────────── */
557.chain-dropdown { margin-bottom: 12px; }
558.chain-summary {
559  cursor: pointer; font-size: 13px; font-weight: 600;
560  color: rgba(255,255,255,0.85); padding: 8px 14px;
561  background: rgba(255,255,255,0.1); border-radius: 10px;
562  border: 1px solid rgba(255,255,255,0.15);
563  transition: all 0.2s; list-style: none;
564  display: flex; align-items: center; gap: 8px;
565}
566.chain-summary::-webkit-details-marker { display: none; }
567.chain-summary::before { content: "\u25B6"; font-size: 10px; transition: transform 0.15s; display: inline-block; }
568details[open] > .chain-summary::before { transform: rotate(90deg); }
569.chain-summary:hover { background: rgba(255,255,255,0.18); }
570.chain-modes { font-size: 11px; color: rgba(255,255,255,0.5); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
571.chain-phases { margin-top: 12px; display: flex; flex-direction: column; gap: 12px; }
572
573/* ── Chain: phase containers ────────────────────── */
574.chain-phase { border-radius: 10px; overflow: hidden; }
575.chain-phase-header {
576  display: flex; align-items: center; gap: 8px; padding: 8px 12px; font-size: 12px; font-weight: 600;
577  flex-wrap: wrap;
578}
579.chain-phase-icon { font-size: 14px; }
580.chain-phase-label { color: rgba(255,255,255,0.85); }
581.chain-phase-translate { background: rgba(100,100,220,0.12); border: 1px solid rgba(100,100,220,0.2); }
582.chain-phase-plan { background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); }
583.chain-phase-respond { background: rgba(72,187,120,0.1); border: 1px solid rgba(72,187,120,0.2); }
584.chain-plan-directive { padding: 6px 12px 10px; font-size: 12px; color: rgba(255,255,255,0.6); line-height: 1.5; white-space: pre-wrap; }
585
586/* ── Chain: clickable summary for translate/substep details ── */
587.chain-phase-summary, .chain-substep-summary {
588  cursor: pointer; list-style: none;
589  display: flex; align-items: center; gap: 8px;
590  padding: 8px 12px;
591  font-size: 12px; font-weight: 600;
592  flex-wrap: wrap;
593}
594.chain-phase-summary::-webkit-details-marker,
595.chain-substep-summary::-webkit-details-marker { display: none; }
596.chain-phase-summary::before,
597.chain-substep-summary::before {
598  content: "\u25B6"; font-size: 8px; color: rgba(255,255,255,0.35);
599  transition: transform 0.15s; display: inline-block;
600}
601details[open] > .chain-phase-summary::before,
602details[open] > .chain-substep-summary::before { transform: rotate(90deg); }
603.chain-phase-summary:hover, .chain-substep-summary:hover { background: rgba(255,255,255,0.05); }
604
605/* ── Chain: substeps inside plan ────────────────── */
606.chain-substeps { display: flex; flex-direction: column; gap: 2px; padding: 0 8px 8px; }
607.chain-substep { border-radius: 6px; background: rgba(255,255,255,0.04); }
608.chain-substep:hover { background: rgba(255,255,255,0.07); }
609
610/* ── Chain: status dot ──────────────────────────── */
611.chain-dot {
612  display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
613  border: 2px solid rgba(255,255,255,0.3);
614}
615.chain-dot-done    { background: rgba(72,187,120,0.8); border-color: rgba(72,187,120,0.4); }
616.chain-dot-stopped { background: rgba(200,80,80,0.8); border-color: rgba(200,80,80,0.4); }
617.chain-dot-pending { background: rgba(255,200,50,0.8); border-color: rgba(255,200,50,0.4); }
618.chain-dot-skipped { background: rgba(160,160,160,0.6); border-color: rgba(160,160,160,0.3); }
619
620.chain-step-mode {
621  font-size: 11px; font-weight: 600; color: rgba(255,255,255,0.8);
622  background: rgba(255,255,255,0.12); padding: 2px 8px; border-radius: 6px;
623}
624.chain-step-duration { font-size: 10px; color: rgba(255,255,255,0.45); }
625.chain-model {
626  font-size: 10px; font-family: 'SF Mono', 'Fira Code', monospace;
627  color: rgba(255,255,255,0.4); margin-left: auto; white-space: nowrap;
628  overflow: hidden; text-overflow: ellipsis; max-width: 150px;
629}
630
631/* ── Chain: expanded body ───────────────────────── */
632.chain-step-body { padding: 10px 12px; border-top: 1px solid rgba(255,255,255,0.08); }
633
634.chain-io-label {
635  display: inline-block; font-size: 9px; font-weight: 700; letter-spacing: 0.5px;
636  padding: 1px 6px; border-radius: 4px; margin-right: 8px; vertical-align: middle;
637}
638.chain-io-in  { background: rgba(100,220,255,0.2); color: rgba(100,220,255,0.9); }
639.chain-io-out { background: rgba(72,187,120,0.2); color: rgba(72,187,120,0.9); }
640
641.chain-step-input {
642  font-size: 12px; color: rgba(255,255,255,0.8); line-height: 1.6;
643  word-break: break-word; white-space: pre-wrap;
644  font-family: 'SF Mono', 'Fira Code', monospace;
645}
646.chain-step-output {
647  font-size: 12px; color: rgba(255,255,255,0.65); line-height: 1.6;
648  margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.1);
649  word-break: break-word; white-space: pre-wrap;
650  font-family: 'SF Mono', 'Fira Code', monospace;
651}
652.chain-json { color: rgba(255,255,255,0.8); }
653
654/* ── Tree Context ───────────────────────────────── */
655.tree-context-bar {
656  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
657  padding: 6px 12px; margin-bottom: 6px;
658  background: rgba(255,255,255,0.06); border-radius: 6px;
659  font-size: 12px;
660}
661.tree-target-link {
662  color: rgba(100,220,255,0.95); text-decoration: none;
663  border-bottom: 1px solid rgba(100,220,255,0.3);
664  font-weight: 600; font-size: 12px;
665  transition: all 0.2s;
666}
667.tree-target-link:hover {
668  border-bottom-color: rgba(100,220,255,0.8);
669  text-shadow: 0 0 8px rgba(100,220,255,0.5);
670}
671.tree-target-name {
672  color: rgba(255,255,255,0.8); font-weight: 600; font-size: 12px;
673}
674.tree-directive {
675  padding: 4px 12px 8px; font-size: 11px; color: rgba(255,255,255,0.55);
676  line-height: 1.5; font-style: italic;
677  border-left: 2px solid rgba(255,255,255,0.15);
678  margin: 0 12px 8px;
679}
680.chain-step-counter {
681  font-size: 10px; color: rgba(255,255,255,0.5); font-weight: 500;
682  background: rgba(255,255,255,0.08); padding: 2px 8px; border-radius: 4px;
683}
684.chain-step-target {
685  font-size: 10px; color: rgba(100,220,255,0.7); font-weight: 500;
686  max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
687}
688.chain-step-failed {
689  font-size: 9px; font-weight: 700; color: rgba(200,80,80,0.9);
690  background: rgba(200,80,80,0.15); padding: 1px 6px; border-radius: 4px;
691  letter-spacing: 0.5px;
692}
693.chain-step-fail-reason {
694  font-size: 10px; color: rgba(200,80,80,0.7); font-weight: 400;
695  font-style: italic; max-width: 200px; overflow: hidden;
696  text-overflow: ellipsis; white-space: nowrap;
697}
698.badge-step {
699  background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7);
700  font-family: 'SF Mono', 'Fira Code', monospace; font-size: 10px;
701}
702.badge-skipped {
703  background: rgba(160,160,160,0.25); color: rgba(255,255,255,0.7);
704}
705.chain-plan-summary-text {
706  font-size: 11px; color: rgba(255,255,255,0.45); font-weight: 400;
707  font-style: italic; overflow: hidden; text-overflow: ellipsis;
708  white-space: nowrap; max-width: 300px;
709}
710
711/* ── Contribution Dropdown ──────────────────────── */
712.contrib-dropdown { margin-bottom: 12px; }
713.contrib-summary {
714  cursor: pointer; font-size: 13px; font-weight: 600;
715  color: rgba(255,255,255,0.85); padding: 8px 14px;
716  background: rgba(255,255,255,0.1); border-radius: 10px;
717  border: 1px solid rgba(255,255,255,0.15);
718  transition: all 0.2s; list-style: none;
719  display: flex; align-items: center; gap: 6px;
720}
721.contrib-summary::-webkit-details-marker { display: none; }
722.contrib-summary::before { content: "\u25B6"; font-size: 10px; transition: transform 0.2s; display: inline-block; }
723details[open] .contrib-summary::before { transform: rotate(90deg); }
724.contrib-summary:hover { background: rgba(255,255,255,0.18); }
725.contrib-table-wrap { margin-top: 10px; overflow-x: auto; -webkit-overflow-scrolling: touch; }
726.contrib-table { width: 100%; border-collapse: collapse; font-size: 13px; }
727.contrib-table thead th {
728  text-align: left; font-size: 11px; font-weight: 600; text-transform: uppercase;
729  letter-spacing: 0.5px; color: rgba(255,255,255,0.55); padding: 6px 10px;
730  border-bottom: 1px solid rgba(255,255,255,0.15);
731}
732.contrib-row td {
733  padding: 7px 10px; border-bottom: 1px solid rgba(255,255,255,0.08);
734  color: rgba(255,255,255,0.88); vertical-align: middle; white-space: nowrap;
735}
736.contrib-row:last-child td { border-bottom: none; }
737.contrib-row a { color: white; text-decoration: none; border-bottom: 1px solid rgba(255,255,255,0.3); transition: all 0.2s; }
738.contrib-row a:hover { border-bottom-color: white; text-shadow: 0 0 12px rgba(255,255,255,0.8); }
739.contrib-time { font-size: 11px; color: rgba(255,255,255,0.5); }
740.action-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
741
742/* ── Mini Badges ────────────────────────────────── */
743.mini-badge {
744  display: inline-flex; align-items: center; padding: 1px 7px; border-radius: 980px;
745  font-size: 10px; font-weight: 700; letter-spacing: 0.2px; margin-right: 3px;
746}
747.mini-ai    { background: rgba(255,200,50,0.35); color: #fff; }
748.mini-energy { background: rgba(100,220,255,0.3); color: #fff; }
749
750/* ── Badges ─────────────────────────────────────── */
751.badge {
752  display: inline-flex; align-items: center; padding: 3px 10px; border-radius: 980px;
753  font-size: 11px; font-weight: 700; letter-spacing: 0.3px; border: 1px solid rgba(255,255,255,0.2);
754}
755.badge-done     { background: rgba(72,187,120,0.35); color: #fff; }
756.badge-stopped  { background: rgba(200,80,80,0.35); color: #fff; }
757.badge-pending  { background: rgba(255,200,50,0.3); color: #fff; }
758.badge-duration { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.9); }
759.badge-source   { background: rgba(100,100,210,0.3); color: #fff; }
760.badge-energy   { background: rgba(100,220,255,0.25); color: #fff; border-color: rgba(100,220,255,0.3); }
761
762
763.contribution-id {
764  background: rgba(255,255,255,0.12); padding: 2px 6px; border-radius: 4px;
765  font-size: 11px; font-family: 'SF Mono', 'Fira Code', monospace;
766  color: rgba(255,255,255,0.6); border: 1px solid rgba(255,255,255,0.1);
767}
768
769/* ── Responsive ─────────────────────────────────── */
770`;
771
772  const body = `
773  <div class="container">
774    <div class="back-nav">
775      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">\u2190 Back to Profile</a>
776    </div>
777
778    <div class="header">
779      <h1>
780        AI Chats for
781        <a href="/api/v1/user/${userId}${tokenQS}">@${esc(username)}</a>
782        ${chats.length > 0 ? `<span class="message-count">${chats.length}</span>` : ""}
783      </h1>
784      <div class="header-subtitle">Last 10 AI conversation sessions. Phases = thought process. Contributions = actions made on tree during conversation.</div>
785    </div>
786
787    ${
788      sessionGroups.length
789        ? renderedSections
790        : `
791    <div class="empty-state">
792      <div class="empty-state-icon">\uD83D\uDCAC</div>
793      <div class="empty-state-text">No AI chats yet</div>
794      <div class="empty-state-subtext">AI conversations and their actions will appear here</div>
795    </div>`
796    }
797  </div>`;
798
799  const js = `
800    var observer = new IntersectionObserver(function(entries) {
801      entries.forEach(function(entry, index) {
802        if (entry.isIntersecting) {
803          setTimeout(function() { entry.target.classList.add('visible'); }, index * 50);
804          observer.unobserve(entry.target);
805        }
806      });
807    }, { root: null, rootMargin: '50px', threshold: 0.1 });
808    document.querySelectorAll('.note-card').forEach(function(card) { observer.observe(card); });
809
810    function toggleExpand(btn) {
811      var text = btn.previousElementSibling;
812      if (!text) return;
813      var expanded = text.classList.toggle('expanded');
814      btn.textContent = expanded ? 'Show less' : 'Show more';
815    }`;
816
817  return page({
818    title: `${esc(username)} \u2014 AI Chats`,
819    css,
820    body,
821    js,
822  });
823}
824
1import { page } from "../../html-rendering/html/layout.js";
2import { esc, escapeHtml, actionColorClass } from "../../html-rendering/html/utils.js";
3
4export async function renderUserContributions({ userId, contributions, username, getNodeName, token }) {
5  const tokenQS = token ? `?token=${encodeURIComponent(token)}&html` : `?html`;
6
7  const link = (id, label) =>
8    id
9      ? `<a href="/api/v1/node/${id}${tokenQS}">${label || `<code>${esc(id)}</code>`}</a>`
10      : `<code>unknown</code>`;
11
12  const nodeLink = (id, name, version) => {
13    if (!id) return `<code>unknown node</code>`;
14    const v = version != null ? `/${version}` : "";
15    const display = name || id;
16    return `<a href="/api/v1/node/${id}${v}${tokenQS}"><code>${esc(display)}</code></a>`;
17  };
18
19  const userTag = (u) => {
20    if (!u) return `<code>unknown user</code>`;
21    if (typeof u === "object" && u.username)
22      return `<a href="/api/v1/user/${u._id}${tokenQS}"><code>${esc(u.username)}</code></a>`;
23    if (typeof u === "string")
24      return `<a href="/api/v1/user/${u}${tokenQS}"><code>${esc(u)}</code></a>`;
25    return `<code>unknown user</code>`;
26  };
27
28  const kvMap = (data) => {
29    if (!data) return "";
30    const entries =
31      data instanceof Map
32        ? [...data.entries()]
33        : typeof data === "object"
34          ? Object.entries(data)
35          : [];
36    if (entries.length === 0) return "";
37    return entries
38      .map(
39        ([k, v]) =>
40          `<span class="kv-chip"><code>${esc(k)}</code> ${esc(String(v))}</span>`,
41      )
42      .join(" ");
43  };
44
45    /* ─────────────────────────────────────────────── */
46    /* ACTION RENDERER                                  */
47    /* ─────────────────────────────────────────────── */
48
49    const renderAction = (rawC, nodeName) => {
50      // Merge extensionData into contribution so action renderers work
51      const c = rawC.extensionData ? { ...rawC, ...rawC.extensionData } : rawC;
52      const nId = c.nodeId?._id || c.nodeId;
53      const v = Number(c.nodeVersion ?? 0);
54      const nLink = nodeLink(nId, nodeName, v);
55
56      switch (c.action) {
57        case "create":
58          return `Created ${nLink}`;
59
60        case "editStatus":
61          return `Marked ${nLink} as <code>${esc(c.statusEdited)}</code>`;
62
63        case "editValue":
64          return `Adjusted values on ${nLink} ${kvMap(c.valueEdited)}`;
65
66        case "prestige":
67          return `Prestiged ${nLink} to a new version`;
68
69        case "trade":
70          return `Traded on ${nLink}`;
71
72        case "delete":
73          return `Deleted ${nLink}`;
74
75        case "invite": {
76          const ia = c.inviteAction || {};
77          const target = userTag(ia.receivingId);
78          const labels = {
79            invite: `Invited ${target} to collaborate on`,
80            acceptInvite: `Accepted an invitation on`,
81            denyInvite: `Declined an invitation on`,
82            removeContributor: `Removed ${target} from`,
83            switchOwner: `Transferred ownership of`,
84          };
85          const suffix = ia.action === "switchOwner" ? ` to ${target}` : "";
86          return `${labels[ia.action] || "Updated collaboration on"} ${nLink}${suffix}`;
87        }
88
89        case "editSchedule": {
90          const s = c.scheduleEdited || {};
91          const parts = [];
92          if (s.date)
93            parts.push(
94              `date to <code>${new Date(s.date).toLocaleString()}</code>`,
95            );
96          if (s.reeffectTime != null)
97            parts.push(`re-effect to <code>${s.reeffectTime}</code>`);
98          return parts.length
99            ? `Set ${parts.join(" and ")} on ${nLink}`
100            : `Updated the schedule on ${nLink}`;
101        }
102
103        case "editGoal":
104          return `Set new goals on ${nLink} ${kvMap(c.goalEdited)}`;
105
106        case "transaction": {
107          const tm = c.transactionMeta;
108          if (!tm) return `Recorded a transaction on ${nLink}`;
109          const eventLabel = esc(tm.event || "unknown").replace(/_/g, " ");
110          const counterparty = tm.counterpartyNodeId
111            ? ` with ${link(tm.counterpartyNodeId)}`
112            : "";
113          const sent = kvMap(tm.valuesSent);
114          const recv = kvMap(tm.valuesReceived);
115          let flow = "";
116          if (sent) flow += ` \u2014 sent ${sent}`;
117          if (recv) flow += `${sent ? "," : " \u2014"} received ${recv}`;
118          return `Transaction <code>${eventLabel}</code> as ${esc(tm.role)} (side ${esc(tm.side)}) on ${nLink}${counterparty}${flow}`;
119        }
120
121        case "note": {
122          const na = c.noteAction || {};
123
124          let verb;
125          switch (na.action) {
126            case "add":
127              verb = "Added a note to";
128              break;
129            case "edit":
130              verb = "Edited a note in";
131              break;
132            case "remove":
133              verb = "Removed a note from";
134              break;
135            default:
136              verb = "Updated a note in";
137          }
138
139          const noteRef = na.noteId
140            ? ` <a href="/api/v1/node/${nId}/${v}/notes/${na.noteId}${tokenQS}"><code>${esc(na.noteId)}</code></a>`
141            : "";
142
143          return `${verb} ${nLink}${noteRef}`;
144        }
145
146        case "updateParent": {
147          const up = c.updateParent || {};
148          const from = up.oldParentId
149            ? link(up.oldParentId)
150            : `<code>none</code>`;
151          const to = up.newParentId
152            ? link(up.newParentId)
153            : `<code>none</code>`;
154          return `Moved ${nLink} from ${from} to ${to}`;
155        }
156
157        case "editScript": {
158          const es = c.editScript || {};
159          return `Edited script <code>${esc(es.scriptName || es.scriptId)}</code> on ${nLink}`;
160        }
161
162        case "executeScript": {
163          const xs = c.executeScript || {};
164          const icon = xs.success ? "\u2705" : "\u274C";
165          let text = `${icon} Ran <code>${esc(xs.scriptName || xs.scriptId)}</code> on ${nLink}`;
166          if (xs.error) text += ` \u2014 <code>${esc(xs.error)}</code>`;
167          return text;
168        }
169
170        case "updateChild": {
171          const uc = c.updateChild || {};
172          return uc.action === "added"
173            ? `Added ${link(uc.childId)} as a child of ${nLink}`
174            : `Removed child ${link(uc.childId)} from ${nLink}`;
175        }
176
177        case "editName": {
178          const en = c.editName || {};
179          return `Renamed ${nLink} from <code>${esc(en.oldName)}</code> to <code>${esc(en.newName)}</code>`;
180        }
181
182        case "rawIdea": {
183          const ri = c.rawIdeaAction || {};
184          const ideaRef = `<a href="/api/v1/user/${userId}/raw-ideas/${ri.rawIdeaId}${tokenQS}"><code>${esc(ri.rawIdeaId)}</code></a>`;
185          if (ri.action === "add") return `Captured a raw idea ${ideaRef}`;
186          if (ri.action === "delete")
187            return `Discarded raw idea <code>${esc(ri.rawIdeaId)}</code>`;
188          if (ri.action === "placed") {
189            const target = ri.targetNodeId ? link(ri.targetNodeId) : nLink;
190            return `Placed raw idea ${ideaRef} into ${target}`;
191          }
192          if (ri.action === "aiStarted")
193            return `AI began processing raw idea ${ideaRef}`;
194          if (ri.action === "aiFailed")
195            return `AI failed to place raw idea ${ideaRef}`;
196          return `Updated raw idea ${ideaRef}`;
197        }
198
199        case "branchLifecycle": {
200          const bl = c.branchLifecycle || {};
201          if (bl.action === "retired") {
202            let text = `Retired branch ${nLink}`;
203            if (bl.fromParentId) text += ` from ${link(bl.fromParentId)}`;
204            return text;
205          }
206          if (bl.action === "revived") {
207            let text = `Revived branch ${nLink}`;
208            if (bl.toParentId) text += ` under ${link(bl.toParentId)}`;
209            return text;
210          }
211          return `Revived ${nLink} as a new root`;
212        }
213
214        case "purchase": {
215          const pm = c.purchaseMeta || {};
216          const parts = [];
217          if (pm.plan) parts.push(`the <code>${esc(pm.plan)}</code> plan`);
218          if (pm.energyAmount)
219            parts.push(`<code>${pm.energyAmount}</code> energy`);
220          const price = pm.totalCents
221            ? ` for $${(pm.totalCents / 100).toFixed(2)} ${esc(pm.currency || "usd").toUpperCase()}`
222            : "";
223          return parts.length
224            ? `Purchased ${parts.join(" and ")}${price}`
225            : `Made a purchase${price}`;
226        }
227
228        case "understanding": {
229          const um = c.understandingMeta || {};
230          const rootNode = um.rootNodeId || nId;
231          const runId = um.understandingRunId;
232
233          if (um.stage === "createRun") {
234            const runLink =
235              runId && rootNode
236                ? `<a href="/api/v1/root/${rootNode}/understandings/run/${runId}${tokenQS}"><code>${esc(runId)}</code></a>`
237                : `<code>unknown run</code>`;
238            let text = `Started understanding run ${runLink}`;
239            if (rootNode) text += ` on ${link(rootNode)}`;
240            if (um.nodeCount != null)
241              text += ` spanning <code>${um.nodeCount}</code> nodes`;
242            if (um.perspective) text += ` \u2014 "${esc(um.perspective)}"`;
243            return text;
244          }
245
246          if (um.stage === "processStep") {
247            const uNodeId = um.understandingNodeId;
248            const uNodeLink =
249              uNodeId && runId && rootNode
250                ? `<a href="/api/v1/root/${rootNode}/understandings/run/${runId}/${uNodeId}${tokenQS}"><code>${esc(uNodeId)}</code></a>`
251                : uNodeId
252                  ? `<code>${esc(uNodeId)}</code>`
253                  : `<code>unknown</code>`;
254            let text = `Understanding encoded ${uNodeLink}`;
255            if (um.mode)
256              text += ` <span class="kv-chip">${esc(um.mode)}</span>`;
257            if (um.layer != null) text += ` at layer <code>${um.layer}</code>`;
258            return text;
259          }
260
261          return `Understanding activity on ${nLink}`;
262        }
263
264        default:
265          return `<code>${esc(c.action)}</code> on ${nLink}`;
266      }
267    };
268
269    const items = await Promise.all(
270      contributions.map(async (c) => {
271        const nId = c.nodeId?._id || c.nodeId;
272        const nodeName = nId ? await getNodeName(nId) : null;
273        const time = new Date(c.date).toLocaleString();
274        const actionHtml = renderAction(c, nodeName);
275        const colorClass = actionColorClass(c.action);
276
277        const aiBadge = c.wasAi ? `<span class="badge badge-ai">AI</span>` : "";
278        const energyBadge =
279          c.energyUsed != null && c.energyUsed > 0
280            ? `<span class="badge badge-energy">\u26A1 ${c.energyUsed}</span>`
281            : "";
282
283        return `
284      <li class="note-card ${colorClass}">
285        <div class="note-content">
286          <div class="contribution-action">${actionHtml}</div>
287        </div>
288        <div class="note-meta">
289          ${time}
290          ${aiBadge}${energyBadge}
291          <span class="meta-separator">\u00B7</span>
292          <code class="contribution-id">${esc(c._id)}</code>
293        </div>
294      </li>`;
295      }),
296    );
297
298  const css2 = `
299.header-subtitle {
300  margin-bottom: 16px;
301}
302
303
304.nav-links {
305  display: flex; flex-wrap: wrap; gap: 8px;
306}
307
308.nav-links a {
309  display: inline-block;
310  padding: 6px 14px;
311  background: rgba(255,255,255,0.18);
312  color: white; border-radius: 980px;
313  font-size: 13px; font-weight: 600;
314  text-decoration: none;
315  border: 1px solid rgba(255,255,255,0.25);
316  transition: all 0.2s;
317}
318
319.nav-links a:hover {
320  background: rgba(255,255,255,0.32);
321  transform: translateY(-1px);
322}
323
324.contribution-action {
325  font-size: 15px; line-height: 1.6;
326  color: white; font-weight: 400;
327  word-wrap: break-word;
328}
329
330.contribution-action a {
331  color: white; text-decoration: none;
332  border-bottom: 1px solid rgba(255,255,255,0.3);
333  transition: all 0.2s;
334}
335
336.contribution-action a:hover {
337  border-bottom-color: white;
338  text-shadow: 0 0 12px rgba(255,255,255,0.8);
339}
340
341.contribution-action code {
342  background: rgba(255,255,255,0.18);
343  padding: 2px 7px; border-radius: 5px;
344  font-size: 13px;
345  font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
346  border: 1px solid rgba(255,255,255,0.15);
347}
348
349.contribution-id {
350  background: rgba(255,255,255,0.12);
351  padding: 2px 6px; border-radius: 4px;
352  font-size: 11px;
353  font-family: 'SF Mono', 'Fira Code', monospace;
354  color: rgba(255,255,255,0.6);
355  border: 1px solid rgba(255,255,255,0.1);
356}
357
358/* Badges */
359
360.badge {
361  display: inline-flex; align-items: center;
362  padding: 3px 10px; border-radius: 980px;
363  font-size: 11px; font-weight: 700; letter-spacing: 0.3px;
364  border: 1px solid rgba(255,255,255,0.2);
365}
366
367.badge-ai {
368  background: rgba(255,200,50,0.35);
369  color: #fff;
370  text-shadow: 0 1px 2px rgba(0,0,0,0.2);
371}
372
373.badge-energy {
374  background: rgba(100,220,255,0.3);
375  color: #fff;
376  text-shadow: 0 1px 2px rgba(0,0,0,0.2);
377}
378
379/* KV Chips */
380
381.kv-chip {
382  display: inline-block;
383  padding: 2px 8px;
384  background: rgba(255,255,255,0.15);
385  border-radius: 6px; font-size: 12px;
386  margin: 2px 2px;
387  border: 1px solid rgba(255,255,255,0.15);
388}
389
390.kv-chip code {
391  background: none !important;
392  border: none !important;
393  padding: 0 !important;
394  font-weight: 600;
395}
396
397/* Responsive */`;
398
399  const bodyHtml = `
400  <div class="container">
401    <div class="back-nav">
402      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">\u2190 Back to Profile</a>
403    </div>
404
405    <div class="header">
406      <h1>
407        Contributions by
408        <a href="/api/v1/user/${userId}${tokenQS}">@${esc(username)}</a>
409        ${contributions.length > 0 ? `<span class="message-count">${contributions.length}</span>` : ""}
410      </h1>
411      <div class="header-subtitle">Activity &amp; change history</div>
412
413    </div>
414
415    ${
416      items.length
417        ? `<ul class="notes-list">${items.join("")}</ul>`
418        : `
419    <div class="empty-state">
420      <div class="empty-state-icon">\uD83D\uDCCA</div>
421      <div class="empty-state-text">No contributions yet</div>
422      <div class="empty-state-subtext">Contributions and activity will appear here</div>
423    </div>`
424    }
425  </div>`;
426
427  return page({
428    title: `${esc(username)} \u2014 Contributions`,
429    css: css2,
430    body: bodyHtml,
431  });
432}
433
1import { page } from "../../html-rendering/html/layout.js";
2import { esc, escapeHtml } from "../../html-rendering/html/utils.js";
3
4export function renderUserNotes({ userId, user, notes, processedNotes, query, token }) {
5  const tokenQS = token ? `?token=${encodeURIComponent(token)}&html` : `?html`;
6
7  const css = `
8
9/* Glass Header Section */
10.header {
11  background: rgba(var(--glass-water-rgb), var(--glass-alpha));
12  backdrop-filter: blur(22px) saturate(140%);
13  -webkit-backdrop-filter: blur(22px) saturate(140%);
14  border-radius: 16px;
15  padding: 32px;
16  margin-bottom: 24px;
17  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
18    inset 0 1px 0 rgba(255, 255, 255, 0.25);
19  border: 1px solid rgba(255, 255, 255, 0.28);
20  color: white;
21  animation: fadeInUp 0.6s ease-out 0.1s both;
22}
23
24.header h1 {
25  font-size: 28px;
26  font-weight: 600;
27  color: white;
28  margin-bottom: 8px;
29  line-height: 1.3;
30  letter-spacing: -0.5px;
31  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
32}
33
34.header h1 a {
35  color: white;
36  text-decoration: none;
37  border-bottom: 1px solid rgba(255, 255, 255, 0.3);
38  transition: all 0.2s;
39}
40
41.header h1 a:hover {
42  border-bottom-color: white;
43  text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
44}
45
46.header-subtitle {
47  font-size: 14px;
48  color: rgba(255, 255, 255, 0.9);
49  margin-bottom: 20px;
50  font-weight: 400;
51}
52
53/* Glass Search Form */
54.search-form {
55  display: flex;
56  gap: 10px;
57  flex-wrap: wrap;
58}
59
60.search-form input[type="text"] {
61  flex: 1;
62  min-width: 200px;
63  padding: 12px 16px;
64  font-size: 16px;
65  border-radius: 12px;
66  border: 2px solid rgba(255, 255, 255, 0.3);
67  background: rgba(255, 255, 255, 0.2);
68  backdrop-filter: blur(20px) saturate(150%);
69  -webkit-backdrop-filter: blur(20px) saturate(150%);
70  font-family: inherit;
71  color: white;
72  font-weight: 500;
73  transition: all 0.3s;
74  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1),
75    inset 0 1px 0 rgba(255, 255, 255, 0.25);
76}
77
78.search-form input[type="text"]::placeholder {
79  color: rgba(255, 255, 255, 0.6);
80}
81
82.search-form input[type="text"]:focus {
83  outline: none;
84  border-color: rgba(255, 255, 255, 0.6);
85  background: rgba(255, 255, 255, 0.3);
86  box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.15),
87    0 8px 30px rgba(0, 0, 0, 0.15),
88    inset 0 1px 0 rgba(255, 255, 255, 0.4);
89  transform: translateY(-2px);
90}
91
92
93.search-form button {
94  position: relative;
95  overflow: hidden;
96  padding: 12px 28px;
97  font-size: 15px;
98  font-weight: 600;
99  letter-spacing: -0.2px;
100  border-radius: 980px;
101  border: 1px solid rgba(255, 255, 255, 0.3);
102  background: rgba(255, 255, 255, 0.25);
103  backdrop-filter: blur(10px);
104  color: white;
105  cursor: pointer;
106  transition: all 0.3s;
107  font-family: inherit;
108  white-space: nowrap;
109  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
110}
111
112.search-form button::before {
113  content: "";
114  position: absolute;
115  inset: -40%;
116  background: radial-gradient(
117    120% 60% at 0% 0%,
118    rgba(255, 255, 255, 0.35),
119    transparent 60%
120  );
121  opacity: 0;
122  transform: translateX(-30%) translateY(-10%);
123  transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
124  pointer-events: none;
125}
126
127.search-form button:hover {
128  background: rgba(255, 255, 255, 0.35);
129  transform: translateY(-2px);
130  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
131}
132
133.search-form button:hover::before {
134  opacity: 1;
135  transform: translateX(30%) translateY(10%);
136}
137
138/* Card Actions (Edit + Delete buttons) */
139.card-actions {
140  position: absolute;
141  top: 20px;
142  right: 20px;
143  display: flex;
144  gap: 8px;
145  z-index: 10;
146}
147
148.edit-button,
149.delete-button {
150  background: rgba(255, 255, 255, 0.2);
151  border: 1px solid rgba(255, 255, 255, 0.3);
152  border-radius: 50%;
153  width: 32px;
154  height: 32px;
155  display: flex;
156  align-items: center;
157  justify-content: center;
158  font-size: 16px;
159  cursor: pointer;
160  color: white;
161  padding: 0;
162  line-height: 1;
163  opacity: 0.8;
164  transition: all 0.3s;
165  text-decoration: none;
166}
167
168.edit-button:hover {
169  opacity: 1;
170  background: rgba(72, 187, 178, 0.4);
171  border-color: rgba(72, 187, 178, 0.6);
172  transform: scale(1.1);
173  box-shadow: 0 4px 12px rgba(72, 187, 178, 0.3);
174}
175
176.delete-button:hover {
177  opacity: 1;
178  background: rgba(239, 68, 68, 0.4);
179  border-color: rgba(239, 68, 68, 0.6);
180  transform: scale(1.1);
181  box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
182}
183
184
185.note-author {
186  font-weight: 600;
187  color: white;
188  font-size: 13px;
189  margin-bottom: 6px;
190  opacity: 0.9;
191  letter-spacing: -0.2px;
192  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
193}
194
195.note-link {
196  color: white;
197  text-decoration: none;
198  font-size: 15px;
199  line-height: 1.6;
200  display: block;
201  word-wrap: break-word;
202  transition: all 0.2s;
203  font-weight: 400;
204}
205
206.note-link:hover {
207  text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
208}
209
210.file-badge {
211  display: inline-block;
212  padding: 4px 10px;
213  background: rgba(255, 255, 255, 0.25);
214  color: white;
215  border-radius: 6px;
216  font-size: 11px;
217  font-weight: 600;
218  margin-right: 8px;
219  border: 1px solid rgba(255, 255, 255, 0.3);
220  text-transform: uppercase;
221  letter-spacing: 0.5px;
222}
223
224/* Responsive Design */
225@media (max-width: 640px) {
226  body {
227    padding: 16px;
228  }
229
230  .header {
231    padding: 24px 20px;
232  }
233
234  .header h1 {
235    font-size: 24px;
236  }
237
238  .search-form {
239    flex-direction: column;
240  }
241
242  .search-form input[type="text"] {
243    width: 100%;
244    min-width: 0;
245    font-size: 16px;
246  }
247
248  .search-form button {
249    width: 100%;
250  }
251
252
253  .card-actions {
254    top: 16px;
255    right: 16px;
256    gap: 6px;
257  }
258
259  .edit-button,
260  .delete-button {
261    width: 28px;
262    height: 28px;
263    font-size: 14px;
264  }
265
266}`;
267
268  const body = `
269  <div class="container">
270    <!-- Back Navigation -->
271    <div class="back-nav">
272      <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
273        \u2190 Back to Profile
274      </a>
275    </div>
276
277    <!-- Header Section -->
278    <div class="header">
279      <h1>
280        Notes by
281<a href="/api/v1/user/${userId}${tokenQS}">${escapeHtml(user.username)}</a>
282      </h1>
283      <div class="header-subtitle">
284        View and manage your last 200 notes across every tree
285      </div>
286
287      <!-- Search Form -->
288      <form method="GET" action="/api/v1/user/${userId}/notes" class="search-form">
289        <input type="hidden" name="token" value="${esc(token)}">
290        <input type="hidden" name="html" value="">
291        <input
292          type="text"
293          name="q"
294          placeholder="Search notes..."
295value="${escapeHtml(query)}"
296        />
297        <button type="submit">Search</button>
298      </form>
299    </div>
300
301    <!-- Notes List -->
302    ${
303      notes.length > 0
304        ? `
305    <ul class="notes-list">
306      ${processedNotes.join("")}
307    </ul>
308    `
309        : `
310    <div class="empty-state">
311      <div class="empty-state-icon">\uD83D\uDCDD</div>
312      <div class="empty-state-text">No notes yet</div>
313      <div class="empty-state-subtext">
314        ${
315          query.trim() !== ""
316            ? "Try a different search term"
317            : "Notes will appear here as you create them"
318        }
319      </div>
320    </div>
321    `
322    }
323  </div>`;
324
325  const js = `
326    document.addEventListener("click", async (e) => {
327      if (!e.target.classList.contains("delete-button")) return;
328
329      const card = e.target.closest(".note-card");
330      const noteId = card.dataset.noteId;
331      const nodeId = card.dataset.nodeId;
332      const version = card.dataset.version;
333
334      if (!noteId || !nodeId || !version) {
335        alert("Error: Missing note data. Please refresh and try again.");
336        return;
337      }
338
339      if (!confirm("Delete this note? This cannot be undone.")) return;
340
341      const token = new URLSearchParams(window.location.search).get("token") || "";
342      const qs = token ? "?token=" + encodeURIComponent(token) : "";
343
344      try {
345        const url = "/api/v1/node/" + nodeId + "/notes/" + noteId + qs;
346
347        const res = await fetch(url, { method: "DELETE" });
348
349        const data = await res.json();
350        if (!res.ok || data.status === "error") throw new Error((data.error && data.error.message) || data.error || "Delete failed");
351
352        // Fade out animation
353        card.style.transition = "all 0.3s ease";
354        card.style.opacity = "0";
355        card.style.transform = "translateX(-20px)";
356        setTimeout(() => card.remove(), 300);
357      } catch (err) {
358        alert("Failed to delete: " + (err.message || "Unknown error"));
359      }
360    });`;
361
362  return page({
363    title: `${escapeHtml(user.username)} \u2014 Notes`,
364    css,
365    body,
366    js,
367  });
368}
369
1import log from "../../seed/log.js";
2import express from "express";
3import authenticate from "../../seed/middleware/authenticate.js";
4import { sendOk, sendError, ERR } from "../../seed/protocol.js";
5import User from "../../seed/models/user.js";
6import { getChats } from "../../seed/llm/chatHistory.js";
7import { getExtension } from "../loader.js";
8import {
9  getAllNotesByUser,
10  searchNotesByUser,
11} from "../../seed/tree/notes.js";
12import { getContributionsByUser } from "../../seed/tree/contributions.js";
13import getNodeName from "../../routes/api/helpers/getNameById.js";
14import { renderUserNotes } from "./pages/userNotes.js";
15import { renderChats } from "./pages/userChats.js";
16import { renderUserContributions } from "./pages/userContributions.js";
17function escapeHtml(str) {
18  return String(str || "")
19    .replace(/&/g, "&amp;")
20    .replace(/</g, "&lt;")
21    .replace(/>/g, "&gt;")
22    .replace(/"/g, "&quot;")
23    .replace(/'/g, "&#039;");
24}
25
26export default function createRouter(core) {
27  const htmlExt = getExtension("html-rendering");
28  const htmlAuth = htmlExt?.exports?.urlAuth || authenticate;
29
30  const router = express.Router();
31
32  router.get("/user/:userId/notes", htmlAuth, async (req, res) => {
33    try {
34      const userId = req.params.userId;
35      const startDate = req.query.startDate;
36      const endDate = req.query.endDate;
37      const query = req.query.q || "";
38
39      const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
40      const token = req.query.token ?? "";
41      const tokenQS = token ? `?token=${token}&html` : `?html`;
42
43      const rawLimit = req.query.limit;
44      let limit = rawLimit !== undefined ? Number(rawLimit) : undefined;
45
46      if (limit >= 200 || limit == undefined) {
47        limit = 200;
48      }
49      if (limit !== undefined && (isNaN(limit) || limit <= 0)) {
50        return sendError(res, 400, ERR.INVALID_INPUT, "Invalid limit: must be a positive number");
51      }
52
53      let result;
54      if (query.trim() !== "") {
55        result = await searchNotesByUser({ userId, query, limit, startDate, endDate });
56      } else {
57        result = await getAllNotesByUser(userId, limit, startDate, endDate);
58      }
59
60      const notes = result.notes.map((n) => ({
61        ...n,
62        _id: n._id || n.id,
63        content: n.contentType === "file" ? `/api/v1/uploads/${n.content}` : n.content,
64      }));
65
66      if (!wantHtml || !getExtension("html-rendering")) {
67        return sendOk(res, { notes, query });
68      }
69
70      const user = await User.findById(userId).lean();
71
72      const processedNotes = await Promise.all(
73        notes.map(async (n) => {
74          const noteId = n._id || n.id;
75          const preview =
76            n.contentType === "text"
77              ? n.content.length > 120
78                ? n.content.substring(0, 120) + "..."
79                : n.content
80              : n.content.split("/").pop();
81
82          const nodeName = await getNodeName(n.nodeId);
83
84          return `
85    <li class="note-card" data-note-id="${noteId}" data-node-id="${n.nodeId}" data-version="${n.version}">
86      <div class="card-actions">
87        ${n.contentType === "text"
88            ? `<a href="/api/v1/node/${n.nodeId}/${n.version}/notes/${noteId}/editor${tokenQS}" class="edit-button" title="Edit note">✎</a>`
89            : ""}
90        <button class="delete-button" title="Delete note">✕</button>
91      </div>
92      <div class="note-content">
93        <div class="note-author">${escapeHtml(user.username)}</div>
94        <a href="/api/v1/node/${n.nodeId}/${n.version}/notes/${noteId}${tokenQS}" class="note-link">
95          ${n.contentType === "file" ? `<span class="file-badge">FILE</span>` : ""}${escapeHtml(preview)}
96        </a>
97      </div>
98      <div class="note-meta">
99        ${new Date(n.createdAt).toLocaleString()}
100        <span class="meta-separator">•</span>
101        <a href="/api/v1/node/${n.nodeId}/${n.version}${tokenQS}">${escapeHtml(nodeName)} v${n.version}</a>
102        <span class="meta-separator">•</span>
103        <a href="/api/v1/node/${n.nodeId}/${n.version}/notes${tokenQS}">View Notes</a>
104      </div>
105    </li>`;
106        }),
107      );
108
109      return res.send(renderUserNotes({ userId, user, notes, processedNotes, query, token }));
110    } catch (err) {
111 log.error("User Queries", "Error in /user/:userId/notes:", err);
112      sendError(res, 400, ERR.INVALID_INPUT, err.message);
113    }
114  });
115
116  // Tags route moved to extensions/team
117
118  router.get("/user/:userId/contributions", htmlAuth, async (req, res) => {
119    try {
120      const { userId } = req.params;
121      const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
122
123      const limit = req.query.limit !== undefined ? Number(req.query.limit) : undefined;
124      if (limit !== undefined && (isNaN(limit) || limit <= 0)) {
125        return sendError(res, 400, ERR.INVALID_INPUT, "Invalid limit");
126      }
127
128      const token = req.query.token ?? "";
129      const tokenQS = token ? `?token=${token}&html` : `?html`;
130
131      const { contributions = [] } = await getContributionsByUser(userId, limit, req.query.startDate, req.query.endDate);
132
133      if (!wantHtml || !getExtension("html-rendering")) {
134        return sendOk(res, { userId, contributions });
135      }
136
137      const user = await User.findById(userId).lean();
138      return res.send(await renderUserContributions({ userId, user, contributions, username: user?.username, getNodeName, token }));
139    } catch (err) {
140 log.error("User Queries", "Error in /user/:userId/contributions:", err);
141      sendError(res, 400, ERR.INVALID_INPUT, err.message);
142    }
143  });
144
145  router.get("/user/:userId/chats", htmlAuth, async (req, res) => {
146    try {
147      const { userId } = req.params;
148      const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
149
150      const rawLimit = req.query.limit;
151      let limit = rawLimit !== undefined ? Number(rawLimit) : undefined;
152      if (limit !== undefined && (isNaN(limit) || limit <= 0)) {
153        return sendError(res, 400, ERR.INVALID_INPUT, "Invalid limit");
154      }
155      if (limit > 10) limit = 10;
156
157      const token = req.query.token ?? "";
158      let sessionId = req.query.sessionId;
159      if (typeof sessionId === "string") {
160        sessionId = sessionId.replace(/^"+|"+$/g, "");
161      }
162
163      const { sessions } = await getChats({
164        userId,
165        sessionLimit: limit || 10,
166        sessionId,
167        startDate: req.query.startDate,
168        endDate: req.query.endDate,
169      });
170
171      const allChats = sessions.flatMap((s) => s.chats);
172
173      if (!wantHtml || !getExtension("html-rendering")) {
174        return sendOk(res, { userId, count: allChats.length, sessions });
175      }
176
177      const user = await User.findById(userId).lean();
178      const username = user?.username || "Unknown user";
179
180      return res.send(renderChats({ userId, chats: allChats, sessions, username, token, sessionId }));
181    } catch (err) {
182 log.error("User Queries", err);
183      sendError(res, 500, ERR.INTERNAL, err.message);
184    }
185  });
186
187  // Notifications route moved to extensions/notifications
188
189  return router;
190}
191

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star user-queries

Comments

Loading comments...

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