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 & 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, "&")
20 .replace(/</g, "<")
21 .replace(/>/g, ">")
22 .replace(/"/g, """)
23 .replace(/'/g, "'");
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
Loading comments...