Andrew commited on
Commit
cb5990d
·
1 Parent(s): 8967568

feat(tree): Add ELK port-based layout and persona-specific branching

Browse files
Files changed (35) hide show
  1. src/lib/actions/snapScrollToBottom.ts +40 -25
  2. src/lib/components/ConversationTreeGraph.svelte +120 -54
  3. src/lib/components/CopyToClipBoardBtn.svelte +10 -3
  4. src/lib/components/NavConversationItem.svelte +56 -8
  5. src/lib/components/ShareConversationModal.svelte +3 -0
  6. src/lib/components/chat/ChatMessage.svelte +144 -97
  7. src/lib/components/chat/ChatWindow.svelte +33 -3
  8. src/lib/components/chat/MetacognitivePrompt.svelte +98 -0
  9. src/lib/constants/treeConfig.ts +7 -0
  10. src/lib/hooks/useMetacognitiveEngine.svelte.ts +307 -0
  11. src/lib/server/api/routes/groups/conversations.ts +112 -0
  12. src/lib/server/api/routes/groups/misc.ts +10 -0
  13. src/lib/server/config.ts +8 -1
  14. src/lib/server/endpoints/preprocessMessages.ts +1 -1
  15. src/lib/server/metacognitiveConfig.ts +133 -0
  16. src/lib/stores/treeVisibility.ts +17 -0
  17. src/lib/types/Conversation.ts +6 -0
  18. src/lib/types/Message.ts +16 -0
  19. src/lib/types/MessageContext.ts +41 -0
  20. src/lib/types/MessageUpdate.ts +1 -0
  21. src/lib/types/Metacognitive.ts +18 -0
  22. src/lib/types/Persona.ts +9 -4
  23. src/lib/utils/message/ConversationTreeManager.ts +268 -0
  24. src/lib/utils/message/MessageStreamHandler.ts +228 -0
  25. src/lib/utils/messageSender.ts +21 -347
  26. src/lib/utils/metacognitiveLogic.spec.ts +148 -0
  27. src/lib/utils/metacognitiveLogic.ts +217 -0
  28. src/lib/utils/tree/addChildren.ts +9 -8
  29. src/lib/utils/tree/layout.ts +123 -24
  30. src/routes/+layout.svelte +2 -2
  31. src/routes/+layout.ts +25 -10
  32. src/routes/+page.svelte +7 -7
  33. src/routes/api/conversation/[id]/+server.ts +1 -0
  34. src/routes/conversation/[id]/+page.svelte +90 -0
  35. src/routes/conversation/[id]/+server.ts +35 -6
src/lib/actions/snapScrollToBottom.ts CHANGED
@@ -1,53 +1,68 @@
1
  import { navigating } from "$app/state";
2
  import { tick } from "svelte";
3
 
4
- const detachedOffset = 10;
5
-
6
- /**
7
- * @param node element to snap scroll to bottom
8
- * @param dependency pass in a dependency to update scroll on changes.
9
- */
10
  export const snapScrollToBottom = (node: HTMLElement, dependency: unknown) => {
11
- let prevScrollValue = node.scrollTop;
12
  let isDetached = false;
 
13
 
14
- const handleScroll = () => {
15
- // if user scrolled up, we detach
16
- if (node.scrollTop < prevScrollValue) {
17
- isDetached = true;
 
 
 
 
 
18
  }
 
19
 
20
- // if user scrolled back to within 10px of bottom, we reattach
21
- if (node.scrollTop - (node.scrollHeight - node.clientHeight) >= -detachedOffset) {
 
 
22
  isDetached = false;
 
 
23
  }
24
-
25
- prevScrollValue = node.scrollTop;
26
  };
27
 
28
- const updateScroll = async (_options: { force?: boolean } = {}) => {
29
- const defaultOptions = { force: false };
30
- const options = { ...defaultOptions, ..._options };
31
- const { force } = options;
32
 
33
  if (!force && isDetached && !navigating.to) return;
34
 
35
- // wait for next tick to ensure that the DOM is updated
36
  await tick();
37
 
38
- node.scrollTo({ top: node.scrollHeight });
39
  };
40
 
41
- node.addEventListener("scroll", handleScroll);
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
 
43
  if (dependency) {
44
- updateScroll({ force: true });
45
  }
46
 
47
  return {
48
- update: updateScroll,
49
  destroy: () => {
50
- node.removeEventListener("scroll", handleScroll);
 
51
  },
52
  };
53
  };
 
1
  import { navigating } from "$app/state";
2
  import { tick } from "svelte";
3
 
 
 
 
 
 
 
4
  export const snapScrollToBottom = (node: HTMLElement, dependency: unknown) => {
 
5
  let isDetached = false;
6
+ const threshold = 50; // Distance from bottom to consider "attached"
7
 
8
+ const isNearBottom = () => {
9
+ const { scrollTop, scrollHeight, clientHeight } = node;
10
+ // Use Math.abs for float precision safety, though distances are usually positive
11
+ return Math.abs(scrollHeight - scrollTop - clientHeight) <= threshold;
12
+ };
13
+
14
+ const updateScrollPosition = () => {
15
+ if (!isDetached) {
16
+ node.scrollTo({ top: node.scrollHeight, behavior: "instant" });
17
  }
18
+ };
19
 
20
+ const onScroll = () => {
21
+ // If the user is near the bottom, they are attached.
22
+ // If they scroll up (away from bottom), they detach.
23
+ if (isNearBottom()) {
24
  isDetached = false;
25
+ } else {
26
+ isDetached = true;
27
  }
 
 
28
  };
29
 
30
+ const update = async (_options: { force?: boolean } = {}) => {
31
+ const { force = false } = _options;
 
 
32
 
33
  if (!force && isDetached && !navigating.to) return;
34
 
35
+ // Wait for DOM updates (e.g. new message rendered)
36
  await tick();
37
 
38
+ node.scrollTo({ top: node.scrollHeight, behavior: "instant" });
39
  };
40
 
41
+ // Observe content size changes (e.g. streaming responses, images loading)
42
+ // This ensures we stay at the bottom even if the container size doesn't change
43
+ // but the content grows.
44
+ const observer = new ResizeObserver(() => {
45
+ updateScrollPosition();
46
+ });
47
+
48
+ if (node.firstElementChild) {
49
+ observer.observe(node.firstElementChild);
50
+ } else {
51
+ observer.observe(node);
52
+ }
53
+
54
+ node.addEventListener("scroll", onScroll);
55
 
56
+ // Check initial state
57
  if (dependency) {
58
+ update({ force: true });
59
  }
60
 
61
  return {
62
+ update,
63
  destroy: () => {
64
+ node.removeEventListener("scroll", onScroll);
65
+ observer.disconnect();
66
  },
67
  };
68
  };
src/lib/components/ConversationTreeGraph.svelte CHANGED
@@ -4,22 +4,90 @@
4
  import { MessageRole } from "$lib/types/Message";
5
  import { getPersonaColor } from "$lib/utils/personaColors";
6
  import type { TreeLayoutNode } from "$lib/utils/tree/layout";
 
7
 
8
  interface Props {
9
  treeData: { nodes: TreeLayoutNode[]; width: number; height: number };
10
- onNodeClick: (messageId: string) => void;
11
  }
12
 
13
  let { treeData, onNodeClick }: Props = $props();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  </script>
15
 
16
  {#if treeData.nodes.length > 0}
17
- {@const nodeSize = 24}
18
- {@const iconSize = 18}
19
- {@const svgWidth = Math.max(treeData.width, 100)}
20
- {@const svgHeight = Math.max(treeData.height, 50)}
21
 
22
- <div class="mt-2 mb-3 flex justify-center conversation-tree">
 
 
 
 
23
  <svg
24
  width={svgWidth}
25
  height={svgHeight}
@@ -34,23 +102,27 @@
34
 
35
  {#if parentIsUser && isMultiPersonaResponse && node.message.personaResponses}
36
  {@const personaCount = node.message.personaResponses.length}
37
- {@const spacing = 8}
38
  {@const totalWidth = personaCount * iconSize + (personaCount - 1) * spacing}
39
 
40
  <!-- Use ELK coordinates directly -->
41
- {@const leftmostX = node.x + iconSize / 2}
 
42
  {@const rightmostX = leftmostX + (personaCount - 1) * (iconSize + spacing)}
43
- {@const childCenterX = node.x + totalWidth / 2}
44
 
45
- {@const junctionY = node.y - (node.y - node.parentY - nodeSize) * 0.25}
 
 
 
46
  {@const parentWidth = parentNode?.width || nodeSize}
47
  {@const parentCenterX = node.parentX + parentWidth / 2}
48
 
49
  <!-- Curve from Parent to Child Center -->
50
  <path
51
- d="M {parentCenterX},{node.parentY + nodeSize}
52
- C {parentCenterX},{node.parentY + nodeSize + 15}
53
- {childCenterX},{junctionY - 15}
54
  {childCenterX},{junctionY}"
55
  stroke="currentColor"
56
  stroke-width="1.5"
@@ -74,8 +146,7 @@
74
  {@const dropX = leftmostX + personaIndex * (iconSize + spacing)}
75
  <path
76
  d="M {dropX},{junctionY}
77
- Q {dropX},{junctionY + 5}
78
- {dropX},{node.y}"
79
  stroke="currentColor"
80
  stroke-width="1.5"
81
  fill="none"
@@ -86,7 +157,20 @@
86
  {@const parentWidth = parentNode?.width || nodeSize}
87
  {@const parentCenterX = node.parentX + parentWidth / 2}
88
 
89
- {@const x1 = parentCenterX}
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  {@const y1 = node.parentY + nodeSize}
91
  {@const x2 = node.x + nodeSize / 2}
92
  {@const y2 = node.y}
@@ -109,7 +193,7 @@
109
 
110
  <!-- Draw nodes -->
111
  {#each treeData.nodes as node}
112
- {@const cx = node.x + nodeSize / 2}
113
  {@const cy = node.y + nodeSize / 2}
114
 
115
  {#if node.message.from === MessageRole.User}
@@ -132,7 +216,7 @@
132
  </foreignObject>
133
  {:else if node.message.personaResponses && node.message.personaResponses.length > 0}
134
  {@const personaCount = node.message.personaResponses.length}
135
- {@const spacing = 8}
136
 
137
  {#if personaCount === 1}
138
  <!-- Single persona: center it like user icons -->
@@ -141,15 +225,16 @@
141
  y={cy - iconSize / 2}
142
  width={iconSize}
143
  height={iconSize}
 
144
  role="button"
145
  tabindex="0"
146
  class="cursor-pointer hover:opacity-80 transition-opacity"
147
- onclick={() => onNodeClick(node.message.id)}
148
- onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id)}
149
  >
150
  <div
151
- class="flex items-center justify-center w-full h-full rounded-full {node.isActive ? 'opacity-100 scale-110' : ''}"
152
- style="color: {getPersonaColor(node.message.personaResponses[0].personaId)};"
153
  title={node.message.personaResponses[0].personaName || node.message.personaResponses[0].personaId}
154
  >
155
  <CarbonChat class="w-full h-full" />
@@ -158,7 +243,8 @@
158
  {:else}
159
  <!-- Multiple personas: distribute horizontally -->
160
  {@const totalWidth = personaCount * iconSize + (personaCount - 1) * spacing}
161
- {@const startX = node.x + iconSize / 2}
 
162
 
163
  {#each node.message.personaResponses as response, i}
164
  {@const iconX = startX + i * (iconSize + spacing)}
@@ -167,20 +253,21 @@
167
  y={cy - iconSize / 2}
168
  width={iconSize}
169
  height={iconSize}
 
170
  role="button"
171
  tabindex="0"
172
  class="cursor-pointer hover:opacity-80 transition-opacity"
173
- onclick={() => onNodeClick(node.message.id)}
174
- onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id)}
 
 
 
 
 
175
  >
176
- <div
177
- class="flex items-center justify-center w-full h-full rounded-full {node.isActive ? 'opacity-100 scale-110' : ''}"
178
- style="color: {getPersonaColor(response.personaId)};"
179
- title={response.personaName || response.personaId}
180
- >
181
- <CarbonChat class="w-full h-full" />
182
- </div>
183
- </foreignObject>
184
  {/each}
185
  {/if}
186
  {:else}
@@ -207,24 +294,3 @@
207
  </svg>
208
  </div>
209
  {/if}
210
-
211
- <style>
212
- .conversation-tree {
213
- animation: fadeIn 0.3s ease-in;
214
- }
215
-
216
- .conversation-tree path,
217
- .conversation-tree line,
218
- .conversation-tree foreignObject {
219
- animation: fadeIn 0.4s ease-in;
220
- }
221
-
222
- @keyframes fadeIn {
223
- from {
224
- opacity: 0;
225
- }
226
- to {
227
- opacity: 1;
228
- }
229
- }
230
- </style>
 
4
  import { MessageRole } from "$lib/types/Message";
5
  import { getPersonaColor } from "$lib/utils/personaColors";
6
  import type { TreeLayoutNode } from "$lib/utils/tree/layout";
7
+ import { TREE_CONFIG } from "$lib/constants/treeConfig";
8
 
9
  interface Props {
10
  treeData: { nodes: TreeLayoutNode[]; width: number; height: number };
11
+ onNodeClick: (messageId: string, personaId?: string) => void;
12
  }
13
 
14
  let { treeData, onNodeClick }: Props = $props();
15
+
16
+ let containerElement: HTMLDivElement | undefined = $state();
17
+ let previousNodeCount = $state(0);
18
+ let previousActiveNodeId = $state<string | undefined>(undefined);
19
+ let userHasScrolled = $state(false);
20
+ let scrollResetTimeout: ReturnType<typeof setTimeout> | undefined;
21
+
22
+ // Auto-scroll to keep active or latest nodes in view
23
+ $effect(() => {
24
+ if (!containerElement || treeData.nodes.length === 0) return;
25
+
26
+ const activeNode = treeData.nodes.find(n => n.isActive);
27
+ const activeNodeId = activeNode?.id;
28
+
29
+ // Determine if we should auto-scroll
30
+ const shouldAutoScroll =
31
+ // New nodes were added
32
+ (treeData.nodes.length > previousNodeCount) ||
33
+ // Active node changed and user hasn't manually scrolled recently
34
+ (activeNodeId !== previousActiveNodeId && !userHasScrolled);
35
+
36
+ if (shouldAutoScroll) {
37
+ // Find the target node (active node or the last node)
38
+ const targetNode = activeNode || treeData.nodes[treeData.nodes.length - 1];
39
+
40
+ if (targetNode) {
41
+ // Small delay to ensure DOM is updated
42
+ requestAnimationFrame(() => {
43
+ if (!containerElement) return;
44
+
45
+ // Calculate the center Y position of the target node
46
+ const nodeY = targetNode.y + (targetNode.height / 2);
47
+ const containerHeight = containerElement.clientHeight;
48
+
49
+ // Scroll to center the node vertically in the view
50
+ const scrollTop = nodeY - (containerHeight / 2);
51
+
52
+ // Use smooth scrolling
53
+ containerElement.scrollTo({
54
+ top: Math.max(0, scrollTop),
55
+ behavior: 'smooth'
56
+ });
57
+ });
58
+ }
59
+
60
+ // Reset user scroll flag after auto-scroll
61
+ userHasScrolled = false;
62
+ }
63
+
64
+ previousNodeCount = treeData.nodes.length;
65
+ previousActiveNodeId = activeNodeId;
66
+ });
67
+
68
+ // Track manual scrolling by user
69
+ function handleScroll() {
70
+ userHasScrolled = true;
71
+
72
+ // Reset the flag after a delay so auto-scroll can resume
73
+ clearTimeout(scrollResetTimeout);
74
+ scrollResetTimeout = setTimeout(() => {
75
+ userHasScrolled = false;
76
+ }, 2000); // 2 second delay
77
+ }
78
  </script>
79
 
80
  {#if treeData.nodes.length > 0}
81
+ {@const nodeSize = TREE_CONFIG.nodeSize}
82
+ {@const iconSize = TREE_CONFIG.iconSize}
83
+ {@const svgWidth = Math.max(treeData.width, TREE_CONFIG.minWidth)}
84
+ {@const svgHeight = Math.max(treeData.height, TREE_CONFIG.minHeight)}
85
 
86
+ <div
87
+ bind:this={containerElement}
88
+ onscroll={handleScroll}
89
+ class="mt-2 mb-3 flex justify-center conversation-tree max-h-[60vh] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent hover:scrollbar-thumb-gray-400 dark:hover:scrollbar-thumb-gray-500"
90
+ >
91
  <svg
92
  width={svgWidth}
93
  height={svgHeight}
 
102
 
103
  {#if parentIsUser && isMultiPersonaResponse && node.message.personaResponses}
104
  {@const personaCount = node.message.personaResponses.length}
105
+ {@const spacing = TREE_CONFIG.spacing}
106
  {@const totalWidth = personaCount * iconSize + (personaCount - 1) * spacing}
107
 
108
  <!-- Use ELK coordinates directly -->
109
+ {@const startOffset = (node.width - totalWidth) / 2}
110
+ {@const leftmostX = node.x + startOffset + iconSize / 2}
111
  {@const rightmostX = leftmostX + (personaCount - 1) * (iconSize + spacing)}
112
+ {@const childCenterX = node.x + node.width / 2}
113
 
114
+ {@const parentBottom = node.parentY + nodeSize}
115
+ {@const gap = node.y - parentBottom}
116
+ {@const junctionY = parentBottom + gap * 0.5}
117
+
118
  {@const parentWidth = parentNode?.width || nodeSize}
119
  {@const parentCenterX = node.parentX + parentWidth / 2}
120
 
121
  <!-- Curve from Parent to Child Center -->
122
  <path
123
+ d="M {parentCenterX},{parentBottom}
124
+ C {parentCenterX},{parentBottom + gap * 0.25}
125
+ {childCenterX},{junctionY - gap * 0.25}
126
  {childCenterX},{junctionY}"
127
  stroke="currentColor"
128
  stroke-width="1.5"
 
146
  {@const dropX = leftmostX + personaIndex * (iconSize + spacing)}
147
  <path
148
  d="M {dropX},{junctionY}
149
+ L {dropX},{node.y}"
 
150
  stroke="currentColor"
151
  stroke-width="1.5"
152
  fill="none"
 
157
  {@const parentWidth = parentNode?.width || nodeSize}
158
  {@const parentCenterX = node.parentX + parentWidth / 2}
159
 
160
+ {@const spacing = TREE_CONFIG.spacing}
161
+ {@const parentIsMultiPersona = parentNode?.message.from === MessageRole.Assistant && parentNode?.message.personaResponses && parentNode.message.personaResponses.length > 1}
162
+ {@const branchedFromPersonaId = node.message.branchedFrom?.personaId}
163
+ {@const targetPersonaIndex = (parentIsMultiPersona && branchedFromPersonaId && parentNode?.message.personaResponses)
164
+ ? parentNode.message.personaResponses.findIndex(p => p.personaId === branchedFromPersonaId)
165
+ : -1}
166
+
167
+ {@const parentPersonaCount = parentNode?.message.personaResponses?.length || 0}
168
+ {@const parentTotalWidth = (parentPersonaCount > 0) ? parentPersonaCount * iconSize + (parentPersonaCount - 1) * spacing : 0}
169
+ {@const parentStartOffset = (parentWidth - parentTotalWidth) / 2}
170
+
171
+ {@const x1 = (targetPersonaIndex !== -1)
172
+ ? (node.parentX + parentStartOffset + iconSize / 2) + targetPersonaIndex * (iconSize + spacing)
173
+ : parentCenterX}
174
  {@const y1 = node.parentY + nodeSize}
175
  {@const x2 = node.x + nodeSize / 2}
176
  {@const y2 = node.y}
 
193
 
194
  <!-- Draw nodes -->
195
  {#each treeData.nodes as node}
196
+ {@const cx = node.x + node.width / 2}
197
  {@const cy = node.y + nodeSize / 2}
198
 
199
  {#if node.message.from === MessageRole.User}
 
216
  </foreignObject>
217
  {:else if node.message.personaResponses && node.message.personaResponses.length > 0}
218
  {@const personaCount = node.message.personaResponses.length}
219
+ {@const spacing = TREE_CONFIG.spacing}
220
 
221
  {#if personaCount === 1}
222
  <!-- Single persona: center it like user icons -->
 
225
  y={cy - iconSize / 2}
226
  width={iconSize}
227
  height={iconSize}
228
+ style="overflow: visible"
229
  role="button"
230
  tabindex="0"
231
  class="cursor-pointer hover:opacity-80 transition-opacity"
232
+ onclick={() => onNodeClick(node.message.id, node.message.personaResponses![0].personaId)}
233
+ onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id, node.message.personaResponses![0].personaId)}
234
  >
235
  <div
236
+ class="flex items-center justify-center rounded-full {node.isActive ? 'opacity-100 scale-110' : ''}"
237
+ style="width: {iconSize}px; height: {iconSize}px; color: {getPersonaColor(node.message.personaResponses[0].personaId)};"
238
  title={node.message.personaResponses[0].personaName || node.message.personaResponses[0].personaId}
239
  >
240
  <CarbonChat class="w-full h-full" />
 
243
  {:else}
244
  <!-- Multiple personas: distribute horizontally -->
245
  {@const totalWidth = personaCount * iconSize + (personaCount - 1) * spacing}
246
+ {@const startOffset = (node.width - totalWidth) / 2}
247
+ {@const startX = node.x + startOffset + iconSize / 2}
248
 
249
  {#each node.message.personaResponses as response, i}
250
  {@const iconX = startX + i * (iconSize + spacing)}
 
253
  y={cy - iconSize / 2}
254
  width={iconSize}
255
  height={iconSize}
256
+ style="overflow: visible"
257
  role="button"
258
  tabindex="0"
259
  class="cursor-pointer hover:opacity-80 transition-opacity"
260
+ onclick={() => onNodeClick(node.message.id, response.personaId)}
261
+ onkeydown={(e) => e.key === 'Enter' && onNodeClick(node.message.id, response.personaId)}
262
+ >
263
+ <div
264
+ class="flex items-center justify-center rounded-full {node.isActive ? 'opacity-100 scale-110' : ''}"
265
+ style="width: {iconSize}px; height: {iconSize}px; color: {getPersonaColor(response.personaId)};"
266
+ title={response.personaName || response.personaId}
267
  >
268
+ <CarbonChat class="w-full h-full" />
269
+ </div>
270
+ </foreignObject>
 
 
 
 
 
271
  {/each}
272
  {/if}
273
  {:else}
 
294
  </svg>
295
  </div>
296
  {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/components/CopyToClipBoardBtn.svelte CHANGED
@@ -10,6 +10,7 @@
10
  children?: import("svelte").Snippet;
11
  onClick?: () => void;
12
  showTooltip?: boolean;
 
13
  }
14
 
15
  let {
@@ -19,6 +20,7 @@
19
  children,
20
  onClick,
21
  showTooltip = true,
 
22
  }: Props = $props();
23
 
24
  let isSuccess = $state(false);
@@ -46,6 +48,8 @@
46
  };
47
 
48
  const handleClick = async () => {
 
 
49
  try {
50
  await copy(value);
51
 
@@ -70,11 +74,14 @@
70
 
71
  <button
72
  class={classNames}
73
- title={"Copy to clipboard"}
74
  type="button"
 
75
  onclick={() => {
76
- onClick?.();
77
- handleClick();
 
 
78
  }}
79
  >
80
  <div class="relative transition-transform duration-200 {isSuccess ? 'scale-125' : 'scale-100'}">
 
10
  children?: import("svelte").Snippet;
11
  onClick?: () => void;
12
  showTooltip?: boolean;
13
+ disabled?: boolean;
14
  }
15
 
16
  let {
 
20
  children,
21
  onClick,
22
  showTooltip = true,
23
+ disabled = false,
24
  }: Props = $props();
25
 
26
  let isSuccess = $state(false);
 
48
  };
49
 
50
  const handleClick = async () => {
51
+ if (disabled) return;
52
+
53
  try {
54
  await copy(value);
55
 
 
74
 
75
  <button
76
  class={classNames}
77
+ title={disabled ? "Please wait for current response to complete" : "Copy to clipboard"}
78
  type="button"
79
+ {disabled}
80
  onclick={() => {
81
+ if (!disabled) {
82
+ onClick?.();
83
+ handleClick();
84
+ }
85
  }}
86
  >
87
  <div class="relative transition-transform duration-200 {isSuccess ? 'scale-125' : 'scale-100'}">
src/lib/components/NavConversationItem.svelte CHANGED
@@ -7,12 +7,15 @@
7
  import CarbonTrashCan from "~icons/carbon/trash-can";
8
  import CarbonClose from "~icons/carbon/close";
9
  import CarbonEdit from "~icons/carbon/edit";
 
 
10
  import type { ConvSidebar } from "$lib/types/ConvSidebar";
11
- import { type Message, MessageRole } from "$lib/types/Message";
12
 
13
  import EditConversationModal from "$lib/components/EditConversationModal.svelte";
14
  import ConversationTreeGraph from "$lib/components/ConversationTreeGraph.svelte";
15
  import { conversationTree } from "$lib/stores/conversationTree";
 
16
  import type { TreeLayoutNode } from "$lib/utils/tree/layout";
17
  import { buildTreeWithPositions } from "$lib/utils/tree/layout";
18
  import { onDestroy } from "svelte";
@@ -34,6 +37,16 @@
34
  conv.id === page.params.id && $conversationTree.conversationId === conv.id
35
  );
36
 
 
 
 
 
 
 
 
 
 
 
37
  let treeData = $state<{ nodes: TreeLayoutNode[]; width: number; height: number }>({
38
  nodes: [],
39
  width: 0,
@@ -46,7 +59,7 @@
46
  // Only update after messages are complete (have content)
47
  // DEBOUNCED to prevent layout thrashing during streaming
48
  $effect(() => {
49
- if (isActiveWithTree && $conversationTree.messages.length > 0) {
50
  clearTimeout(treeUpdateTimeout);
51
  treeUpdateTimeout = setTimeout(() => {
52
  // Filter to only messages with content (streaming complete)
@@ -83,8 +96,10 @@
83
  }, 300); // 300ms debounce
84
  } else {
85
  treeData = { nodes: [], width: 0, height: 0 };
86
- // Reset to default width
87
- document.documentElement.style.setProperty('--sidebar-width', '290px');
 
 
88
  }
89
  });
90
 
@@ -92,11 +107,13 @@
92
  if (treeUpdateTimeout) clearTimeout(treeUpdateTimeout);
93
  });
94
 
95
- function handleTreeNodeClick(messageId: string) {
96
  const clickedMessage = $conversationTree.messages.find(m => m.id === messageId);
 
 
97
  if (!clickedMessage) {
98
  console.error('Clicked message not found:', messageId);
99
- goto(`${base}/conversation/${conv.id}?msgId=${messageId}&scrollTo=true`);
100
  return;
101
  }
102
 
@@ -107,12 +124,12 @@
107
 
108
  if (currentActivePath.has(messageId)) {
109
  // Message is in active branch; preserve state
110
- goto(`${base}/conversation/${conv.id}?msgId=${messageId}&scrollTo=true&keepBranch=true`);
111
  return;
112
  }
113
  }
114
 
115
- goto(`${base}/conversation/${conv.id}?msgId=${messageId}&scrollTo=true`);
116
  }
117
  </script>
118
 
@@ -122,6 +139,13 @@
122
  onmouseleave={() => {
123
  confirmDelete = false;
124
  }}
 
 
 
 
 
 
 
125
  href="{base}/conversation/{conv.id}"
126
  class="group flex h-[2.15rem] flex-none items-center gap-1.5 rounded-lg pl-2.5 pr-2 text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 max-sm:h-10
127
  {conv.id === page.params.id ? 'bg-gray-100 dark:bg-gray-700' : ''}"
@@ -163,6 +187,30 @@
163
  />
164
  </button>
165
  {:else}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
166
  <button
167
  type="button"
168
  class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
 
7
  import CarbonTrashCan from "~icons/carbon/trash-can";
8
  import CarbonClose from "~icons/carbon/close";
9
  import CarbonEdit from "~icons/carbon/edit";
10
+ import CarbonChevronDown from "~icons/carbon/chevron-down";
11
+ import CarbonChevronRight from "~icons/carbon/chevron-right";
12
  import type { ConvSidebar } from "$lib/types/ConvSidebar";
13
+ import { MessageRole } from "$lib/types/Message";
14
 
15
  import EditConversationModal from "$lib/components/EditConversationModal.svelte";
16
  import ConversationTreeGraph from "$lib/components/ConversationTreeGraph.svelte";
17
  import { conversationTree } from "$lib/stores/conversationTree";
18
+ import { treeVisibility } from "$lib/stores/treeVisibility";
19
  import type { TreeLayoutNode } from "$lib/utils/tree/layout";
20
  import { buildTreeWithPositions } from "$lib/utils/tree/layout";
21
  import { onDestroy } from "svelte";
 
37
  conv.id === page.params.id && $conversationTree.conversationId === conv.id
38
  );
39
 
40
+ // Initialize visibility for active conversation if not set
41
+ $effect(() => {
42
+ if (isActiveWithTree && $treeVisibility[conv.id.toString()] === undefined) {
43
+ // Default to visible for active conversation
44
+ treeVisibility.setVisible(conv.id.toString(), true);
45
+ }
46
+ });
47
+
48
+ let isVisible = $derived($treeVisibility[conv.id.toString()] ?? false);
49
+
50
  let treeData = $state<{ nodes: TreeLayoutNode[]; width: number; height: number }>({
51
  nodes: [],
52
  width: 0,
 
59
  // Only update after messages are complete (have content)
60
  // DEBOUNCED to prevent layout thrashing during streaming
61
  $effect(() => {
62
+ if (isActiveWithTree && isVisible && $conversationTree.messages.length > 0) {
63
  clearTimeout(treeUpdateTimeout);
64
  treeUpdateTimeout = setTimeout(() => {
65
  // Filter to only messages with content (streaming complete)
 
96
  }, 300); // 300ms debounce
97
  } else {
98
  treeData = { nodes: [], width: 0, height: 0 };
99
+ // Reset to default width if this was the active conversation
100
+ if (isActiveWithTree) {
101
+ document.documentElement.style.setProperty('--sidebar-width', '290px');
102
+ }
103
  }
104
  });
105
 
 
107
  if (treeUpdateTimeout) clearTimeout(treeUpdateTimeout);
108
  });
109
 
110
+ function handleTreeNodeClick(messageId: string, personaId?: string) {
111
  const clickedMessage = $conversationTree.messages.find(m => m.id === messageId);
112
+ const personaParam = personaId ? `&personaId=${personaId}` : '';
113
+
114
  if (!clickedMessage) {
115
  console.error('Clicked message not found:', messageId);
116
+ goto(`${base}/conversation/${conv.id}?msgId=${messageId}&scrollTo=true${personaParam}`);
117
  return;
118
  }
119
 
 
124
 
125
  if (currentActivePath.has(messageId)) {
126
  // Message is in active branch; preserve state
127
+ goto(`${base}/conversation/${conv.id}?msgId=${messageId}&scrollTo=true&keepBranch=true${personaParam}`);
128
  return;
129
  }
130
  }
131
 
132
+ goto(`${base}/conversation/${conv.id}?msgId=${messageId}&scrollTo=true${personaParam}`);
133
  }
134
  </script>
135
 
 
139
  onmouseleave={() => {
140
  confirmDelete = false;
141
  }}
142
+ onclick={(e) => {
143
+ // If clicking the active conversation, ensure tree is visible
144
+ if (isActiveWithTree && !isVisible) {
145
+ treeVisibility.setVisible(conv.id.toString(), true);
146
+ }
147
+ // Navigation happens automatically via href
148
+ }}
149
  href="{base}/conversation/{conv.id}"
150
  class="group flex h-[2.15rem] flex-none items-center gap-1.5 rounded-lg pl-2.5 pr-2 text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 max-sm:h-10
151
  {conv.id === page.params.id ? 'bg-gray-100 dark:bg-gray-700' : ''}"
 
187
  />
188
  </button>
189
  {:else}
190
+ <button
191
+ type="button"
192
+ class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
193
+ title={isVisible ? "Hide tree" : "Show tree"}
194
+ onclick={(e) => {
195
+ e.preventDefault();
196
+ e.stopPropagation();
197
+ if (isActiveWithTree) {
198
+ // Only toggle locally if it's the active conversation
199
+ treeVisibility.toggle(conv.id.toString());
200
+ } else {
201
+ // If inactive, navigate to it (Option A)
202
+ // This will naturally trigger the visibility effect to set it to true
203
+ goto(`${base}/conversation/${conv.id}`);
204
+ }
205
+ }}
206
+ >
207
+ {#if isActiveWithTree && isVisible}
208
+ <CarbonChevronDown class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
209
+ {:else}
210
+ <CarbonChevronRight class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
211
+ {/if}
212
+ </button>
213
+
214
  <button
215
  type="button"
216
  class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
src/lib/components/ShareConversationModal.svelte CHANGED
@@ -26,6 +26,9 @@
26
  try {
27
  creating = true;
28
  errorMsg = null;
 
 
 
29
  createdUrl = await createShareLink(page.params.id);
30
  } catch (e) {
31
  errorMsg = (e as Error).message || "Could not create link";
 
26
  try {
27
  creating = true;
28
  errorMsg = null;
29
+ if (!page.params.id) {
30
+ throw new Error("No conversation id available to share");
31
+ }
32
  createdUrl = await createShareLink(page.params.id);
33
  } catch (e) {
34
  errorMsg = (e as Error).message || "Could not create link";
src/lib/components/chat/ChatMessage.svelte CHANGED
@@ -1,5 +1,5 @@
1
  <script lang="ts">
2
- import type { Message } from "$lib/types/Message";
3
  import { tick } from "svelte";
4
 
5
  import { usePublicConfig } from "$lib/utils/PublicConfig.svelte";
@@ -29,6 +29,15 @@
29
  import { base } from "$app/paths";
30
  import type { PersonaResponse } from "$lib/types/Message";
31
  import { onDestroy } from "svelte";
 
 
 
 
 
 
 
 
 
32
 
33
  interface Props {
34
  message: Message;
@@ -48,9 +57,11 @@
48
  personaName: string;
49
  } | null;
50
  branchPersonas?: string[];
 
51
  onretry?: (payload: { id: Message["id"]; content?: string; personaId?: string }) => void;
52
  onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
53
  onbranch?: (messageId: string, personaId: string) => void;
 
54
  messageBranches?: any[]; // Branches originating from this message
55
  onopenbranchmodal?: (messageId: string, personaId: string, branches: any[]) => void;
56
  }
@@ -69,9 +80,11 @@
69
  personaOccupation,
70
  personaStance,
71
  branchState,
 
72
  onretry,
73
  onshowAlternateMsg,
74
  onbranch,
 
75
  messageBranches = [],
76
  onopenbranchmodal,
77
  }: Props = $props();
@@ -144,6 +157,31 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
144
  }];
145
  });
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  // Multiple cards need horizontal scroll layout
148
  let hasMultipleCards = $derived(responses.length > 1);
149
 
@@ -217,12 +255,12 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
217
 
218
  // If expanding, scroll to show the bottom of the content
219
  if (!isCurrentlyExpanded) {
220
- setTimeout(() => {
221
  const element = contentElements[personaId];
222
  if (element) {
223
  element.scrollIntoView({ behavior: 'smooth', block: 'end' });
224
  }
225
- }, 50);
226
  }
227
  }
228
  }
@@ -307,26 +345,27 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
307
  </script>
308
 
309
  {#if message.from === "assistant"}
310
- <div
311
- bind:offsetWidth={messageWidth}
312
- class="group relative -mb-4 flex max-w-full items-start justify-start gap-4 pb-4 leading-relaxed max-sm:mb-1 {message.routerMetadata &&
313
- messageInfoWidth >= messageWidth
314
- ? 'mb-1'
315
- : ''}"
316
- class:w-full={isPersonaMode}
317
- class:w-fit={!isPersonaMode}
318
- data-message-id={message.id}
319
- data-message-role="assistant"
320
- role="presentation"
321
- onclick={() => (isTapped = !isTapped)}
322
- onkeydown={() => (isTapped = !isTapped)}
323
- >
324
- <MessageAvatar
325
- classNames="mt-5 size-3.5 flex-none select-none rounded-full shadow-lg max-sm:hidden"
326
- animating={isLast && loading}
327
- />
328
-
329
- <div class="flex-1 min-w-0 relative">
 
330
  <!-- Focused mode carousel navigation arrows -->
331
  {#if focusedPersonaId && hasMultipleCards}
332
  {@const currentIndex = responses.findIndex(r => r.personaId === focusedPersonaId)}
@@ -355,7 +394,7 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
355
  {/if}
356
 
357
  <!-- Container: horizontal scroll for multiple cards (unless focused), single card otherwise -->
358
- <div class="{hasMultipleCards && !focusedPersonaId ? 'persona-scroll-container flex gap-3 overflow-x-auto pb-2 px-12' : ''}">
359
  {#if isPersonaMode && responses.length === 0 && isLast && loading}
360
  <!-- Loading state: waiting for personas to start responding -->
361
  <IconLoading classNames="loading inline ml-2" />
@@ -372,6 +411,7 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
372
  <div
373
  class="rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-4 text-gray-600 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300 {hasMultipleCards && !focusedPersonaId ? 'persona-card flex-shrink-0' : ''}"
374
  style={hasMultipleCards && !focusedPersonaId ? `min-width: 320px; max-width: ${isExpanded ? '600px' : '420px'};` : ''}
 
375
  >
376
  <!-- Persona Header: persona name + action buttons -->
377
  <div class="mb-3 flex items-center justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
@@ -392,47 +432,50 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
392
  {/if}
393
 
394
  <div class="flex items-center gap-1">
395
- {#if hasMultipleCards}
396
- <button
397
- type="button"
398
- class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
399
- onclick={(e) => {
400
- e.stopPropagation();
401
- focusedPersonaId === response.personaId ? toggleExpanded(response.personaId) : setFocus(response.personaId);
402
- }}
403
- aria-label={focusedPersonaId === response.personaId ? "Exit focus mode" : "Focus this persona"}
404
- title={focusedPersonaId === response.personaId ? "Show all cards" : "Focus on this card"}
405
- >
406
- {#if focusedPersonaId === response.personaId}
407
- <CarbonMinimize class="text-base" />
408
- {:else}
409
- <CarbonMaximize class="text-base" />
410
- {/if}
411
- </button>
412
- {/if}
413
-
414
- {#if !loading && onretry}
415
- <button
416
- type="button"
417
- class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
418
- onclick={(e) => {
419
- e.stopPropagation();
420
- onretry?.({ id: message.id, personaId: response.personaId });
421
- }}
422
- aria-label="Regenerate {displayName}'s response"
423
- title="Regenerate this response"
424
- >
425
- <CarbonRotate360 class="text-base" />
426
- </button>
427
- {/if}
428
- {#if !loading && onbranch}
429
- {@const isBranchClicked = branchClickedPersonaId === response.personaId}
430
  <button
431
  type="button"
432
  class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
433
  onclick={(e) => {
434
  e.stopPropagation();
435
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  // Trigger animation
437
  branchClickedPersonaId = response.personaId;
438
  if (branchClickTimeout) {
@@ -443,21 +486,22 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
443
  }, 500);
444
 
445
  onbranch?.(message.id, response.personaId);
446
- }}
447
- aria-label="Branch conversation with {displayName}"
448
- title="Start private conversation with {displayName}"
449
- >
450
- <div class="relative transition-transform duration-200 {isBranchClicked ? 'scale-125' : 'scale-100'}">
451
- <CarbonBranch class="text-base" />
452
- </div>
453
- </button>
454
- {/if}
455
- {#if !loading}
456
- <CopyToClipBoardBtn
457
- classNames="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800"
458
- value={response.content}
459
- />
460
- {/if}
 
461
  </div>
462
  </div>
463
 
@@ -518,17 +562,19 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
518
  </div>
519
 
520
  <!-- Branch button for legacy mode (outside card border) -->
521
- {#if !isPersonaMode && (!isLast || !loading) && onbranch && personaName}
522
  {@const branchCount = personaBranches.length}
523
  {@const hasExistingBranches = branchCount > 0}
 
524
 
525
  <div class="mt-1.5 flex items-center justify-end gap-1 px-2">
526
  <button
527
  type="button"
528
- class="flex items-center gap-1 rounded-md px-2 py-1.5 text-sm text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/50 {isBranching ? 'animate-pulse' : ''}"
529
- onclick={handleBranchButtonClick}
 
530
  aria-label={hasExistingBranches ? "Branch options" : "Branch from this response"}
531
- title={hasExistingBranches ? "View or create branch" : "Branch from this response"}
532
  >
533
  <CarbonBranch class="text-xs" />
534
  {#if hasExistingBranches}
@@ -537,8 +583,6 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
537
  </button>
538
  </div>
539
  {/if}
540
- </div>
541
-
542
  {#if message.routerMetadata && (!isLast || !loading)}
543
  <div
544
  class="absolute -bottom-3.5 {message.routerMetadata && messageInfoWidth > messageWidth
@@ -576,6 +620,24 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
576
  {/if}
577
  </div>
578
  {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
579
  </div>
580
  {/if}
581
  {#if message.from === "user"}
@@ -690,21 +752,6 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
690
  /* Fade effect for horizontal scroll container */
691
  .persona-scroll-container {
692
  position: relative;
693
- /* Add gradient mask to fade out cards at edges */
694
- mask-image: linear-gradient(
695
- to right,
696
- transparent 0%,
697
- black 40px,
698
- black calc(100% - 40px),
699
- transparent 100%
700
- );
701
- -webkit-mask-image: linear-gradient(
702
- to right,
703
- transparent 0%,
704
- black 40px,
705
- black calc(100% - 40px),
706
- transparent 100%
707
- );
708
  }
709
 
710
  .persona-scroll-container {
@@ -722,7 +769,7 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
722
 
723
  .persona-scroll-container::-webkit-scrollbar-track {
724
  background: transparent;
725
- margin: 0 48px; /* Match the px-12 padding (3rem = 48px) */
726
  }
727
 
728
  .persona-scroll-container::-webkit-scrollbar-thumb {
 
1
  <script lang="ts">
2
+ import type { Message, MetacognitiveEventType } from "$lib/types/Message";
3
  import { tick } from "svelte";
4
 
5
  import { usePublicConfig } from "$lib/utils/PublicConfig.svelte";
 
29
  import { base } from "$app/paths";
30
  import type { PersonaResponse } from "$lib/types/Message";
31
  import { onDestroy } from "svelte";
32
+ import MetacognitivePrompt from "./MetacognitivePrompt.svelte";
33
+
34
+ type MetacognitivePromptData = {
35
+ type: MetacognitiveEventType;
36
+ promptText: string;
37
+ triggerFrequency: number;
38
+ suggestedPersonaId?: string;
39
+ suggestedPersonaName?: string;
40
+ } | null;
41
 
42
  interface Props {
43
  message: Message;
 
57
  personaName: string;
58
  } | null;
59
  branchPersonas?: string[];
60
+ metacognitivePrompt?: MetacognitivePromptData;
61
  onretry?: (payload: { id: Message["id"]; content?: string; personaId?: string }) => void;
62
  onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
63
  onbranch?: (messageId: string, personaId: string) => void;
64
+ onmetacognitiveaction?: () => void;
65
  messageBranches?: any[]; // Branches originating from this message
66
  onopenbranchmodal?: (messageId: string, personaId: string, branches: any[]) => void;
67
  }
 
80
  personaOccupation,
81
  personaStance,
82
  branchState,
83
+ metacognitivePrompt,
84
  onretry,
85
  onshowAlternateMsg,
86
  onbranch,
87
+ onmetacognitiveaction,
88
  messageBranches = [],
89
  onopenbranchmodal,
90
  }: Props = $props();
 
157
  }];
158
  });
159
 
160
+ // Use local or stored prompt
161
+ let storedMetacognitiveEvent = $derived(
162
+ message.metacognitiveEvents && message.metacognitiveEvents.length > 0
163
+ ? message.metacognitiveEvents[message.metacognitiveEvents.length - 1]
164
+ : null
165
+ );
166
+
167
+ let isMetacognitiveEventAccepted = $derived(storedMetacognitiveEvent?.accepted ?? false);
168
+
169
+ let activeMetacognitivePrompt = $derived.by(() => {
170
+ if (metacognitivePrompt) return metacognitivePrompt;
171
+
172
+ if (storedMetacognitiveEvent) {
173
+ return {
174
+ type: storedMetacognitiveEvent.type,
175
+ promptText: storedMetacognitiveEvent.promptText,
176
+ triggerFrequency: storedMetacognitiveEvent.triggerFrequency,
177
+ suggestedPersonaId: storedMetacognitiveEvent.suggestedPersonaId,
178
+ suggestedPersonaName: storedMetacognitiveEvent.suggestedPersonaName,
179
+ } as MetacognitivePromptData;
180
+ }
181
+
182
+ return null;
183
+ });
184
+
185
  // Multiple cards need horizontal scroll layout
186
  let hasMultipleCards = $derived(responses.length > 1);
187
 
 
255
 
256
  // If expanding, scroll to show the bottom of the content
257
  if (!isCurrentlyExpanded) {
258
+ tick().then(() => {
259
  const element = contentElements[personaId];
260
  if (element) {
261
  element.scrollIntoView({ behavior: 'smooth', block: 'end' });
262
  }
263
+ });
264
  }
265
  }
266
  }
 
345
  </script>
346
 
347
  {#if message.from === "assistant"}
348
+ <div class="w-full">
349
+ <div
350
+ bind:offsetWidth={messageWidth}
351
+ class="group relative -mb-4 flex max-w-full items-start justify-start gap-4 pb-4 leading-relaxed max-sm:mb-1 {message.routerMetadata &&
352
+ messageInfoWidth >= messageWidth
353
+ ? 'mb-1'
354
+ : ''}"
355
+ class:w-full={isPersonaMode}
356
+ class:w-fit={!isPersonaMode}
357
+ data-message-id={message.id}
358
+ data-message-role="assistant"
359
+ role="presentation"
360
+ onclick={() => (isTapped = !isTapped)}
361
+ onkeydown={() => (isTapped = !isTapped)}
362
+ >
363
+ <MessageAvatar
364
+ classNames="mt-5 size-3.5 flex-none select-none rounded-full shadow-lg max-sm:hidden"
365
+ animating={isLast && loading}
366
+ />
367
+
368
+ <div class="flex-1 min-w-0 relative">
369
  <!-- Focused mode carousel navigation arrows -->
370
  {#if focusedPersonaId && hasMultipleCards}
371
  {@const currentIndex = responses.findIndex(r => r.personaId === focusedPersonaId)}
 
394
  {/if}
395
 
396
  <!-- Container: horizontal scroll for multiple cards (unless focused), single card otherwise -->
397
+ <div class="{hasMultipleCards && !focusedPersonaId ? 'persona-scroll-container flex gap-3 overflow-x-auto pb-2' : ''}">
398
  {#if isPersonaMode && responses.length === 0 && isLast && loading}
399
  <!-- Loading state: waiting for personas to start responding -->
400
  <IconLoading classNames="loading inline ml-2" />
 
411
  <div
412
  class="rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-4 text-gray-600 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300 {hasMultipleCards && !focusedPersonaId ? 'persona-card flex-shrink-0' : ''}"
413
  style={hasMultipleCards && !focusedPersonaId ? `min-width: 320px; max-width: ${isExpanded ? '600px' : '420px'};` : ''}
414
+ data-persona-id={response.personaId}
415
  >
416
  <!-- Persona Header: persona name + action buttons -->
417
  <div class="mb-3 flex items-center justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
 
432
  {/if}
433
 
434
  <div class="flex items-center gap-1">
435
+ {#if hasMultipleCards}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  <button
437
  type="button"
438
  class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
439
  onclick={(e) => {
440
  e.stopPropagation();
441
+ focusedPersonaId === response.personaId ? toggleExpanded(response.personaId) : setFocus(response.personaId);
442
+ }}
443
+ aria-label={focusedPersonaId === response.personaId ? "Exit focus mode" : "Focus this persona"}
444
+ title={focusedPersonaId === response.personaId ? "Show all cards" : "Focus on this card"}
445
+ >
446
+ {#if focusedPersonaId === response.personaId}
447
+ <CarbonMinimize class="text-base" />
448
+ {:else}
449
+ <CarbonMaximize class="text-base" />
450
+ {/if}
451
+ </button>
452
+ {/if}
453
+
454
+ {#if onretry}
455
+ <button
456
+ type="button"
457
+ class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors {loading ? 'opacity-50 cursor-not-allowed' : ''}"
458
+ onclick={(e) => {
459
+ e.stopPropagation();
460
+ if (!loading) {
461
+ onretry?.({ id: message.id, personaId: response.personaId });
462
+ }
463
+ }}
464
+ disabled={loading}
465
+ aria-label="Regenerate {displayName}'s response"
466
+ title={loading ? "Please wait for current response to complete" : "Regenerate this response"}
467
+ >
468
+ <CarbonRotate360 class="text-base" />
469
+ </button>
470
+ {/if}
471
+ {#if onbranch}
472
+ {@const isBranchClicked = branchClickedPersonaId === response.personaId}
473
+ <button
474
+ type="button"
475
+ class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors {loading ? 'opacity-50 cursor-not-allowed' : ''}"
476
+ onclick={(e) => {
477
+ e.stopPropagation();
478
+ if (!loading) {
479
  // Trigger animation
480
  branchClickedPersonaId = response.personaId;
481
  if (branchClickTimeout) {
 
486
  }, 500);
487
 
488
  onbranch?.(message.id, response.personaId);
489
+ }
490
+ }}
491
+ disabled={loading}
492
+ aria-label="Branch conversation with {displayName}"
493
+ title={loading ? "Please wait for current response to complete" : "Start private conversation with {displayName}"}
494
+ >
495
+ <div class="relative transition-transform duration-200 {isBranchClicked ? 'scale-125' : 'scale-100'}">
496
+ <CarbonBranch class="text-base" />
497
+ </div>
498
+ </button>
499
+ {/if}
500
+ <CopyToClipBoardBtn
501
+ classNames="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 {loading ? 'opacity-50 cursor-not-allowed' : ''}"
502
+ value={response.content}
503
+ disabled={loading}
504
+ />
505
  </div>
506
  </div>
507
 
 
562
  </div>
563
 
564
  <!-- Branch button for legacy mode (outside card border) -->
565
+ {#if !isPersonaMode && onbranch && personaName}
566
  {@const branchCount = personaBranches.length}
567
  {@const hasExistingBranches = branchCount > 0}
568
+ {@const isDisabled = isLast && loading}
569
 
570
  <div class="mt-1.5 flex items-center justify-end gap-1 px-2">
571
  <button
572
  type="button"
573
+ class="flex items-center gap-1 rounded-md px-2 py-1.5 text-sm text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/50 {isBranching ? 'animate-pulse' : ''} {isDisabled ? 'opacity-50 cursor-not-allowed' : ''}"
574
+ onclick={() => !isDisabled && handleBranchButtonClick()}
575
+ disabled={isDisabled}
576
  aria-label={hasExistingBranches ? "Branch options" : "Branch from this response"}
577
+ title={isDisabled ? "Please wait for current response to complete" : (hasExistingBranches ? "View or create branch" : "Branch from this response")}
578
  >
579
  <CarbonBranch class="text-xs" />
580
  {#if hasExistingBranches}
 
583
  </button>
584
  </div>
585
  {/if}
 
 
586
  {#if message.routerMetadata && (!isLast || !loading)}
587
  <div
588
  class="absolute -bottom-3.5 {message.routerMetadata && messageInfoWidth > messageWidth
 
620
  {/if}
621
  </div>
622
  {/if}
623
+ </div>
624
+ </div>
625
+
626
+ {#if activeMetacognitivePrompt}
627
+ <!-- Render below the response block -->
628
+ <!-- Only hide during initial loading (no stored event yet) -->
629
+ {#if !isLast || !loading || storedMetacognitiveEvent}
630
+ <div class="max-sm:pl-0 sm:pl-[1.875rem]">
631
+ <MetacognitivePrompt
632
+ promptType={activeMetacognitivePrompt.type}
633
+ promptText={activeMetacognitivePrompt.promptText}
634
+ suggestedPersonaName={activeMetacognitivePrompt.suggestedPersonaName}
635
+ onAction={() => onmetacognitiveaction?.()}
636
+ isClicked={isMetacognitiveEventAccepted}
637
+ />
638
+ </div>
639
+ {/if}
640
+ {/if}
641
  </div>
642
  {/if}
643
  {#if message.from === "user"}
 
752
  /* Fade effect for horizontal scroll container */
753
  .persona-scroll-container {
754
  position: relative;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
  }
756
 
757
  .persona-scroll-container {
 
769
 
770
  .persona-scroll-container::-webkit-scrollbar-track {
771
  background: transparent;
772
+ margin: 0;
773
  }
774
 
775
  .persona-scroll-container::-webkit-scrollbar-thumb {
src/lib/components/chat/ChatWindow.svelte CHANGED
@@ -1,5 +1,7 @@
1
  <script lang="ts">
2
- import type { Message, MessageFile } from "$lib/types/Message";
 
 
3
  import { onDestroy, tick } from "svelte";
4
 
5
  import IconOmni from "$lib/components/icons/IconOmni.svelte";
@@ -11,6 +13,7 @@
11
  import StopGeneratingBtn from "../StopGeneratingBtn.svelte";
12
  import type { Model } from "$lib/types/Model";
13
  import { page } from "$app/state";
 
14
  import FileDropzone from "./FileDropzone.svelte";
15
  import RetryBtn from "../RetryBtn.svelte";
16
  import file2base64 from "$lib/utils/file2base64";
@@ -24,6 +27,7 @@
24
  import ChatIntroduction from "./ChatIntroduction.svelte";
25
  import UploadedFile from "./UploadedFile.svelte";
26
  import { useSettingsStore } from "$lib/stores/settings";
 
27
  import ModelSwitch from "./ModelSwitch.svelte";
28
  import { routerExamples } from "$lib/constants/routerExamples";
29
  import type { RouterFollowUp, RouterExample } from "$lib/constants/routerExamples";
@@ -35,6 +39,7 @@
35
  import { loginModalOpen } from "$lib/stores/loginModal";
36
 
37
  import { isVirtualKeyboard } from "$lib/utils/isVirtualKeyboard";
 
38
 
39
  interface Props {
40
  messages?: Message[];
@@ -53,12 +58,18 @@
53
  personaName: string;
54
  } | null;
55
  files?: File[];
 
 
 
 
 
56
  onmessage?: (content: string) => void;
57
  onstop?: () => void;
58
  onretry?: (payload: { id: Message["id"]; content?: string; personaId?: string }) => void;
59
  oncontinue?: (payload: { id: Message["id"] }) => void;
60
  onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
61
  onbranch?: (messageId: string, personaId: string) => void;
 
62
  }
63
 
64
  let {
@@ -74,12 +85,15 @@
74
  lockedPersonaId,
75
  branchState,
76
  files = $bindable([]),
 
 
77
  onmessage,
78
  onstop,
79
  onretry,
80
  oncontinue,
81
  onshowAlternateMsg,
82
  onbranch,
 
83
  }: Props = $props();
84
 
85
  let isReadOnly = $derived(!models.some((model) => model.id === currentModel.id));
@@ -111,6 +125,18 @@
111
  return branchPoints;
112
  });
113
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  let message: string = $state("");
115
  let shareModalOpen = $state(false);
116
  let editMsdgId: Message["id"] | null = $state(null);
@@ -179,7 +205,9 @@
179
  let lastMessage = $derived(browser && (messages.at(-1) as Message));
180
  let scrollSignal = $derived.by(() => {
181
  const last = messages.at(-1) as Message | undefined;
182
- return last ? `${last.id}:${last.content.length}:${messages.length}` : `${messages.length}:0`;
 
 
183
  });
184
  let lastIsError = $derived(
185
  lastMessage &&
@@ -385,7 +413,7 @@
385
 
386
  {#if messages.length > 0}
387
  <div class="flex h-max flex-col gap-8 pb-52">
388
- {#each messages as message, idx (message.id)}
389
  <ChatMessage
390
  {loading}
391
  {message}
@@ -398,10 +426,12 @@
398
  personaStance={message.from === "assistant" && !message.personaResponses ? persona?.stance : undefined}
399
  {branchState}
400
  branchPersonas={branchPointInfo.get(message.id) ?? []}
 
401
  bind:editMsdgId
402
  onretry={(payload) => onretry?.(payload)}
403
  onshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)}
404
  onbranch={(messageId, personaId) => onbranch?.(messageId, personaId)}
 
405
  />
406
  {/each}
407
  {#if isReadOnly}
 
1
  <script lang="ts">
2
+ import type { Message, MessageFile, MetacognitiveEvent, MetacognitiveEventType } from "$lib/types/Message";
3
+ import type { MetacognitiveConfig, MetacognitivePromptData } from "$lib/types/Metacognitive";
4
+ import { determineMetacognitivePrompt } from "$lib/utils/metacognitiveLogic";
5
  import { onDestroy, tick } from "svelte";
6
 
7
  import IconOmni from "$lib/components/icons/IconOmni.svelte";
 
13
  import StopGeneratingBtn from "../StopGeneratingBtn.svelte";
14
  import type { Model } from "$lib/types/Model";
15
  import { page } from "$app/state";
16
+ import { goto } from "$app/navigation";
17
  import FileDropzone from "./FileDropzone.svelte";
18
  import RetryBtn from "../RetryBtn.svelte";
19
  import file2base64 from "$lib/utils/file2base64";
 
27
  import ChatIntroduction from "./ChatIntroduction.svelte";
28
  import UploadedFile from "./UploadedFile.svelte";
29
  import { useSettingsStore } from "$lib/stores/settings";
30
+ import { useMetacognitiveEngine } from "$lib/hooks/useMetacognitiveEngine.svelte";
31
  import ModelSwitch from "./ModelSwitch.svelte";
32
  import { routerExamples } from "$lib/constants/routerExamples";
33
  import type { RouterFollowUp, RouterExample } from "$lib/constants/routerExamples";
 
39
  import { loginModalOpen } from "$lib/stores/loginModal";
40
 
41
  import { isVirtualKeyboard } from "$lib/utils/isVirtualKeyboard";
42
+ import superjson from "superjson";
43
 
44
  interface Props {
45
  messages?: Message[];
 
58
  personaName: string;
59
  } | null;
60
  files?: File[];
61
+ metacognitiveConfig?: MetacognitiveConfig;
62
+ metacognitiveState?: {
63
+ targetFrequency?: number;
64
+ lastPromptedAtMessageId?: string | null;
65
+ };
66
  onmessage?: (content: string) => void;
67
  onstop?: () => void;
68
  onretry?: (payload: { id: Message["id"]; content?: string; personaId?: string }) => void;
69
  oncontinue?: (payload: { id: Message["id"] }) => void;
70
  onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
71
  onbranch?: (messageId: string, personaId: string) => void;
72
+ onmetacognitivebranch?: (messageId: string, personaId: string, promptData: MetacognitivePromptData) => void;
73
  }
74
 
75
  let {
 
85
  lockedPersonaId,
86
  branchState,
87
  files = $bindable([]),
88
+ metacognitiveConfig,
89
+ metacognitiveState,
90
  onmessage,
91
  onstop,
92
  onretry,
93
  oncontinue,
94
  onshowAlternateMsg,
95
  onbranch,
96
+ onmetacognitivebranch,
97
  }: Props = $props();
98
 
99
  let isReadOnly = $derived(!models.some((model) => model.id === currentModel.id));
 
125
  return branchPoints;
126
  });
127
 
128
+ const metacognitiveEngine = useMetacognitiveEngine(() => ({
129
+ messages,
130
+ loading,
131
+ pending,
132
+ metacognitiveConfig,
133
+ metacognitiveState,
134
+ userSettings: $userSettings,
135
+ onmetacognitivebranch
136
+ }));
137
+
138
+ let activeMetacognitivePrompt = $derived(metacognitiveEngine.activeMetacognitivePrompt);
139
+
140
  let message: string = $state("");
141
  let shareModalOpen = $state(false);
142
  let editMsdgId: Message["id"] | null = $state(null);
 
205
  let lastMessage = $derived(browser && (messages.at(-1) as Message));
206
  let scrollSignal = $derived.by(() => {
207
  const last = messages.at(-1) as Message | undefined;
208
+ return last
209
+ ? `${last.id}:${last.content.length}:${messages.length}:${loading}:${activeMetacognitivePrompt?.messageId ?? ""}`
210
+ : `${messages.length}:0:${loading}:${activeMetacognitivePrompt?.messageId ?? ""}`;
211
  });
212
  let lastIsError = $derived(
213
  lastMessage &&
 
413
 
414
  {#if messages.length > 0}
415
  <div class="flex h-max flex-col gap-8 pb-52">
416
+ {#each messages as message, idx (idx)}
417
  <ChatMessage
418
  {loading}
419
  {message}
 
426
  personaStance={message.from === "assistant" && !message.personaResponses ? persona?.stance : undefined}
427
  {branchState}
428
  branchPersonas={branchPointInfo.get(message.id) ?? []}
429
+ metacognitivePrompt={idx === messages.length - 1 && activeMetacognitivePrompt?.messageId === message.id ? activeMetacognitivePrompt : null}
430
  bind:editMsdgId
431
  onretry={(payload) => onretry?.(payload)}
432
  onshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)}
433
  onbranch={(messageId, personaId) => onbranch?.(messageId, personaId)}
434
+ onmetacognitiveaction={() => metacognitiveEngine.handleMetacognitiveAction(message.id)}
435
  />
436
  {/each}
437
  {#if isReadOnly}
src/lib/components/chat/MetacognitivePrompt.svelte ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import type { MetacognitiveEventType } from "$lib/types/Message";
3
+ import CarbonChat from "~icons/carbon/chat";
4
+ import CarbonUser from "~icons/carbon/user";
5
+
6
+ interface Props {
7
+ promptType: MetacognitiveEventType;
8
+ promptText: string;
9
+ suggestedPersonaName?: string;
10
+ isClicked?: boolean;
11
+ onAction?: () => void;
12
+ }
13
+
14
+ let { promptType, promptText, suggestedPersonaName, isClicked = false, onAction }: Props = $props();
15
+
16
+ let isHovered = $state(false);
17
+
18
+ function handleClick() {
19
+ if (promptType === "perspective" && onAction) {
20
+ onAction();
21
+ }
22
+ }
23
+
24
+ // Compute dynamic classes to avoid Svelte class: directive issues with Tailwind dark: prefix
25
+ let containerClasses = $derived.by(() => {
26
+ let classes = "metacognitive-prompt mt-2 mb-1 flex w-full items-start gap-2.5 rounded-lg border border-gray-200/60 bg-gray-50/50 px-3 py-2.5 text-sm shadow-sm transition-all duration-200 dark:border-gray-700/40 dark:bg-gray-800/30";
27
+
28
+ if (promptType === "perspective") {
29
+ classes += " cursor-pointer hover:border-gray-300 hover:bg-gray-100/50 hover:shadow-md dark:hover:border-gray-600 dark:hover:bg-gray-800/50";
30
+ }
31
+
32
+ return classes;
33
+ });
34
+
35
+ let badgeClasses = $derived.by(() => {
36
+ let classes = "inline-flex w-fit items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium text-gray-600 transition-colors dark:text-gray-400";
37
+
38
+ if (isHovered) {
39
+ classes += " bg-gray-200/70 dark:bg-gray-700/60";
40
+ } else {
41
+ classes += " bg-gray-100 dark:bg-gray-800/60";
42
+ }
43
+
44
+ return classes;
45
+ });
46
+ </script>
47
+
48
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
49
+ <div
50
+ class={containerClasses}
51
+ role={promptType === "perspective" ? "button" : "note"}
52
+ tabindex={promptType === "perspective" ? 0 : -1}
53
+ onmouseenter={() => (isHovered = true)}
54
+ onmouseleave={() => (isHovered = false)}
55
+ onclick={handleClick}
56
+ onkeydown={(e) => e.key === "Enter" && handleClick()}
57
+ >
58
+ <div class="mt-0.5 shrink-0">
59
+ {#if promptType === "comprehension"}
60
+ <CarbonChat
61
+ class="h-4 w-4 text-gray-500 dark:text-gray-400"
62
+ />
63
+ {:else}
64
+ <CarbonUser
65
+ class="h-4 w-4 text-gray-500 transition-transform duration-200 dark:text-gray-400 {isHovered ? 'scale-110' : ''}"
66
+ />
67
+ {/if}
68
+ </div>
69
+
70
+ <div class="flex flex-col gap-1">
71
+ <p class="leading-relaxed text-gray-700 dark:text-gray-300">
72
+ {promptText}
73
+ </p>
74
+
75
+ {#if promptType === "perspective" && suggestedPersonaName}
76
+ <span class={badgeClasses}>
77
+ {isClicked ? `View what ${suggestedPersonaName} said` : `Click to hear from ${suggestedPersonaName}`}
78
+ </span>
79
+ {/if}
80
+ </div>
81
+ </div>
82
+
83
+ <style>
84
+ .metacognitive-prompt {
85
+ animation: fadeSlideIn 0.3s ease-out;
86
+ }
87
+
88
+ @keyframes fadeSlideIn {
89
+ from {
90
+ opacity: 0;
91
+ transform: translateY(8px);
92
+ }
93
+ to {
94
+ opacity: 1;
95
+ transform: translateY(0);
96
+ }
97
+ }
98
+ </style>
src/lib/constants/treeConfig.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ export const TREE_CONFIG = {
2
+ nodeSize: 24,
3
+ iconSize: 18,
4
+ spacing: 8,
5
+ minWidth: 100,
6
+ minHeight: 50,
7
+ };
src/lib/hooks/useMetacognitiveEngine.svelte.ts ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { page } from "$app/state";
2
+ import { goto } from "$app/navigation";
3
+ import { base } from "$app/paths";
4
+ import { browser } from "$app/environment";
5
+ import superjson from "superjson";
6
+ import type { Message, MetacognitiveEvent } from "$lib/types/Message";
7
+ import type { MetacognitiveConfig, MetacognitivePromptData } from "$lib/types/Metacognitive";
8
+ import { determineMetacognitivePrompt } from "$lib/utils/metacognitiveLogic";
9
+
10
+ interface MetacognitiveSettings {
11
+ activePersonas?: string[];
12
+ personas?: Array<{ id: string; name?: string }>;
13
+ }
14
+
15
+ interface UseMetacognitiveEngineProps {
16
+ messages: Message[];
17
+ loading: boolean;
18
+ pending: boolean;
19
+ metacognitiveConfig?: MetacognitiveConfig;
20
+ metacognitiveState?: {
21
+ targetFrequency?: number;
22
+ lastPromptedAtMessageId?: string | null;
23
+ };
24
+ userSettings: MetacognitiveSettings;
25
+ onmetacognitivebranch?: (
26
+ messageId: string,
27
+ personaId: string,
28
+ promptData: MetacognitivePromptData
29
+ ) => void;
30
+ }
31
+
32
+ export function useMetacognitiveEngine(props: () => UseMetacognitiveEngineProps) {
33
+ let metacognitiveTargetFrequency = $state<number | null>(null);
34
+ let metacognitiveLastPromptedAtMessageId = $state<string | null>(null);
35
+ let metacognitivePromptDismissedForMessageId = $state<string | null>(null);
36
+
37
+ let activeMetacognitivePrompt = $state<MetacognitivePromptData | null>(null);
38
+ let lastProcessedMessageId = $state<string | null>(null);
39
+ let promptGenerationTimeout: ReturnType<typeof setTimeout> | undefined = $state();
40
+
41
+ // Cache generated prompts to prevent regeneration with different random values
42
+ const promptCache = new Map<string, MetacognitivePromptData | null>();
43
+ let lastMessageCount = 0;
44
+
45
+ // Initialize from server state
46
+ $effect(() => {
47
+ const { metacognitiveConfig, metacognitiveState, messages } = props();
48
+ if (!metacognitiveConfig?.enabled) {
49
+ return;
50
+ }
51
+ if (metacognitiveState?.targetFrequency && metacognitiveTargetFrequency === null) {
52
+ metacognitiveTargetFrequency = metacognitiveState.targetFrequency;
53
+ }
54
+ if (
55
+ metacognitiveState?.lastPromptedAtMessageId !== undefined &&
56
+ metacognitiveLastPromptedAtMessageId === null
57
+ ) {
58
+ metacognitiveLastPromptedAtMessageId = metacognitiveState.lastPromptedAtMessageId ?? null;
59
+ }
60
+
61
+ // Clear cache if messages array was replaced (e.g., navigation to different conversation)
62
+ if (messages.length < lastMessageCount) {
63
+ promptCache.clear();
64
+ }
65
+ lastMessageCount = messages.length;
66
+
67
+ // Populate cache from server-loaded events to prevent regeneration
68
+ messages.forEach((msg) => {
69
+ if (msg.from === "assistant" && msg.metacognitiveEvents?.length && !promptCache.has(msg.id)) {
70
+ const event = msg.metacognitiveEvents[msg.metacognitiveEvents.length - 1];
71
+ promptCache.set(msg.id, {
72
+ type: event.type,
73
+ promptText: event.promptText,
74
+ triggerFrequency: event.triggerFrequency,
75
+ suggestedPersonaId: event.suggestedPersonaId,
76
+ suggestedPersonaName: event.suggestedPersonaName,
77
+ messageId: msg.id,
78
+ });
79
+ }
80
+ });
81
+ });
82
+
83
+ function persistPromptShownEvent(lastMessage: Message, prompt: MetacognitivePromptData) {
84
+ if (!prompt) return;
85
+ if (lastMessage.from !== "assistant") return;
86
+ if (lastMessage.metacognitiveEvents?.length) return;
87
+
88
+ const eventData: MetacognitiveEvent = {
89
+ type: prompt.type,
90
+ promptText: prompt.promptText,
91
+ triggerFrequency: prompt.triggerFrequency,
92
+ suggestedPersonaId: prompt.suggestedPersonaId,
93
+ suggestedPersonaName: prompt.suggestedPersonaName,
94
+ timestamp: new Date(),
95
+ accepted: false,
96
+ };
97
+
98
+ // Defensive copy using structuredClone
99
+ const cleanEventData = structuredClone(eventData);
100
+
101
+ // Immediately persist locally to prevent race conditions
102
+ lastMessage.metacognitiveEvents = [cleanEventData];
103
+ metacognitiveLastPromptedAtMessageId = lastMessage.id;
104
+
105
+ // Ensure cache reflects persisted state
106
+ promptCache.set(lastMessage.id, prompt);
107
+
108
+ // Force message update in parent if needed
109
+ if (!browser || !page.params.id) return;
110
+
111
+ (async () => {
112
+ try {
113
+ const response = await fetch(
114
+ `${base}/api/v2/conversations/${page.params.id}/message/${lastMessage.id}/metacognitive-event`,
115
+ {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({
119
+ type: prompt.type,
120
+ promptText: prompt.promptText,
121
+ triggerFrequency: prompt.triggerFrequency,
122
+ suggestedPersonaId: prompt.suggestedPersonaId,
123
+ suggestedPersonaName: prompt.suggestedPersonaName,
124
+ accepted: false,
125
+ }),
126
+ }
127
+ );
128
+
129
+ if (!response.ok) {
130
+ console.error("Failed to log metacognitive prompt shown event:", response.status);
131
+ return;
132
+ }
133
+
134
+ const parsed = superjson.parse(await response.text()) as {
135
+ nextTargetFrequency?: number;
136
+ };
137
+
138
+ if (parsed.nextTargetFrequency) {
139
+ metacognitiveTargetFrequency = parsed.nextTargetFrequency;
140
+ }
141
+ } catch (e) {
142
+ console.error("Failed to log metacognitive prompt shown event:", e);
143
+ }
144
+ })();
145
+ }
146
+
147
+ $effect(() => {
148
+ const { messages, loading, pending, metacognitiveConfig, userSettings } = props();
149
+
150
+ if (!metacognitiveConfig?.enabled) {
151
+ activeMetacognitivePrompt = null;
152
+ return;
153
+ }
154
+
155
+ const lastMessage = messages[messages.length - 1];
156
+
157
+ if (!lastMessage || lastMessage.from !== "assistant") {
158
+ activeMetacognitivePrompt = null;
159
+ if (promptGenerationTimeout) {
160
+ clearTimeout(promptGenerationTimeout);
161
+ promptGenerationTimeout = undefined;
162
+ }
163
+ return;
164
+ }
165
+
166
+ if (activeMetacognitivePrompt && activeMetacognitivePrompt.messageId !== lastMessage.id) {
167
+ activeMetacognitivePrompt = null;
168
+ }
169
+
170
+ if (loading || pending) {
171
+ if (promptGenerationTimeout) {
172
+ clearTimeout(promptGenerationTimeout);
173
+ promptGenerationTimeout = undefined;
174
+ }
175
+ return;
176
+ }
177
+
178
+ if (lastMessage.metacognitiveEvents?.length) {
179
+ lastProcessedMessageId = lastMessage.id;
180
+ return;
181
+ }
182
+
183
+ if (lastProcessedMessageId === lastMessage.id) {
184
+ return;
185
+ }
186
+
187
+ if (!promptGenerationTimeout) {
188
+ promptGenerationTimeout = setTimeout(() => {
189
+ promptGenerationTimeout = undefined;
190
+ const currentMessages = props().messages;
191
+ const currentLast = currentMessages[currentMessages.length - 1];
192
+
193
+ if (!currentLast || currentLast.id !== lastMessage.id) return;
194
+ if (props().loading || props().pending) return;
195
+
196
+ lastProcessedMessageId = currentLast.id;
197
+ if (currentLast.metacognitiveEvents?.length) return;
198
+
199
+ // Check cache first to ensure immutability
200
+ let prompt = promptCache.get(currentLast.id);
201
+ if (prompt === undefined) {
202
+ // Only generate if not cached
203
+ prompt = determineMetacognitivePrompt(
204
+ currentMessages,
205
+ metacognitiveConfig,
206
+ {
207
+ dismissedForMessageId: metacognitivePromptDismissedForMessageId || undefined,
208
+ targetFrequency: metacognitiveTargetFrequency || undefined,
209
+ },
210
+ {
211
+ activePersonas: userSettings.activePersonas,
212
+ personas: userSettings.personas,
213
+ }
214
+ );
215
+ // Cache the result (even if null) to prevent regeneration
216
+ promptCache.set(currentLast.id, prompt);
217
+ }
218
+
219
+ if (prompt && prompt.messageId === currentLast.id) {
220
+ persistPromptShownEvent(currentLast, prompt);
221
+ activeMetacognitivePrompt = prompt;
222
+ } else {
223
+ activeMetacognitivePrompt = null;
224
+ }
225
+ }, 1000);
226
+ }
227
+ });
228
+
229
+ function handleMetacognitiveAction(messageId?: string, data?: MetacognitivePromptData) {
230
+ const { messages, onmetacognitivebranch } = props();
231
+ let promptData = data;
232
+ let targetMessageIndex = messages.length - 1;
233
+
234
+ if (messageId) {
235
+ const idx = messages.findIndex((m) => m.id === messageId);
236
+ if (idx !== -1) {
237
+ targetMessageIndex = idx;
238
+ const msg = messages[idx];
239
+
240
+ if (msg.metacognitiveEvents?.length) {
241
+ const event = msg.metacognitiveEvents.find((e) => e.type === "perspective");
242
+ if (event) {
243
+ event.accepted = true;
244
+ // Trigger update in UI? The object is mutated.
245
+ promptData = {
246
+ type: event.type,
247
+ promptText: event.promptText,
248
+ triggerFrequency: event.triggerFrequency,
249
+ suggestedPersonaId: event.suggestedPersonaId,
250
+ suggestedPersonaName: event.suggestedPersonaName,
251
+ messageId: msg.id,
252
+ linkedMessageId: event.linkedMessageId,
253
+ };
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ if (!promptData) {
260
+ if (activeMetacognitivePrompt) {
261
+ if (!messageId || activeMetacognitivePrompt.messageId === messageId) {
262
+ promptData = activeMetacognitivePrompt;
263
+ }
264
+ }
265
+ }
266
+
267
+ if (!promptData || promptData.type !== "perspective") {
268
+ return;
269
+ }
270
+
271
+ if (promptData.linkedMessageId) {
272
+ const url = new URL(window.location.href);
273
+ url.searchParams.set("msgId", promptData.linkedMessageId);
274
+ url.searchParams.set("scrollTo", "true");
275
+ goto(url.toString(), { replaceState: false, noScroll: true });
276
+ return;
277
+ }
278
+
279
+ const targetMessage = messages[targetMessageIndex];
280
+ if (!targetMessage) return;
281
+
282
+ let previousUserMessageId: string | null = null;
283
+ for (let i = targetMessageIndex; i >= 0; i--) {
284
+ if (messages[i].from === "user") {
285
+ previousUserMessageId = messages[i].id;
286
+ break;
287
+ }
288
+ }
289
+
290
+ if (!previousUserMessageId || !promptData.suggestedPersonaId) {
291
+ return;
292
+ }
293
+
294
+ if (targetMessageIndex === messages.length - 1) {
295
+ metacognitivePromptDismissedForMessageId = targetMessage.id;
296
+ }
297
+
298
+ onmetacognitivebranch?.(previousUserMessageId, promptData.suggestedPersonaId, promptData);
299
+ }
300
+
301
+ return {
302
+ get activeMetacognitivePrompt() {
303
+ return activeMetacognitivePrompt;
304
+ },
305
+ handleMetacognitiveAction,
306
+ };
307
+ }
src/lib/server/api/routes/groups/conversations.ts CHANGED
@@ -6,6 +6,9 @@ import { authCondition } from "$lib/server/auth";
6
  import { validModelIdSchema } from "$lib/server/models";
7
  import { convertLegacyConversation } from "$lib/utils/tree/convertLegacyConversation";
8
  import type { Conversation } from "$lib/types/Conversation";
 
 
 
9
 
10
  import { CONV_NUM_PER_PAGE } from "$lib/constants/pagination";
11
 
@@ -75,6 +78,7 @@ export const conversationGroup = new Elysia().use(authPlugin).group("/conversati
75
  .derive(async ({ locals, params }) => {
76
  let conversation;
77
  let shared = false;
 
78
 
79
  // if the conver
80
  if (params.id.length === 7) {
@@ -113,6 +117,23 @@ export const conversationGroup = new Elysia().use(authPlugin).group("/conversati
113
 
114
  throw new Error("Conversation not found.");
115
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  }
117
 
118
  const convertedConv = {
@@ -134,6 +155,7 @@ export const conversationGroup = new Elysia().use(authPlugin).group("/conversati
134
  updatedAt: conversation.updatedAt,
135
  modelId: conversation.model,
136
  shared: conversation.shared,
 
137
  };
138
  })
139
  .post("", () => {
@@ -251,6 +273,96 @@ export const conversationGroup = new Elysia().use(authPlugin).group("/conversati
251
  messageId: t.String(),
252
  }),
253
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  );
255
  }
256
  )
 
6
  import { validModelIdSchema } from "$lib/server/models";
7
  import { convertLegacyConversation } from "$lib/utils/tree/convertLegacyConversation";
8
  import type { Conversation } from "$lib/types/Conversation";
9
+ import type { MetacognitiveEvent } from "$lib/types/Message";
10
+ import { logger } from "$lib/server/logger";
11
+ import { getMetacognitiveConfig, selectRandomFrequency } from "$lib/server/metacognitiveConfig";
12
 
13
  import { CONV_NUM_PER_PAGE } from "$lib/constants/pagination";
14
 
 
78
  .derive(async ({ locals, params }) => {
79
  let conversation;
80
  let shared = false;
81
+ const metacognitiveConfig = getMetacognitiveConfig();
82
 
83
  // if the conver
84
  if (params.id.length === 7) {
 
117
 
118
  throw new Error("Conversation not found.");
119
  }
120
+
121
+ // Initialize metacognitiveState server-side so targetFrequency is consistent across refresh/devices.
122
+ if (metacognitiveConfig.enabled) {
123
+ const existingTarget = conversation.metacognitiveState?.targetFrequency;
124
+ if (!existingTarget) {
125
+ const metacognitiveState = {
126
+ targetFrequency: selectRandomFrequency(),
127
+ lastPromptedAtMessageId: null,
128
+ updatedAt: new Date(),
129
+ };
130
+ await collections.conversations.updateOne(
131
+ { _id: new ObjectId(params.id), ...authCondition(locals) },
132
+ { $set: { metacognitiveState } }
133
+ );
134
+ conversation.metacognitiveState = metacognitiveState;
135
+ }
136
+ }
137
  }
138
 
139
  const convertedConv = {
 
155
  updatedAt: conversation.updatedAt,
156
  modelId: conversation.model,
157
  shared: conversation.shared,
158
+ metacognitiveState: (conversation as Conversation).metacognitiveState,
159
  };
160
  })
161
  .post("", () => {
 
273
  messageId: t.String(),
274
  }),
275
  }
276
+ )
277
+ .post(
278
+ "/message/:messageId/metacognitive-event",
279
+ async ({ locals, params, body, conversation }) => {
280
+ // Find the message in the conversation
281
+ const messageIndex = conversation.messages.findIndex(
282
+ (m) => m.id === params.messageId
283
+ );
284
+ if (messageIndex === -1) {
285
+ throw new Error("Message not found");
286
+ }
287
+
288
+ // Create the metacognitive event
289
+ const event: MetacognitiveEvent = {
290
+ type: body.type as "comprehension" | "perspective",
291
+ promptText: body.promptText,
292
+ triggerFrequency: body.triggerFrequency,
293
+ timestamp: new Date(),
294
+ suggestedPersonaId: body.suggestedPersonaId,
295
+ suggestedPersonaName: body.suggestedPersonaName,
296
+ accepted: body.accepted,
297
+ };
298
+
299
+ // Log the event for research tracking
300
+ logger.info(
301
+ {
302
+ conversationId: params.id,
303
+ messageId: params.messageId,
304
+ eventType: event.type,
305
+ triggerFrequency: event.triggerFrequency,
306
+ accepted: event.accepted,
307
+ suggestedPersonaId: event.suggestedPersonaId,
308
+ },
309
+ "Metacognitive prompt event"
310
+ );
311
+
312
+ // Update the message with the new event
313
+ const updatePath = `messages.${messageIndex}.metacognitiveEvents`;
314
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
315
+ const updateDoc: Record<string, any> = {
316
+ $push: { [updatePath]: event },
317
+ };
318
+
319
+ // If this is a shown event, persist metacognitive state so
320
+ // targetFrequency stays consistent across refresh/devices and the counter can reset.
321
+ let nextTargetFrequency: number | undefined;
322
+ if (body.accepted === false) {
323
+ const metacognitiveConfig = getMetacognitiveConfig();
324
+ if (metacognitiveConfig.enabled) {
325
+ nextTargetFrequency = selectRandomFrequency();
326
+ updateDoc.$set = {
327
+ "metacognitiveState.lastPromptedAtMessageId": params.messageId,
328
+ "metacognitiveState.targetFrequency": nextTargetFrequency,
329
+ "metacognitiveState.updatedAt": new Date(),
330
+ };
331
+ }
332
+ }
333
+
334
+ const res = await collections.conversations.updateOne(
335
+ {
336
+ _id: new ObjectId(params.id),
337
+ ...authCondition(locals),
338
+ },
339
+ updateDoc
340
+ );
341
+
342
+ if (res.modifiedCount === 0) {
343
+ throw new Error("Failed to log metacognitive event");
344
+ }
345
+
346
+ return {
347
+ success: true,
348
+ event,
349
+ nextTargetFrequency,
350
+ };
351
+ },
352
+ {
353
+ params: t.Object({
354
+ id: t.String(),
355
+ messageId: t.String(),
356
+ }),
357
+ body: t.Object({
358
+ type: t.Union([t.Literal("comprehension"), t.Literal("perspective")]),
359
+ promptText: t.String(),
360
+ triggerFrequency: t.Number(),
361
+ suggestedPersonaId: t.Optional(t.String()),
362
+ suggestedPersonaName: t.Optional(t.String()),
363
+ accepted: t.Boolean(),
364
+ }),
365
+ }
366
  );
367
  }
368
  )
src/lib/server/api/routes/groups/misc.ts CHANGED
@@ -9,6 +9,7 @@ import yazl from "yazl";
9
  import { downloadFile } from "$lib/server/files/downloadFile";
10
  import mimeTypes from "mime-types";
11
  import { logger } from "$lib/server/logger";
 
12
 
13
  export interface FeatureFlags {
14
  enableAssistants: boolean;
@@ -23,6 +24,15 @@ export type ApiReturnType = Awaited<ReturnType<typeof Client.prototype.view_api>
23
  export const misc = new Elysia()
24
  .use(authPlugin)
25
  .get("/public-config", async () => config.getPublicConfig())
 
 
 
 
 
 
 
 
 
26
  .get("/feature-flags", async ({ locals }) => {
27
  let loginRequired = false;
28
  const messagesBeforeLogin = config.MESSAGES_BEFORE_LOGIN
 
9
  import { downloadFile } from "$lib/server/files/downloadFile";
10
  import mimeTypes from "mime-types";
11
  import { logger } from "$lib/server/logger";
12
+ import { getMetacognitiveConfig } from "$lib/server/metacognitiveConfig";
13
 
14
  export interface FeatureFlags {
15
  enableAssistants: boolean;
 
24
  export const misc = new Elysia()
25
  .use(authPlugin)
26
  .get("/public-config", async () => config.getPublicConfig())
27
+ .get("/metacognitive-config", async () => {
28
+ const metacogConfig = getMetacognitiveConfig();
29
+ return {
30
+ frequencies: metacogConfig.frequencies,
31
+ comprehensionPrompts: metacogConfig.comprehensionPrompts,
32
+ perspectivePrompts: metacogConfig.perspectivePrompts,
33
+ enabled: metacogConfig.enabled,
34
+ };
35
+ })
36
  .get("/feature-flags", async ({ locals }) => {
37
  let loginRequired = false;
38
  const messagesBeforeLogin = config.MESSAGES_BEFORE_LOGIN
src/lib/server/config.ts CHANGED
@@ -151,7 +151,14 @@ export const ready = (async () => {
151
  }
152
  })();
153
 
154
- type ExtraConfigKeys = "HF_TOKEN" | "OLD_MODELS" | "ENABLE_ASSISTANTS" | "ALLOWED_MODELS";
 
 
 
 
 
 
 
155
 
156
  type ConfigProxy = ConfigManager & { [K in ConfigKey | ExtraConfigKeys]: string };
157
 
 
151
  }
152
  })();
153
 
154
+ type ExtraConfigKeys =
155
+ | "HF_TOKEN"
156
+ | "OLD_MODELS"
157
+ | "ENABLE_ASSISTANTS"
158
+ | "ALLOWED_MODELS"
159
+ | "METACOGNITIVE_FREQUENCIES"
160
+ | "METACOGNITIVE_PROMPTS_COMPREHENSION"
161
+ | "METACOGNITIVE_PROMPTS_PERSPECTIVE";
162
 
163
  type ConfigProxy = ConfigManager & { [K in ConfigKey | ExtraConfigKeys]: string };
164
 
src/lib/server/endpoints/preprocessMessages.ts CHANGED
@@ -32,7 +32,7 @@ function expandPersonaResponses(messages: EndpointMessage[]): EndpointMessage[]
32
 
33
  return {
34
  ...message,
35
- content: personaContents,
36
  };
37
  }
38
  return message;
 
32
 
33
  return {
34
  ...message,
35
+ content: `--- Transcript of responses from participating personas ---\n${personaContents}`,
36
  };
37
  }
38
  return message;
src/lib/server/metacognitiveConfig.ts ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Metacognitive Prompts Configuration
3
+ *
4
+ * Parses environment variables for metacognitive prompt feature:
5
+ * - METACOGNITIVE_FREQUENCIES: Comma-separated list of integers (e.g., "3,5,10")
6
+ * - METACOGNITIVE_PROMPTS_COMPREHENSION: JSON array of prompt templates
7
+ * - METACOGNITIVE_PROMPTS_PERSPECTIVE: JSON array of prompt templates with {{personaName}} placeholder
8
+ */
9
+
10
+ import { config } from "./config";
11
+ import { logger } from "./logger";
12
+
13
+ export interface MetacognitiveConfig {
14
+ frequencies: number[];
15
+ comprehensionPrompts: string[];
16
+ perspectivePrompts: string[];
17
+ enabled: boolean;
18
+ }
19
+
20
+ const DEFAULT_FREQUENCIES = [5];
21
+ const DEFAULT_COMPREHENSION_PROMPTS = [
22
+ "Is there anything in this response that you do not fully understand? If yes, try asking a follow-up question.",
23
+ ];
24
+ const DEFAULT_PERSPECTIVE_PROMPTS = [
25
+ "Want to know what {{personaName}} thinks about this?",
26
+ "You've been talking with the same persona for a while. Maybe see what {{personaName}} would say?",
27
+ ];
28
+
29
+ function parseFrequencies(value: string | undefined): number[] {
30
+ if (!value || value.trim() === "") {
31
+ return DEFAULT_FREQUENCIES;
32
+ }
33
+
34
+ try {
35
+ const parsed = value
36
+ .split(",")
37
+ .map((s) => parseInt(s.trim(), 10))
38
+ .filter((n) => !isNaN(n) && n > 0);
39
+
40
+ if (parsed.length === 0) {
41
+ logger.warn("METACOGNITIVE_FREQUENCIES parsed to empty array, using defaults");
42
+ return DEFAULT_FREQUENCIES;
43
+ }
44
+
45
+ return parsed;
46
+ } catch (e) {
47
+ logger.error(e, "Failed to parse METACOGNITIVE_FREQUENCIES");
48
+ return DEFAULT_FREQUENCIES;
49
+ }
50
+ }
51
+
52
+ function parsePrompts(value: string | undefined, defaults: string[]): string[] {
53
+ if (!value || value.trim() === "") {
54
+ return defaults;
55
+ }
56
+
57
+ try {
58
+ const parsed = JSON.parse(value);
59
+ if (Array.isArray(parsed) && parsed.length > 0 && parsed.every((p) => typeof p === "string")) {
60
+ return parsed;
61
+ }
62
+ logger.warn("Parsed prompts not a valid string array, using defaults");
63
+ return defaults;
64
+ } catch (e) {
65
+ logger.error(e, "Failed to parse metacognitive prompts JSON");
66
+ return defaults;
67
+ }
68
+ }
69
+
70
+ let cachedConfig: MetacognitiveConfig | null = null;
71
+
72
+ export function getMetacognitiveConfig(): MetacognitiveConfig {
73
+ if (cachedConfig) {
74
+ return cachedConfig;
75
+ }
76
+
77
+ const frequencies = parseFrequencies(config.METACOGNITIVE_FREQUENCIES);
78
+ const comprehensionPrompts = parsePrompts(
79
+ config.METACOGNITIVE_PROMPTS_COMPREHENSION,
80
+ DEFAULT_COMPREHENSION_PROMPTS
81
+ );
82
+ const perspectivePrompts = parsePrompts(
83
+ config.METACOGNITIVE_PROMPTS_PERSPECTIVE,
84
+ DEFAULT_PERSPECTIVE_PROMPTS
85
+ );
86
+
87
+ // Feature is enabled if frequencies are configured (even defaults)
88
+ const enabled = frequencies.length > 0;
89
+
90
+ cachedConfig = {
91
+ frequencies,
92
+ comprehensionPrompts,
93
+ perspectivePrompts,
94
+ enabled,
95
+ };
96
+
97
+ logger.info(
98
+ {
99
+ frequencies,
100
+ comprehensionPromptsCount: comprehensionPrompts.length,
101
+ perspectivePromptsCount: perspectivePrompts.length,
102
+ enabled,
103
+ },
104
+ "Metacognitive config loaded"
105
+ );
106
+
107
+ return cachedConfig;
108
+ }
109
+
110
+ /**
111
+ * Select a random frequency from the configured list
112
+ */
113
+ export function selectRandomFrequency(): number {
114
+ const { frequencies } = getMetacognitiveConfig();
115
+ return frequencies[Math.floor(Math.random() * frequencies.length)];
116
+ }
117
+
118
+ /**
119
+ * Select a random comprehension prompt
120
+ */
121
+ export function selectComprehensionPrompt(): string {
122
+ const { comprehensionPrompts } = getMetacognitiveConfig();
123
+ return comprehensionPrompts[Math.floor(Math.random() * comprehensionPrompts.length)];
124
+ }
125
+
126
+ /**
127
+ * Select a random perspective prompt and substitute the persona name
128
+ */
129
+ export function selectPerspectivePrompt(personaName: string): string {
130
+ const { perspectivePrompts } = getMetacognitiveConfig();
131
+ const template = perspectivePrompts[Math.floor(Math.random() * perspectivePrompts.length)];
132
+ return template.replace(/\{\{personaName\}\}/g, personaName);
133
+ }
src/lib/stores/treeVisibility.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { writable } from "svelte/store";
2
+
3
+ // Map of conversation ID -> boolean (true = visible)
4
+ const initialVisibility: Record<string, boolean> = {};
5
+
6
+ function createTreeVisibilityStore() {
7
+ const { subscribe, update, set } = writable<Record<string, boolean>>(initialVisibility);
8
+
9
+ return {
10
+ subscribe,
11
+ toggle: (id: string) => update((n) => ({ ...n, [id]: !n[id] })),
12
+ setVisible: (id: string, visible: boolean) => update((n) => ({ ...n, [id]: visible })),
13
+ reset: () => set({}),
14
+ };
15
+ }
16
+
17
+ export const treeVisibility = createTreeVisibilityStore();
src/lib/types/Conversation.ts CHANGED
@@ -24,5 +24,11 @@ export interface Conversation extends Timestamps {
24
  personaId?: string;
25
  assistantId?: Assistant["_id"];
26
 
 
 
 
 
 
 
27
  userAgent?: string;
28
  }
 
24
  personaId?: string;
25
  assistantId?: Assistant["_id"];
26
 
27
+ metacognitiveState?: {
28
+ targetFrequency: number;
29
+ lastPromptedAtMessageId?: Message["id"] | null;
30
+ updatedAt?: Date;
31
+ };
32
+
33
  userAgent?: string;
34
  }
src/lib/types/Message.ts CHANGED
@@ -2,6 +2,19 @@ import type { MessageUpdate } from "./MessageUpdate";
2
  import type { Timestamps } from "./Timestamps";
3
  import type { v4 } from "uuid";
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  export type PersonaResponse = {
6
  personaId: string;
7
  personaName: string;
@@ -58,6 +71,9 @@ export type Message = Partial<Timestamps> & {
58
  personaId: string;
59
  };
60
 
 
 
 
61
  // needed for conversation trees
62
  ancestors?: Message["id"][];
63
 
 
2
  import type { Timestamps } from "./Timestamps";
3
  import type { v4 } from "uuid";
4
 
5
+ export type MetacognitiveEventType = "comprehension" | "perspective";
6
+
7
+ export type MetacognitiveEvent = {
8
+ type: MetacognitiveEventType;
9
+ promptText: string;
10
+ triggerFrequency: number;
11
+ timestamp: Date;
12
+ suggestedPersonaId?: string;
13
+ suggestedPersonaName?: string;
14
+ accepted: boolean;
15
+ linkedMessageId?: string;
16
+ };
17
+
18
  export type PersonaResponse = {
19
  personaId: string;
20
  personaName: string;
 
71
  personaId: string;
72
  };
73
 
74
+ // Metacognitive prompt events
75
+ metacognitiveEvents?: MetacognitiveEvent[];
76
+
77
  // needed for conversation trees
78
  ancestors?: Message["id"][];
79
 
src/lib/types/MessageContext.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Message } from "./Message";
2
+ import type { v4 } from "uuid";
3
+ import type { goto, invalidate } from "$app/navigation";
4
+
5
+ export interface WriteMessageContext {
6
+ page: { params: { id: string } };
7
+ messages: Message[];
8
+ messagesPath: Message[];
9
+ data: { rootMessageId: string };
10
+ files: File[];
11
+ settings: {
12
+ disableStream: boolean;
13
+ personas?: Array<{ id: string; name: string }>;
14
+ };
15
+ isAborted: () => boolean;
16
+ branchState: {
17
+ messageId: string;
18
+ personaId: string;
19
+ personaName: string;
20
+ } | null;
21
+
22
+ setLoading: (val: boolean) => void;
23
+ setPending: (val: boolean) => void;
24
+ setFiles: (val: File[]) => void;
25
+ setError: (val: string) => void;
26
+ setIsAborted: (val: boolean) => void;
27
+ setTitleUpdate: (val: { title: string; convId: string }) => void;
28
+ onTitleUpdate?: (title: string) => void;
29
+ onMessageCreated?: (id: string) => void;
30
+ updateBranchState: (val: unknown) => void;
31
+ invalidate: typeof invalidate;
32
+ goto: typeof goto;
33
+ }
34
+
35
+ export interface WriteMessageParams {
36
+ prompt?: string;
37
+ messageId?: ReturnType<typeof v4>;
38
+ isRetry?: boolean;
39
+ isContinue?: boolean;
40
+ personaId?: string;
41
+ }
src/lib/types/MessageUpdate.ts CHANGED
@@ -32,6 +32,7 @@ export interface MessageStatusUpdate {
32
  type: MessageUpdateType.Status;
33
  status: MessageUpdateStatus;
34
  message?: string;
 
35
  }
36
 
37
  // Everything else
 
32
  type: MessageUpdateType.Status;
33
  status: MessageUpdateStatus;
34
  message?: string;
35
+ messageId?: string;
36
  }
37
 
38
  // Everything else
src/lib/types/Metacognitive.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { MetacognitiveEventType } from "./Message";
2
+
3
+ export type MetacognitiveConfig = {
4
+ frequencies: number[];
5
+ comprehensionPrompts: string[];
6
+ perspectivePrompts: string[];
7
+ enabled: boolean;
8
+ };
9
+
10
+ export type MetacognitivePromptData = {
11
+ type: MetacognitiveEventType;
12
+ promptText: string;
13
+ triggerFrequency: number;
14
+ suggestedPersonaId?: string;
15
+ suggestedPersonaName?: string;
16
+ messageId?: string;
17
+ linkedMessageId?: string;
18
+ };
src/lib/types/Persona.ts CHANGED
@@ -37,19 +37,24 @@ export function generatePersonaPrompt(persona: Persona): string {
37
  .map(([field, value]) => `${field}: ${value}`);
38
 
39
  const guardrails = `# Core Behavior Rules (Always Active)
40
- - Always respond strictly as the assigned persona (see fields below). Never respond in another persona’s voice, step out of character, or question which persona you are.
41
- - Stay concise by default. Use short answers unless the user explicitly requests detail.
 
42
  - Engage the user: ask natural follow-up questions if it helps the conversation flow.
43
  - Maintain natural human conversational tone. Do not format like an AI, narrate rules, or describe your persona. Reveal personal details only when relevant.
44
  - Never introduce yourself unless the user directly asks who you are.
45
- - Never use tags or brackets for your speaker name at the beginning of the response. E.g., "[Persona A]: ...".
46
  - Let your background, values, and lived experience shape word choice, perspective, and emotional tone.
47
  - Keep the discussion anchored to healthcare policy, reform, access, and socioeconomic factors relevant to your persona.
48
- - Do not fabricate statistics. Use domain knowledge only at a plausibly human level. Express uncertainty over hallucinated statistics.
49
  - Allow your views to evolve through conversation, but maintain your core values.
50
  - Avoid repetition: build on what the user said rather than restating previous arguments.
51
  - Vary your moves: sometimes ask questions, sometimes offer an experience, sometimes reason through tradeoffs. Base your actions off the user's request.
52
  - Never use slurs, discriminatory language, or derogatory stereotypes.
 
 
 
 
53
 
54
  # Answer-Style Constraints
55
  - Prioritize short, direct responses.
 
37
  .map(([field, value]) => `${field}: ${value}`);
38
 
39
  const guardrails = `# Core Behavior Rules (Always Active)
40
+ - Always respond strictly as the most recent persona assigned to you. Never respond in another persona’s voice, step out of character, or question which persona you are.
41
+ - The conversation history may contain a "Transcript of responses" from other personas (labeled with [Name]: ...). These may include your persona and others. If it includes others, do not adopt their names, styles, or views. You are exclusively the persona defined below.
42
+ - Always provide brief and simple answers (1-3 sentences) unless the user explicitly requests detail.th
43
  - Engage the user: ask natural follow-up questions if it helps the conversation flow.
44
  - Maintain natural human conversational tone. Do not format like an AI, narrate rules, or describe your persona. Reveal personal details only when relevant.
45
  - Never introduce yourself unless the user directly asks who you are.
46
+ - Do not use tags or brackets for your speaker name at the beginning of the response. E.g., "[Persona A]: ...".
47
  - Let your background, values, and lived experience shape word choice, perspective, and emotional tone.
48
  - Keep the discussion anchored to healthcare policy, reform, access, and socioeconomic factors relevant to your persona.
49
+ - Never fabricate statistics. Use domain knowledge only at a plausibly human level. Express uncertainty over hallucinated statistics.
50
  - Allow your views to evolve through conversation, but maintain your core values.
51
  - Avoid repetition: build on what the user said rather than restating previous arguments.
52
  - Vary your moves: sometimes ask questions, sometimes offer an experience, sometimes reason through tradeoffs. Base your actions off the user's request.
53
  - Never use slurs, discriminatory language, or derogatory stereotypes.
54
+ - Do not start your response with "--- Transcript of responses from participating personas ---" or any similar header. Your response must only contain the content of your message.
55
+ - Never include persona tags: e.g., "[Mayor David Chen]: ... [Dr. Robert Zane]: ..."
56
+ - Never provide more than one response - in other words, only adopt the one persona defined below in "Official Persona Details."
57
+
58
 
59
  # Answer-Style Constraints
60
  - Prioritize short, direct responses.
src/lib/utils/message/ConversationTreeManager.ts ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Message, MessageFile } from "$lib/types/Message";
2
+ import { MessageRole } from "$lib/types/Message";
3
+ import { addChildren } from "$lib/utils/tree/addChildren";
4
+ import { addSibling } from "$lib/utils/tree/addSibling";
5
+ import type { WriteMessageContext, WriteMessageParams } from "$lib/types/MessageContext";
6
+
7
+ export class ConversationTreeManager {
8
+ constructor(private ctx: WriteMessageContext) {}
9
+
10
+ public prepareMessageForWrite(
11
+ params: WriteMessageParams,
12
+ base64Files: MessageFile[] = []
13
+ ): {
14
+ messageToWriteToId: string;
15
+ navigateToMessageId: string | null;
16
+ } {
17
+ const {
18
+ prompt,
19
+ messageId = this.ctx.messagesPath.at(-1)?.id ?? undefined,
20
+ isRetry = false,
21
+ isContinue = false,
22
+ personaId,
23
+ } = params;
24
+
25
+ let messageToWriteToId: string | undefined;
26
+ let navigateToMessageId: string | null = null;
27
+
28
+ if (isContinue && messageId) {
29
+ const msg = this.ctx.messages.find((m) => m.id === messageId);
30
+ if ((msg?.children?.length ?? 0) > 0) {
31
+ throw new Error("Can only continue the last message");
32
+ }
33
+ messageToWriteToId = messageId;
34
+ } else if (isRetry && messageId) {
35
+ const messageToRetry = this.ctx.messages.find((m) => m.id === messageId);
36
+ if (!messageToRetry) {
37
+ throw new Error("Message not found");
38
+ }
39
+
40
+ if (messageToRetry.from === MessageRole.User && prompt !== undefined) {
41
+ const newUserMessageId = addSibling(
42
+ {
43
+ messages: this.ctx.messages,
44
+ rootMessageId: this.ctx.data.rootMessageId,
45
+ },
46
+ {
47
+ from: MessageRole.User,
48
+ content: prompt,
49
+ files: messageToRetry.files,
50
+ ...(messageToRetry.branchedFrom && {
51
+ branchedFrom: messageToRetry.branchedFrom,
52
+ }),
53
+ },
54
+ messageId
55
+ );
56
+
57
+ const targetPersonaId =
58
+ this.ctx.branchState?.personaId || messageToRetry.branchedFrom?.personaId;
59
+ const initialResponses: Message["personaResponses"] = [];
60
+ if (targetPersonaId) {
61
+ const persona = this.ctx.settings.personas?.find((p) => p.id === targetPersonaId);
62
+ initialResponses.push({
63
+ personaId: targetPersonaId,
64
+ personaName: this.ctx.branchState?.personaName || persona?.name || targetPersonaId,
65
+ content: "",
66
+ });
67
+ }
68
+
69
+ messageToWriteToId = addChildren(
70
+ {
71
+ messages: this.ctx.messages,
72
+ rootMessageId: this.ctx.data.rootMessageId,
73
+ },
74
+ {
75
+ from: MessageRole.Assistant,
76
+ content: "",
77
+ personaResponses: initialResponses,
78
+ ...((this.ctx.branchState || messageToRetry.branchedFrom) && {
79
+ branchedFrom: this.ctx.branchState
80
+ ? {
81
+ messageId: this.ctx.branchState.messageId,
82
+ personaId: this.ctx.branchState.personaId,
83
+ }
84
+ : messageToRetry.branchedFrom,
85
+ }),
86
+ },
87
+ newUserMessageId
88
+ );
89
+
90
+ if (messageToRetry.branchedFrom && !this.ctx.branchState) {
91
+ const persona = this.ctx.settings.personas?.find(
92
+ (p) => p.id === messageToRetry.branchedFrom?.personaId
93
+ );
94
+ this.ctx.updateBranchState({
95
+ messageId: messageToRetry.branchedFrom.messageId,
96
+ personaId: messageToRetry.branchedFrom.personaId,
97
+ personaName: persona?.name || messageToRetry.branchedFrom.personaId,
98
+ });
99
+ navigateToMessageId = newUserMessageId;
100
+ }
101
+ this.ctx.onMessageCreated?.(messageToWriteToId);
102
+ } else if (messageToRetry.from === MessageRole.User && prompt === undefined) {
103
+ messageToWriteToId = addChildren(
104
+ {
105
+ messages: this.ctx.messages,
106
+ rootMessageId: this.ctx.data.rootMessageId,
107
+ },
108
+ {
109
+ from: MessageRole.Assistant,
110
+ content: "",
111
+ personaResponses: [],
112
+ ...(this.ctx.branchState && {
113
+ branchedFrom: {
114
+ messageId: this.ctx.branchState.messageId,
115
+ personaId: this.ctx.branchState.personaId,
116
+ },
117
+ }),
118
+ },
119
+ messageId
120
+ );
121
+ navigateToMessageId = messageToWriteToId;
122
+ this.ctx.onMessageCreated?.(messageToWriteToId);
123
+ } else if (messageToRetry.from === MessageRole.Assistant) {
124
+ let initialPersonaResponses: Message["personaResponses"] = [];
125
+ if (personaId && messageToRetry.personaResponses) {
126
+ initialPersonaResponses = messageToRetry.personaResponses.map((p) => {
127
+ if (p.personaId === personaId) {
128
+ return {
129
+ ...p,
130
+ content: "",
131
+ interrupted: undefined,
132
+ reasoning: undefined,
133
+ updates: undefined,
134
+ routerMetadata: undefined,
135
+ };
136
+ }
137
+ // Defensive copy using structuredClone
138
+ return structuredClone(p);
139
+ });
140
+ }
141
+
142
+ messageToWriteToId = addSibling(
143
+ {
144
+ messages: this.ctx.messages,
145
+ rootMessageId: this.ctx.data.rootMessageId,
146
+ },
147
+ {
148
+ from: MessageRole.Assistant,
149
+ content: "",
150
+ personaResponses: initialPersonaResponses,
151
+ ...((this.ctx.branchState || messageToRetry.branchedFrom) && {
152
+ branchedFrom: this.ctx.branchState
153
+ ? {
154
+ messageId: this.ctx.branchState.messageId,
155
+ personaId: this.ctx.branchState.personaId,
156
+ }
157
+ : messageToRetry.branchedFrom,
158
+ }),
159
+ },
160
+ messageId
161
+ );
162
+
163
+ if (messageToRetry.branchedFrom && !this.ctx.branchState) {
164
+ const persona = this.ctx.settings.personas?.find(
165
+ (p) => p.id === messageToRetry.branchedFrom?.personaId
166
+ );
167
+ this.ctx.updateBranchState({
168
+ messageId: messageToRetry.branchedFrom.messageId,
169
+ personaId: messageToRetry.branchedFrom.personaId,
170
+ personaName: persona?.name || messageToRetry.branchedFrom.personaId,
171
+ });
172
+ navigateToMessageId = messageToWriteToId;
173
+ }
174
+ this.ctx.onMessageCreated?.(messageToWriteToId);
175
+ }
176
+ } else {
177
+ // New message
178
+ const newUserMessageId = addChildren(
179
+ {
180
+ messages: this.ctx.messages,
181
+ rootMessageId: this.ctx.data.rootMessageId,
182
+ },
183
+ {
184
+ from: MessageRole.User,
185
+ content: prompt ?? "",
186
+ files: base64Files,
187
+ ...(this.ctx.branchState && {
188
+ branchedFrom: {
189
+ messageId: this.ctx.branchState.messageId,
190
+ personaId: this.ctx.branchState.personaId,
191
+ },
192
+ }),
193
+ },
194
+ messageId
195
+ );
196
+
197
+ if (!this.ctx.data.rootMessageId) {
198
+ this.ctx.data.rootMessageId = newUserMessageId;
199
+ }
200
+
201
+ messageToWriteToId = addChildren(
202
+ {
203
+ messages: this.ctx.messages,
204
+ rootMessageId: this.ctx.data.rootMessageId,
205
+ },
206
+ {
207
+ from: MessageRole.Assistant,
208
+ content: "",
209
+ personaResponses: [],
210
+ ...(this.ctx.branchState && {
211
+ branchedFrom: {
212
+ messageId: this.ctx.branchState.messageId,
213
+ personaId: this.ctx.branchState.personaId,
214
+ },
215
+ }),
216
+ },
217
+ newUserMessageId
218
+ );
219
+
220
+ this.ctx.onMessageCreated?.(messageToWriteToId);
221
+ }
222
+
223
+ if (!messageToWriteToId) {
224
+ throw new Error("Failed to determine message ID to write to");
225
+ }
226
+
227
+ return { messageToWriteToId, navigateToMessageId };
228
+ }
229
+
230
+ /**
231
+ * Safely updates a message ID in the tree, ensuring parent linkage is maintained.
232
+ */
233
+ public syncMessageId(oldId: string, newId: string): void {
234
+ const message = this.ctx.messages.find((m) => m.id === oldId || m.id === newId);
235
+ if (!message) return;
236
+
237
+ // If ID is already updated, just return
238
+ if (message.id === newId) return;
239
+
240
+ message.id = newId;
241
+
242
+ if (message.ancestors && message.ancestors.length > 0) {
243
+ const parentId = message.ancestors[message.ancestors.length - 1];
244
+ const parent = this.ctx.messages.find((m) => m.id === parentId);
245
+
246
+ if (parent) {
247
+ if (!parent.children) parent.children = [];
248
+
249
+ const childIndex = parent.children.indexOf(oldId);
250
+ if (childIndex !== -1) {
251
+ parent.children[childIndex] = newId;
252
+ } else {
253
+ // Fallback: append if not found
254
+ console.warn(
255
+ `[TreeManager] Parent ${parentId} missing child ${oldId}, appending ${newId}`
256
+ );
257
+ parent.children.push(newId);
258
+ }
259
+ } else {
260
+ console.error(
261
+ `[TreeManager] Parent ${parentId} not found for message ${oldId} -> ${newId}`
262
+ );
263
+ }
264
+ }
265
+
266
+ console.debug(`[TreeManager] Synced message ID: ${oldId} -> ${newId}`);
267
+ }
268
+ }
src/lib/utils/message/MessageStreamHandler.ts ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ MessageReasoningUpdateType,
3
+ MessageUpdateStatus,
4
+ MessageUpdateType,
5
+ } from "$lib/types/MessageUpdate";
6
+ import { fetchMessageUpdates } from "$lib/utils/messageUpdates";
7
+ import { updateDebouncer } from "$lib/utils/updates.js";
8
+ import type { WriteMessageContext } from "$lib/types/MessageContext";
9
+ import type { ConversationTreeManager } from "./ConversationTreeManager";
10
+
11
+ export class MessageStreamHandler {
12
+ constructor(
13
+ private ctx: WriteMessageContext,
14
+ private treeManager: ConversationTreeManager
15
+ ) {}
16
+
17
+ public async handleStream(
18
+ conversationId: string,
19
+ params: {
20
+ base: string;
21
+ prompt?: string;
22
+ messageId?: string;
23
+ isRetry: boolean;
24
+ isContinue: boolean;
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ files?: any[];
27
+ personaId?: string;
28
+ },
29
+ messageToWriteToId: string
30
+ ) {
31
+ const messageToWriteTo = this.ctx.messages.find((m) => m.id === messageToWriteToId);
32
+ if (!messageToWriteTo) {
33
+ throw new Error("Message to write to not found");
34
+ }
35
+
36
+ const messageUpdatesAbortController = new AbortController();
37
+
38
+ const messageUpdatesIterator = await fetchMessageUpdates(
39
+ conversationId,
40
+ {
41
+ base: params.base,
42
+ inputs: params.prompt,
43
+ messageId: params.messageId,
44
+ isRetry: params.isRetry,
45
+ isContinue: params.isContinue,
46
+ files: params.files,
47
+ personaId: params.personaId,
48
+ branchedFrom: this.ctx.branchState
49
+ ? {
50
+ messageId: this.ctx.branchState.messageId,
51
+ personaId: this.ctx.branchState.personaId,
52
+ }
53
+ : undefined,
54
+ },
55
+ messageUpdatesAbortController.signal
56
+ ).catch((err) => {
57
+ this.ctx.setError(err.message);
58
+ });
59
+
60
+ if (messageUpdatesIterator === undefined) return;
61
+
62
+ this.ctx.setFiles([]);
63
+ let buffer = "";
64
+ let lastUpdateTime = new Date();
65
+
66
+ let reasoningBuffer = "";
67
+ let reasoningLastUpdate = new Date();
68
+
69
+ const personaBuffers = new Map<string, string>();
70
+ const personaLastUpdateTimes = new Map<string, Date>();
71
+
72
+ for await (const update of messageUpdatesIterator) {
73
+ if (this.ctx.isAborted()) {
74
+ messageUpdatesAbortController.abort();
75
+ return;
76
+ }
77
+
78
+ if (update.type === MessageUpdateType.Stream) {
79
+ update.token = update.token.replaceAll("\0", "");
80
+ }
81
+
82
+ const isHighFrequencyUpdate =
83
+ (update.type === MessageUpdateType.Reasoning &&
84
+ update.subtype === MessageReasoningUpdateType.Stream) ||
85
+ update.type === MessageUpdateType.Stream ||
86
+ update.type === MessageUpdateType.Persona ||
87
+ (update.type === MessageUpdateType.Status &&
88
+ update.status === MessageUpdateStatus.KeepAlive);
89
+
90
+ if (!isHighFrequencyUpdate) {
91
+ messageToWriteTo.updates = [...(messageToWriteTo.updates ?? []), update];
92
+ }
93
+ const currentTime = new Date();
94
+
95
+ if (update.type === MessageUpdateType.PersonaInit) {
96
+ const newResponses = update.personas.map((p) => ({
97
+ personaId: p.personaId,
98
+ personaName: p.personaName,
99
+ personaOccupation: p.personaOccupation,
100
+ personaStance: p.personaStance,
101
+ content: "",
102
+ }));
103
+
104
+ if (!messageToWriteTo.personaResponses) {
105
+ messageToWriteTo.personaResponses = newResponses;
106
+ } else {
107
+ // Merge with existing personas (preserving those not in the update)
108
+ for (const newRes of newResponses) {
109
+ const existingIdx = messageToWriteTo.personaResponses.findIndex(
110
+ (p) => p.personaId === newRes.personaId
111
+ );
112
+ if (existingIdx !== -1) {
113
+ // Update existing persona in place
114
+ messageToWriteTo.personaResponses[existingIdx] = {
115
+ ...messageToWriteTo.personaResponses[existingIdx],
116
+ ...newRes,
117
+ content: messageToWriteTo.personaResponses[existingIdx].content || newRes.content,
118
+ };
119
+ } else {
120
+ messageToWriteTo.personaResponses.push(newRes);
121
+ }
122
+ }
123
+ }
124
+ } else if (update.type === MessageUpdateType.Persona) {
125
+ if (!messageToWriteTo.personaResponses) {
126
+ messageToWriteTo.personaResponses = [];
127
+ }
128
+
129
+ let personaResponse = messageToWriteTo.personaResponses.find(
130
+ (pr) => pr.personaId === update.personaId
131
+ );
132
+ if (!personaResponse) {
133
+ personaResponse = {
134
+ personaId: update.personaId,
135
+ personaName: update.personaName,
136
+ personaOccupation: update.personaOccupation,
137
+ personaStance: update.personaStance,
138
+ content: "",
139
+ };
140
+ messageToWriteTo.personaResponses.push(personaResponse);
141
+ }
142
+
143
+ if (update.updateType === "stream" && update.token && !this.ctx.settings.disableStream) {
144
+ const personaBuffer = personaBuffers.get(update.personaId) || "";
145
+ const newBuffer = personaBuffer + update.token;
146
+ personaBuffers.set(update.personaId, newBuffer);
147
+
148
+ const lastUpdate = personaLastUpdateTimes.get(update.personaId) || new Date(0);
149
+ if (currentTime.getTime() - lastUpdate.getTime() > updateDebouncer.maxUpdateTime) {
150
+ personaResponse.content += newBuffer;
151
+ personaBuffers.set(update.personaId, "");
152
+ personaLastUpdateTimes.set(update.personaId, currentTime);
153
+ }
154
+ this.ctx.setPending(false);
155
+ } else if (update.updateType === "finalAnswer" && update.text) {
156
+ personaResponse.content = update.text;
157
+ personaResponse.interrupted = update.interrupted;
158
+ } else if (update.updateType === "routerMetadata" && update.route && update.model) {
159
+ personaResponse.routerMetadata = {
160
+ route: update.route,
161
+ model: update.model,
162
+ };
163
+ } else if (update.updateType === "status" && update.error) {
164
+ personaResponse.interrupted = true;
165
+ personaResponse.content = personaResponse.content || `Error: ${update.error}`;
166
+ }
167
+ } else if (update.type === MessageUpdateType.Stream && !this.ctx.settings.disableStream) {
168
+ buffer += update.token;
169
+ if (currentTime.getTime() - lastUpdateTime.getTime() > updateDebouncer.maxUpdateTime) {
170
+ messageToWriteTo.content += buffer;
171
+ buffer = "";
172
+ lastUpdateTime = currentTime;
173
+ }
174
+ this.ctx.setPending(false);
175
+ } else if (
176
+ update.type === MessageUpdateType.Status &&
177
+ update.status === MessageUpdateStatus.Error
178
+ ) {
179
+ this.ctx.setError(update.message ?? "An error has occurred");
180
+ } else if (
181
+ update.type === MessageUpdateType.Status &&
182
+ update.status === MessageUpdateStatus.Started &&
183
+ update.messageId
184
+ ) {
185
+ if (messageToWriteTo.id !== update.messageId) {
186
+ // Use TreeManager to safely update the ID and parent links
187
+ const oldId = messageToWriteTo.id;
188
+ this.treeManager.syncMessageId(oldId, update.messageId);
189
+ }
190
+ } else if (update.type === MessageUpdateType.Title) {
191
+ this.ctx.setTitleUpdate({
192
+ title: update.title,
193
+ convId: conversationId,
194
+ });
195
+ this.ctx.onTitleUpdate?.(update.title);
196
+ } else if (update.type === MessageUpdateType.File) {
197
+ messageToWriteTo.files = [
198
+ ...(messageToWriteTo.files ?? []),
199
+ { type: "hash", value: update.sha, mime: update.mime, name: update.name },
200
+ ];
201
+ } else if (update.type === MessageUpdateType.Reasoning) {
202
+ if (!messageToWriteTo.reasoning) {
203
+ messageToWriteTo.reasoning = "";
204
+ }
205
+ if (update.subtype === MessageReasoningUpdateType.Stream) {
206
+ reasoningBuffer += update.token;
207
+ if (
208
+ currentTime.getTime() - reasoningLastUpdate.getTime() >
209
+ updateDebouncer.maxUpdateTime
210
+ ) {
211
+ messageToWriteTo.reasoning += reasoningBuffer;
212
+ reasoningBuffer = "";
213
+ reasoningLastUpdate = currentTime;
214
+ }
215
+ }
216
+ } else if (update.type === MessageUpdateType.RouterMetadata) {
217
+ messageToWriteTo.routerMetadata = {
218
+ route: update.route,
219
+ model: update.model,
220
+ };
221
+ } else if (update.type === MessageUpdateType.FinalAnswer) {
222
+ messageToWriteTo.content = update.text;
223
+ messageToWriteTo.interrupted = update.interrupted;
224
+ this.ctx.setPending(false);
225
+ }
226
+ }
227
+ }
228
+ }
src/lib/utils/messageSender.ts CHANGED
@@ -1,58 +1,13 @@
1
- import { goto, invalidate } from "$app/navigation";
2
- import { base } from "$app/paths";
3
  import { tick } from "svelte";
4
- import { type Message, MessageRole } from "$lib/types/Message";
5
- import {
6
- MessageReasoningUpdateType,
7
- MessageUpdateStatus,
8
- MessageUpdateType,
9
- } from "$lib/types/MessageUpdate";
10
  import { UrlDependency } from "$lib/types/UrlDependency";
11
  import file2base64 from "$lib/utils/file2base64";
12
- import { fetchMessageUpdates } from "$lib/utils/messageUpdates";
13
- import { addChildren } from "$lib/utils/tree/addChildren";
14
- import { addSibling } from "$lib/utils/tree/addSibling";
15
- import { updateDebouncer } from "$lib/utils/updates.js";
16
- import type { v4 } from "uuid";
17
  import { ERROR_MESSAGES } from "$lib/stores/errors";
 
 
 
 
18
 
19
- export interface WriteMessageContext {
20
- page: { params: { id: string } };
21
- messages: Message[];
22
- messagesPath: Message[];
23
- data: { rootMessageId: string };
24
- files: File[];
25
- settings: {
26
- disableStream: boolean;
27
- personas?: Array<{ id: string; name: string }>;
28
- };
29
- isAborted: () => boolean;
30
- branchState: {
31
- messageId: string;
32
- personaId: string;
33
- personaName: string;
34
- } | null;
35
-
36
- setLoading: (val: boolean) => void;
37
- setPending: (val: boolean) => void;
38
- setFiles: (val: File[]) => void;
39
- setError: (val: string) => void;
40
- setIsAborted: (val: boolean) => void;
41
- setTitleUpdate: (val: { title: string; convId: string }) => void;
42
- onTitleUpdate?: (title: string) => void;
43
- onMessageCreated?: (id: string) => void;
44
- updateBranchState: (val: unknown) => void;
45
- invalidate: typeof invalidate;
46
- goto: typeof goto;
47
- }
48
-
49
- export interface WriteMessageParams {
50
- prompt?: string;
51
- messageId?: ReturnType<typeof v4>;
52
- isRetry?: boolean;
53
- isContinue?: boolean;
54
- personaId?: string;
55
- }
56
 
57
  export async function writeMessage(
58
  ctx: WriteMessageContext,
@@ -72,7 +27,8 @@ export async function writeMessage(
72
  return;
73
  }
74
 
75
- let navigateToMessageId: string | null = null;
 
76
 
77
  try {
78
  ctx.setIsAborted(false);
@@ -90,306 +46,32 @@ export async function writeMessage(
90
  )
91
  );
92
 
93
- let messageToWriteToId: Message["id"] | undefined = undefined;
94
-
95
- if (isContinue && messageId) {
96
- if ((ctx.messages.find((msg) => msg.id === messageId)?.children?.length ?? 0) > 0) {
97
- ctx.setError("Can only continue the last message");
98
- } else {
99
- messageToWriteToId = messageId;
100
- }
101
- } else if (isRetry && messageId) {
102
- const messageToRetry = ctx.messages.find((message) => message.id === messageId);
103
-
104
- if (!messageToRetry) {
105
- ctx.setError("Message not found");
106
- }
107
-
108
- if (messageToRetry?.from === MessageRole.User && prompt) {
109
- const newUserMessageId = addSibling(
110
- {
111
- messages: ctx.messages,
112
- rootMessageId: ctx.data.rootMessageId,
113
- },
114
- {
115
- from: MessageRole.User,
116
- content: prompt,
117
- files: messageToRetry.files,
118
- ...(messageToRetry.branchedFrom && {
119
- branchedFrom: messageToRetry.branchedFrom,
120
- }),
121
- },
122
- messageId
123
- );
124
- messageToWriteToId = addChildren(
125
- {
126
- messages: ctx.messages,
127
- rootMessageId: ctx.data.rootMessageId,
128
- },
129
- {
130
- from: MessageRole.Assistant,
131
- content: "",
132
- personaResponses: [],
133
- ...(messageToRetry.branchedFrom && {
134
- branchedFrom: messageToRetry.branchedFrom,
135
- }),
136
- },
137
- newUserMessageId
138
- );
139
-
140
- if (messageToRetry.branchedFrom) {
141
- const persona = ctx.settings.personas?.find(
142
- (p) => p.id === messageToRetry.branchedFrom?.personaId
143
- );
144
- ctx.updateBranchState({
145
- messageId: messageToRetry.branchedFrom.messageId,
146
- personaId: messageToRetry.branchedFrom.personaId,
147
- personaName: persona?.name || messageToRetry.branchedFrom.personaId,
148
- });
149
- navigateToMessageId = newUserMessageId;
150
- }
151
-
152
- ctx.onMessageCreated?.(messageToWriteToId);
153
- } else if (messageToRetry?.from === MessageRole.Assistant) {
154
- messageToWriteToId = addSibling(
155
- {
156
- messages: ctx.messages,
157
- rootMessageId: ctx.data.rootMessageId,
158
- },
159
- {
160
- from: MessageRole.Assistant,
161
- content: "",
162
- personaResponses: [],
163
- ...(messageToRetry.branchedFrom && {
164
- branchedFrom: messageToRetry.branchedFrom,
165
- }),
166
- },
167
- messageId
168
- );
169
-
170
- if (messageToRetry.branchedFrom) {
171
- const persona = ctx.settings.personas?.find(
172
- (p) => p.id === messageToRetry.branchedFrom?.personaId
173
- );
174
- ctx.updateBranchState({
175
- messageId: messageToRetry.branchedFrom.messageId,
176
- personaId: messageToRetry.branchedFrom.personaId,
177
- personaName: persona?.name || messageToRetry.branchedFrom.personaId,
178
- });
179
- navigateToMessageId = messageToWriteToId;
180
- }
181
- ctx.onMessageCreated?.(messageToWriteToId);
182
- }
183
- } else {
184
- const newUserMessageId = addChildren(
185
- {
186
- messages: ctx.messages,
187
- rootMessageId: ctx.data.rootMessageId,
188
- },
189
- {
190
- from: MessageRole.User,
191
- content: prompt ?? "",
192
- files: base64Files,
193
- ...(ctx.branchState && {
194
- branchedFrom: {
195
- messageId: ctx.branchState.messageId,
196
- personaId: ctx.branchState.personaId,
197
- },
198
- }),
199
- },
200
- messageId
201
- );
202
-
203
- if (!ctx.data.rootMessageId) {
204
- ctx.data.rootMessageId = newUserMessageId;
205
- }
206
-
207
- messageToWriteToId = addChildren(
208
- {
209
- messages: ctx.messages,
210
- rootMessageId: ctx.data.rootMessageId,
211
- },
212
- {
213
- from: MessageRole.Assistant,
214
- content: "",
215
- personaResponses: [],
216
- ...(ctx.branchState && {
217
- branchedFrom: {
218
- messageId: ctx.branchState.messageId,
219
- personaId: ctx.branchState.personaId,
220
- },
221
- }),
222
- },
223
- newUserMessageId
224
- );
225
-
226
- ctx.onMessageCreated?.(messageToWriteToId);
227
- }
228
-
229
  const userMessage = ctx.messages.find((message) => message.id === messageId);
230
- const messageToWriteTo = ctx.messages.find((message) => message.id === messageToWriteToId);
231
- if (!messageToWriteTo) {
232
- throw new Error("Message to write to not found");
233
- }
234
-
235
- const messageUpdatesAbortController = new AbortController();
236
 
237
- const messageUpdatesIterator = await fetchMessageUpdates(
238
  conversationId,
239
  {
240
  base,
241
- inputs: prompt,
242
  messageId,
243
  isRetry,
244
  isContinue,
245
  files: isRetry ? userMessage?.files : base64Files,
246
  personaId,
247
- branchedFrom: ctx.branchState
248
- ? {
249
- messageId: ctx.branchState.messageId,
250
- personaId: ctx.branchState.personaId,
251
- }
252
- : undefined,
253
  },
254
- messageUpdatesAbortController.signal
255
- ).catch((err) => {
256
- ctx.setError(err.message);
257
- });
258
- if (messageUpdatesIterator === undefined) return;
259
-
260
- ctx.setFiles([]);
261
- let buffer = "";
262
- let lastUpdateTime = new Date();
263
-
264
- let reasoningBuffer = "";
265
- let reasoningLastUpdate = new Date();
266
-
267
- const personaBuffers = new Map<string, string>();
268
- const personaLastUpdateTimes = new Map<string, Date>();
269
-
270
- for await (const update of messageUpdatesIterator) {
271
- if (ctx.isAborted()) {
272
- messageUpdatesAbortController.abort();
273
- return;
274
- }
275
-
276
- if (update.type === MessageUpdateType.Stream) {
277
- update.token = update.token.replaceAll("\0", "");
278
- }
279
-
280
- const isHighFrequencyUpdate =
281
- (update.type === MessageUpdateType.Reasoning &&
282
- update.subtype === MessageReasoningUpdateType.Stream) ||
283
- update.type === MessageUpdateType.Stream ||
284
- update.type === MessageUpdateType.Persona ||
285
- (update.type === MessageUpdateType.Status &&
286
- update.status === MessageUpdateStatus.KeepAlive);
287
-
288
- if (!isHighFrequencyUpdate) {
289
- messageToWriteTo.updates = [...(messageToWriteTo.updates ?? []), update];
290
- }
291
- const currentTime = new Date();
292
-
293
- if (update.type === MessageUpdateType.PersonaInit) {
294
- messageToWriteTo.personaResponses = update.personas.map((p) => ({
295
- personaId: p.personaId,
296
- personaName: p.personaName,
297
- personaOccupation: p.personaOccupation,
298
- personaStance: p.personaStance,
299
- content: "",
300
- }));
301
- } else if (update.type === MessageUpdateType.Persona) {
302
- if (!messageToWriteTo.personaResponses) {
303
- messageToWriteTo.personaResponses = [];
304
- }
305
-
306
- let personaResponse = messageToWriteTo.personaResponses.find(
307
- (pr) => pr.personaId === update.personaId
308
- );
309
- if (!personaResponse) {
310
- personaResponse = {
311
- personaId: update.personaId,
312
- personaName: update.personaName,
313
- personaOccupation: update.personaOccupation,
314
- personaStance: update.personaStance,
315
- content: "",
316
- };
317
- messageToWriteTo.personaResponses.push(personaResponse);
318
- }
319
-
320
- if (update.updateType === "stream" && update.token && !ctx.settings.disableStream) {
321
- const personaBuffer = personaBuffers.get(update.personaId) || "";
322
- const newBuffer = personaBuffer + update.token;
323
- personaBuffers.set(update.personaId, newBuffer);
324
 
325
- const lastUpdate = personaLastUpdateTimes.get(update.personaId) || new Date(0);
326
- if (currentTime.getTime() - lastUpdate.getTime() > updateDebouncer.maxUpdateTime) {
327
- personaResponse.content += newBuffer;
328
- personaBuffers.set(update.personaId, "");
329
- personaLastUpdateTimes.set(update.personaId, currentTime);
330
- }
331
- ctx.setPending(false);
332
- } else if (update.updateType === "finalAnswer" && update.text) {
333
- personaResponse.content = update.text;
334
- personaResponse.interrupted = update.interrupted;
335
- } else if (update.updateType === "routerMetadata" && update.route && update.model) {
336
- personaResponse.routerMetadata = {
337
- route: update.route,
338
- model: update.model,
339
- };
340
- } else if (update.updateType === "status" && update.error) {
341
- personaResponse.interrupted = true;
342
- personaResponse.content = personaResponse.content || `Error: ${update.error}`;
343
- }
344
- } else if (update.type === MessageUpdateType.Stream && !ctx.settings.disableStream) {
345
- buffer += update.token;
346
- if (currentTime.getTime() - lastUpdateTime.getTime() > updateDebouncer.maxUpdateTime) {
347
- messageToWriteTo.content += buffer;
348
- buffer = "";
349
- lastUpdateTime = currentTime;
350
- }
351
- ctx.setPending(false);
352
- } else if (
353
- update.type === MessageUpdateType.Status &&
354
- update.status === MessageUpdateStatus.Error
355
- ) {
356
- ctx.setError(update.message ?? "An error has occurred");
357
- } else if (update.type === MessageUpdateType.Title) {
358
- ctx.setTitleUpdate({
359
- title: update.title,
360
- convId: conversationId,
361
- });
362
- ctx.onTitleUpdate?.(update.title);
363
- } else if (update.type === MessageUpdateType.File) {
364
- messageToWriteTo.files = [
365
- ...(messageToWriteTo.files ?? []),
366
- { type: "hash", value: update.sha, mime: update.mime, name: update.name },
367
- ];
368
- } else if (update.type === MessageUpdateType.Reasoning) {
369
- if (!messageToWriteTo.reasoning) {
370
- messageToWriteTo.reasoning = "";
371
- }
372
- if (update.subtype === MessageReasoningUpdateType.Stream) {
373
- reasoningBuffer += update.token;
374
- if (
375
- currentTime.getTime() - reasoningLastUpdate.getTime() >
376
- updateDebouncer.maxUpdateTime
377
- ) {
378
- messageToWriteTo.reasoning += reasoningBuffer;
379
- reasoningBuffer = "";
380
- reasoningLastUpdate = currentTime;
381
- }
382
- }
383
- } else if (update.type === MessageUpdateType.RouterMetadata) {
384
- messageToWriteTo.routerMetadata = {
385
- route: update.route,
386
- model: update.model,
387
- };
388
- } else if (update.type === MessageUpdateType.FinalAnswer) {
389
- messageToWriteTo.content = update.text;
390
- messageToWriteTo.interrupted = update.interrupted;
391
- ctx.setPending(false);
392
- }
393
  }
394
  } catch (err) {
395
  if (err instanceof Error && err.message.includes("overloaded")) {
@@ -408,13 +90,5 @@ export async function writeMessage(
408
 
409
  ctx.setLoading(false);
410
  ctx.setPending(false);
411
-
412
- if (navigateToMessageId) {
413
- await tick();
414
- const url = new URL(window.location.href);
415
- url.searchParams.set("msgId", navigateToMessageId);
416
- url.searchParams.set("scrollTo", "true");
417
- await ctx.goto(url.toString(), { replaceState: false, noScroll: true });
418
- }
419
  }
420
  }
 
 
 
1
  import { tick } from "svelte";
 
 
 
 
 
 
2
  import { UrlDependency } from "$lib/types/UrlDependency";
3
  import file2base64 from "$lib/utils/file2base64";
 
 
 
 
 
4
  import { ERROR_MESSAGES } from "$lib/stores/errors";
5
+ import type { WriteMessageContext, WriteMessageParams } from "$lib/types/MessageContext";
6
+ import { ConversationTreeManager } from "./message/ConversationTreeManager";
7
+ import { MessageStreamHandler } from "./message/MessageStreamHandler";
8
+ import { base } from "$app/paths";
9
 
10
+ export type { WriteMessageContext, WriteMessageParams };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  export async function writeMessage(
13
  ctx: WriteMessageContext,
 
27
  return;
28
  }
29
 
30
+ const treeManager = new ConversationTreeManager(ctx);
31
+ const streamHandler = new MessageStreamHandler(ctx, treeManager);
32
 
33
  try {
34
  ctx.setIsAborted(false);
 
46
  )
47
  );
48
 
49
+ const { messageToWriteToId, navigateToMessageId } = treeManager.prepareMessageForWrite(
50
+ params,
51
+ base64Files
52
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  const userMessage = ctx.messages.find((message) => message.id === messageId);
 
 
 
 
 
 
54
 
55
+ await streamHandler.handleStream(
56
  conversationId,
57
  {
58
  base,
59
+ prompt,
60
  messageId,
61
  isRetry,
62
  isContinue,
63
  files: isRetry ? userMessage?.files : base64Files,
64
  personaId,
 
 
 
 
 
 
65
  },
66
+ messageToWriteToId
67
+ );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
+ if (navigateToMessageId) {
70
+ await tick();
71
+ const url = new URL(window.location.href);
72
+ url.searchParams.set("msgId", navigateToMessageId);
73
+ url.searchParams.set("scrollTo", "true");
74
+ await ctx.goto(url.toString(), { replaceState: false, noScroll: true });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  }
76
  } catch (err) {
77
  if (err instanceof Error && err.message.includes("overloaded")) {
 
90
 
91
  ctx.setLoading(false);
92
  ctx.setPending(false);
 
 
 
 
 
 
 
 
93
  }
94
  }
src/lib/utils/metacognitiveLogic.spec.ts ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, test, expect } from "vitest";
2
+ import type { Message } from "$lib/types/Message";
3
+ import type { MetacognitiveConfig } from "$lib/types/Metacognitive";
4
+ import { determineMetacognitivePrompt } from "./metacognitiveLogic";
5
+
6
+ const enabledConfig: MetacognitiveConfig = {
7
+ enabled: true,
8
+ frequencies: [2],
9
+ comprehensionPrompts: ["C1"],
10
+ perspectivePrompts: ["P1 {{personaName}}"],
11
+ };
12
+
13
+ function msg(partial: Partial<Message> & Pick<Message, "id" | "from" | "content">): Message {
14
+ // The caller type guarantees id/from/content are present.
15
+ return { ...partial } as Message;
16
+ }
17
+
18
+ describe("determineMetacognitivePrompt", () => {
19
+ test("returns null when config is disabled", () => {
20
+ const out = determineMetacognitivePrompt(
21
+ [msg({ id: "a1", from: "assistant", content: "x" })],
22
+ undefined,
23
+ {},
24
+ {}
25
+ );
26
+ expect(out).toBeNull();
27
+ });
28
+
29
+ test("respects dismissedForMessageId", () => {
30
+ const messages = [msg({ id: "a1", from: "assistant", content: "x" })];
31
+ const out = determineMetacognitivePrompt(
32
+ messages,
33
+ enabledConfig,
34
+ { targetFrequency: 1, dismissedForMessageId: "a1" },
35
+ {}
36
+ );
37
+ expect(out).toBeNull();
38
+ });
39
+
40
+ test("gates by targetFrequency using assistant messages since last shown event", () => {
41
+ const messages = [
42
+ msg({
43
+ id: "a0",
44
+ from: "assistant",
45
+ content: "old",
46
+ metacognitiveEvents: [
47
+ {
48
+ type: "comprehension",
49
+ promptText: "C",
50
+ triggerFrequency: 1,
51
+ timestamp: new Date(),
52
+ accepted: false,
53
+ },
54
+ ],
55
+ }),
56
+ msg({ id: "u1", from: "user", content: "q" }),
57
+ msg({ id: "a1", from: "assistant", content: "x" }),
58
+ ];
59
+
60
+ expect(
61
+ determineMetacognitivePrompt(messages, enabledConfig, { targetFrequency: 2 }, {})
62
+ ).toBeNull();
63
+ expect(
64
+ determineMetacognitivePrompt(messages, enabledConfig, { targetFrequency: 1 }, {})
65
+ ).not.toBeNull();
66
+ });
67
+
68
+ test("prefers perspective when it can suggest a different active persona", () => {
69
+ const messages = [
70
+ msg({
71
+ id: "a1",
72
+ from: "assistant",
73
+ content: "x",
74
+ personaResponses: [{ personaId: "p1", personaName: "P1", content: "x" }],
75
+ }),
76
+ ];
77
+ const out = determineMetacognitivePrompt(
78
+ messages,
79
+ enabledConfig,
80
+ { targetFrequency: 1 },
81
+ {
82
+ activePersonas: ["p1", "p2"],
83
+ personas: [
84
+ { id: "p1", name: "Alpha" },
85
+ { id: "p2", name: "Beta" },
86
+ ],
87
+ }
88
+ );
89
+
90
+ expect(out?.type).toBe("perspective");
91
+ expect(out?.suggestedPersonaId).toBe("p2");
92
+ expect(out?.promptText).toContain("Beta");
93
+ });
94
+
95
+ test("inserts personaName literally (no $ replacement expansions)", () => {
96
+ const config: MetacognitiveConfig = {
97
+ enabled: true,
98
+ frequencies: [1],
99
+ comprehensionPrompts: ["C1"],
100
+ perspectivePrompts: ["Hello {{personaName}}"],
101
+ };
102
+ const messages = [
103
+ msg({
104
+ id: "a1",
105
+ from: "assistant",
106
+ content: "x",
107
+ personaResponses: [{ personaId: "p1", personaName: "P1", content: "x" }],
108
+ }),
109
+ ];
110
+
111
+ // `$&` would normally expand to the matched substring in String.replace replacement strings.
112
+ const out = determineMetacognitivePrompt(
113
+ messages,
114
+ config,
115
+ { targetFrequency: 1 },
116
+ {
117
+ activePersonas: ["p1", "p2"],
118
+ personas: [
119
+ { id: "p1", name: "Alpha" },
120
+ { id: "p2", name: "$&" },
121
+ ],
122
+ }
123
+ );
124
+
125
+ expect(out?.type).toBe("perspective");
126
+ expect(out?.suggestedPersonaName).toBe("$&");
127
+ expect(out?.promptText).toBe("Hello $&");
128
+ });
129
+
130
+ test("falls back to comprehension when there is no alternative persona", () => {
131
+ const messages = [
132
+ msg({
133
+ id: "a1",
134
+ from: "assistant",
135
+ content: "x",
136
+ personaResponses: [{ personaId: "p1", personaName: "P1", content: "x" }],
137
+ }),
138
+ ];
139
+ const out = determineMetacognitivePrompt(
140
+ messages,
141
+ enabledConfig,
142
+ { targetFrequency: 1 },
143
+ { activePersonas: ["p1"], personas: [{ id: "p1", name: "Alpha" }] }
144
+ );
145
+
146
+ expect(out?.type).toBe("comprehension");
147
+ });
148
+ });
src/lib/utils/metacognitiveLogic.ts ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Message } from "$lib/types/Message";
2
+ import type { MetacognitiveConfig, MetacognitivePromptData } from "$lib/types/Metacognitive";
3
+
4
+ type DetermineState = {
5
+ /**
6
+ * If the user dismissed a prompt for a particular message, never show again for that message.
7
+ */
8
+ dismissedForMessageId?: string;
9
+ /**
10
+ * Target frequency (in assistant messages) for when to show the next prompt.
11
+ */
12
+ targetFrequency?: number;
13
+ /**
14
+ * The ID of the message that most recently triggered a metacognitive prompt (globally).
15
+ * This helps handle cases where the prompt was on a sibling branch not visible in the current path.
16
+ */
17
+ lastPromptedAtMessageId?: string;
18
+ };
19
+
20
+ type PersonaContext = {
21
+ /**
22
+ * Active persona IDs from user settings (if any).
23
+ */
24
+ activePersonas?: string[];
25
+ /**
26
+ * Persona definitions from user settings (if any).
27
+ */
28
+ personas?: Array<{ id: string; name?: string }>;
29
+ };
30
+
31
+ function pickRandom<T>(arr: readonly T[]): T | undefined {
32
+ if (!arr.length) return undefined;
33
+ return arr[Math.floor(Math.random() * arr.length)];
34
+ }
35
+
36
+ function getLastShownMetacognitiveIndex(
37
+ messages: readonly Message[],
38
+ lastPromptedAtMessageId?: string
39
+ ): number {
40
+ for (let i = messages.length - 1; i >= 0; i--) {
41
+ const msg = messages[i];
42
+
43
+ // Reset on messages with metacognitive events
44
+ const events = msg.metacognitiveEvents;
45
+ if (events && events.length > 0) return i;
46
+
47
+ // Reset on multi-persona messages (more than 1 persona response)
48
+ if (msg.personaResponses && msg.personaResponses.length > 1) {
49
+ return i;
50
+ }
51
+
52
+ // Reset if this message matches the global last prompted ID
53
+ if (lastPromptedAtMessageId && msg.id === lastPromptedAtMessageId) {
54
+ return i;
55
+ }
56
+
57
+ // Check if a child of this message (a sibling of the next message in path) was the one prompted.
58
+ // If 'lastPromptedAtMessageId' is in 'msg.children', then 'msg' is the parent of the prompted message.
59
+ // The prompt occurred effectively "at" this junction.
60
+ if (lastPromptedAtMessageId && msg.children?.includes(lastPromptedAtMessageId)) {
61
+ return i;
62
+ }
63
+ }
64
+ return -1;
65
+ }
66
+
67
+ /**
68
+ * Checks if a sibling message (same parent) already has an ACCEPTED "perspective" metacognitive prompt.
69
+ * This prevents suggesting "Want to know what X thinks?" if the user just clicked "Want to know what Y thinks?"
70
+ * and we are now on the Y branch, but the X branch (sibling) had that prompt accepted.
71
+ */
72
+ function hasSiblingWithMetacognitiveEvent(
73
+ messages: readonly Message[],
74
+ currentMessage: Message
75
+ ): boolean {
76
+ const parentId = currentMessage.ancestors?.at(-1);
77
+ if (!parentId) return false;
78
+
79
+ // Find parent message
80
+ const parent = messages.find((m) => m.id === parentId);
81
+ if (!parent || !parent.children) return false;
82
+
83
+ // Check all siblings (children of same parent, excluding self)
84
+ for (const childId of parent.children) {
85
+ if (childId === currentMessage.id) continue;
86
+
87
+ const sibling = messages.find((m) => m.id === childId);
88
+ if (sibling?.metacognitiveEvents?.some((e) => e.type === "perspective" && e.accepted)) {
89
+ return true;
90
+ }
91
+ }
92
+
93
+ return false;
94
+ }
95
+
96
+ function countAssistantMessagesAfterIndex(
97
+ messages: readonly Message[],
98
+ idxExclusive: number
99
+ ): number {
100
+ let count = 0;
101
+ for (let i = idxExclusive + 1; i < messages.length; i++) {
102
+ if (messages[i]?.from === "assistant") count++;
103
+ }
104
+ return count;
105
+ }
106
+
107
+ function getCurrentAssistantPersonaId(message: Message): string | undefined {
108
+ // If the assistant message has exactly one persona response, treat that as the "current" persona.
109
+ if (message.personaResponses && message.personaResponses.length === 1) {
110
+ return message.personaResponses[0]?.personaId;
111
+ }
112
+ return undefined;
113
+ }
114
+
115
+ function pickSuggestedPersona(
116
+ currentPersonaId: string | undefined,
117
+ context: PersonaContext
118
+ ): { id: string; name: string } | undefined {
119
+ const personas = context.personas ?? [];
120
+ const activeIds = new Set(context.activePersonas ?? []);
121
+
122
+ // Select from all personas MINUS the active set (and minus current speaker just in case)
123
+ const candidates = personas.filter((p) => {
124
+ if (!p.id) return false;
125
+ if (activeIds.has(p.id)) return false;
126
+ if (currentPersonaId && p.id === currentPersonaId) return false;
127
+ return true;
128
+ });
129
+
130
+ const chosen = pickRandom(candidates);
131
+ if (!chosen) return undefined;
132
+ return { id: chosen.id, name: chosen.name ?? chosen.id };
133
+ }
134
+
135
+ function renderPerspectivePrompt(template: string, personaName: string): string {
136
+ // Use a function replacer so personaName is inserted literally (no `$&`, `$1`, `$`` expansions).
137
+ return template.replace(/\{\{personaName\}\}/g, () => personaName);
138
+ }
139
+
140
+ /**
141
+ * Determine whether a metacognitive prompt should be shown for the current (last) assistant message,
142
+ * and if so, which prompt to show.
143
+ *
144
+ * This function is intentionally pure: it derives the decision from the passed-in messages/config/state.
145
+ */
146
+ export function determineMetacognitivePrompt(
147
+ messages: readonly Message[],
148
+ config: MetacognitiveConfig | undefined,
149
+ state: DetermineState | undefined,
150
+ context: PersonaContext | undefined
151
+ ): MetacognitivePromptData | null {
152
+ if (!config?.enabled) return null;
153
+
154
+ const targetFrequency = state?.targetFrequency;
155
+ if (!targetFrequency || !Number.isFinite(targetFrequency) || targetFrequency <= 0) return null;
156
+
157
+ const lastMessage = messages[messages.length - 1];
158
+ if (!lastMessage || lastMessage.from !== "assistant") return null;
159
+ if (lastMessage.metacognitiveEvents?.length) return null;
160
+ if (state?.dismissedForMessageId && state.dismissedForMessageId === lastMessage.id) return null;
161
+
162
+ // Check if a sibling already triggered a perspective prompt (meaning this branch is the result of one)
163
+ if (hasSiblingWithMetacognitiveEvent(messages, lastMessage)) {
164
+ return null;
165
+ }
166
+
167
+ // Frequency gate: only show on the Nth assistant message since the last "shown" event OR multi-persona reset.
168
+ // We pass lastPromptedAtMessageId to account for prompts shown on sibling branches (which we might not see in 'messages', but whose parent we see).
169
+ const lastShownIdx = getLastShownMetacognitiveIndex(messages, state?.lastPromptedAtMessageId);
170
+ const assistantSinceLastShown = countAssistantMessagesAfterIndex(messages, lastShownIdx);
171
+
172
+ if (assistantSinceLastShown < targetFrequency) return null;
173
+
174
+ // Determine available prompt types
175
+ const currentPersonaId = getCurrentAssistantPersonaId(lastMessage);
176
+ const suggestedPersona = pickSuggestedPersona(currentPersonaId, context ?? {});
177
+
178
+ const options: Array<() => MetacognitivePromptData> = [];
179
+
180
+ // Option 1: Perspective Prompts (requires a suggested persona)
181
+ if (suggestedPersona && config.perspectivePrompts?.length) {
182
+ const prompts = config.perspectivePrompts;
183
+ options.push(() => {
184
+ const template = pickRandom(prompts) ?? "Want to know what {{personaName}} thinks?";
185
+ return {
186
+ type: "perspective",
187
+ promptText: renderPerspectivePrompt(template, suggestedPersona.name),
188
+ triggerFrequency: targetFrequency,
189
+ suggestedPersonaId: suggestedPersona.id,
190
+ suggestedPersonaName: suggestedPersona.name,
191
+ messageId: lastMessage.id,
192
+ };
193
+ });
194
+ }
195
+
196
+ // Option 2: Comprehension Prompts
197
+ if (config.comprehensionPrompts?.length) {
198
+ const prompts = config.comprehensionPrompts;
199
+ options.push(() => {
200
+ const promptText =
201
+ pickRandom(prompts) ??
202
+ "Is there anything in this response that you do not fully understand?";
203
+ return {
204
+ type: "comprehension",
205
+ promptText,
206
+ triggerFrequency: targetFrequency,
207
+ messageId: lastMessage.id,
208
+ };
209
+ });
210
+ }
211
+
212
+ // Randomly pick one of the available options
213
+ const chosenOption = pickRandom(options);
214
+ if (!chosenOption) return null;
215
+
216
+ return chosenOption();
217
+ }
src/lib/utils/tree/addChildren.ts CHANGED
@@ -28,7 +28,12 @@ export function addChildren<T>(conv: Tree<T>, message: NewNode<T>, parentId?: Tr
28
  return messageId;
29
  }
30
 
31
- const ancestors = [...(conv.messages.find((m) => m.id === parentId)?.ancestors ?? []), parentId];
 
 
 
 
 
32
  conv.messages.push({
33
  ...message,
34
  ancestors,
@@ -36,13 +41,9 @@ export function addChildren<T>(conv: Tree<T>, message: NewNode<T>, parentId?: Tr
36
  children: [],
37
  } as TreeNode<T>);
38
 
39
- const parent = conv.messages.find((m) => m.id === parentId);
40
-
41
- if (parent) {
42
- if (parent.children) {
43
- parent.children.push(messageId);
44
- } else parent.children = [messageId];
45
- }
46
 
47
  return messageId;
48
  }
 
28
  return messageId;
29
  }
30
 
31
+ const parent = conv.messages.find((m) => m.id === parentId);
32
+ if (!parent) {
33
+ throw new Error("Parent message not found");
34
+ }
35
+
36
+ const ancestors = [...(parent.ancestors ?? []), parentId];
37
  conv.messages.push({
38
  ...message,
39
  ancestors,
 
41
  children: [],
42
  } as TreeNode<T>);
43
 
44
+ if (parent.children) {
45
+ parent.children.push(messageId);
46
+ } else parent.children = [messageId];
 
 
 
 
47
 
48
  return messageId;
49
  }
src/lib/utils/tree/layout.ts CHANGED
@@ -1,6 +1,7 @@
1
  import ELK from "elkjs/lib/elk.bundled.js";
2
  import type { Message } from "$lib/types/Message";
3
  import { MessageRole } from "$lib/types/Message";
 
4
 
5
  // Initialize ELK with default options
6
  const elk = new ELK();
@@ -33,7 +34,13 @@ export interface TreeLayoutResult {
33
  }>;
34
  }
35
 
36
- // Add explicit interface for ELK edge
 
 
 
 
 
 
37
  interface ElkEdge {
38
  id: string;
39
  sources: string[];
@@ -47,26 +54,21 @@ interface ExtendedElkNode {
47
  width?: number;
48
  height?: number;
49
  children?: ExtendedElkNode[];
 
50
  edges?: ElkEdge[];
51
  layoutOptions?: Record<string, string>;
52
  }
53
 
54
- // Helper to calculate node width based on content
55
  function getNodeWidth(message: Message): number {
56
- const baseSize = 24;
57
- // For assistant messages with multiple persona responses, we need more width
58
- // only if we are displaying them side-by-side or in a specific way.
59
- // For now, the visual implementation in ConversationTreeGraph uses a fixed size
60
- // but renders multiple icons. We should reserve space for them to prevent overlap.
61
  if (
62
  message.from === MessageRole.Assistant &&
63
  message.personaResponses &&
64
  message.personaResponses.length > 1
65
  ) {
66
- const iconSize = 18;
67
- const spacing = 8;
68
  const count = message.personaResponses.length;
69
- // Calculate total width needed for the horizontal layout of icons
70
  return Math.max(baseSize, count * iconSize + (count - 1) * spacing);
71
  }
72
  return baseSize;
@@ -87,27 +89,55 @@ export async function buildTreeWithPositions(
87
  return { nodes: [], width: 0, height: 0, connections: [] };
88
  }
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  // Map messages by ID for easy access
91
- const messageMap = new Map(messages.map((m) => [m.id, m]));
92
 
93
  // Identify roots (messages with no parent or parent not in the list)
94
  const getParentId = (m: Message) => m.ancestors?.at(-1);
95
 
96
- const roots = messages.filter(
97
  (m) => !m.children?.length && (!getParentId(m) || !messageMap.has(getParentId(m) ?? ""))
98
  );
99
 
100
  // Fallback for roots if none found (circular or system msg issues)
101
- if (roots.length === 0 && messages.length > 0) {
102
  // Try finding messages with parentId that doesn't exist in the current set
103
- const potentialRoots = messages.filter(
104
  (m) => !getParentId(m) || !messageMap.has(getParentId(m) ?? "")
105
  );
106
  if (potentialRoots.length > 0) {
107
  roots.push(...potentialRoots);
108
  } else {
109
  // Ultimate fallback: just take the first one
110
- roots.push(messages[0]);
111
  }
112
  }
113
 
@@ -124,23 +154,87 @@ export async function buildTreeWithPositions(
124
 
125
  const width = getNodeWidth(message);
126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  elkNodes.push({
128
  id: message.id,
129
  width,
130
- height: 24,
 
131
  layoutOptions: {
132
- "elk.portConstraints": "FIXED_SIDE",
133
  "elk.portAlignment.default": "CENTER",
134
  },
135
  });
136
 
137
  if (message.children) {
138
- for (const childId of message.children) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  const child = messageMap.get(childId);
140
  if (child) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  elkEdges.push({
142
  id: `${message.id}-${childId}`,
143
- sources: [message.id],
144
  targets: [childId],
145
  });
146
  buildSubgraph(child);
@@ -152,9 +246,8 @@ export async function buildTreeWithPositions(
152
  // Build graph from all roots
153
  roots.forEach((root) => buildSubgraph(root));
154
 
155
- // Also scan for any disconnected components that weren't reached from roots
156
- // (This handles cases where message history might be fragmented)
157
- messages.forEach((m) => {
158
  if (!visited.has(m.id)) {
159
  buildSubgraph(m);
160
  }
@@ -165,11 +258,16 @@ export async function buildTreeWithPositions(
165
  layoutOptions: {
166
  "elk.algorithm": "layered",
167
  "elk.direction": "DOWN",
168
- "elk.spacing.nodeNode": "20",
169
- "elk.layered.spacing.nodeNodeBetweenLayers": "15",
170
  "elk.edgeRouting": "SPLINES",
 
171
  "elk.spacing.componentComponent": "30",
 
 
 
 
 
172
  },
 
173
  children: elkNodes,
174
  edges: elkEdges,
175
  };
@@ -237,6 +335,7 @@ export async function buildTreeWithPositions(
237
  const connections: TreeLayoutResult["connections"] = [];
238
 
239
  resultNodes.forEach((node) => {
 
240
  if (node.parentId && nodeMap.has(node.parentId)) {
241
  const parent = nodeMap.get(node.parentId);
242
  if (!parent) return;
 
1
  import ELK from "elkjs/lib/elk.bundled.js";
2
  import type { Message } from "$lib/types/Message";
3
  import { MessageRole } from "$lib/types/Message";
4
+ import { TREE_CONFIG } from "$lib/constants/treeConfig";
5
 
6
  // Initialize ELK with default options
7
  const elk = new ELK();
 
34
  }>;
35
  }
36
 
37
+ interface ElkPort {
38
+ id: string;
39
+ layoutOptions?: Record<string, string>;
40
+ width?: number;
41
+ height?: number;
42
+ }
43
+
44
  interface ElkEdge {
45
  id: string;
46
  sources: string[];
 
54
  width?: number;
55
  height?: number;
56
  children?: ExtendedElkNode[];
57
+ ports?: ElkPort[];
58
  edges?: ElkEdge[];
59
  layoutOptions?: Record<string, string>;
60
  }
61
 
 
62
  function getNodeWidth(message: Message): number {
63
+ const baseSize = TREE_CONFIG.nodeSize;
 
 
 
 
64
  if (
65
  message.from === MessageRole.Assistant &&
66
  message.personaResponses &&
67
  message.personaResponses.length > 1
68
  ) {
69
+ const iconSize = TREE_CONFIG.iconSize;
70
+ const spacing = TREE_CONFIG.spacing;
71
  const count = message.personaResponses.length;
 
72
  return Math.max(baseSize, count * iconSize + (count - 1) * spacing);
73
  }
74
  return baseSize;
 
89
  return { nodes: [], width: 0, height: 0, connections: [] };
90
  }
91
 
92
+ // Filter out assistant messages that are just starting (empty content, no personas yet fully loaded)
93
+ // to prevent "flickering" or double updates.
94
+
95
+ const messagesToHide = new Set<string>();
96
+
97
+ const lastMessage = messages[messages.length - 1];
98
+ if (lastMessage) {
99
+ const isStreamingAssistant =
100
+ lastMessage.from === MessageRole.Assistant &&
101
+ (!lastMessage.content || lastMessage.content.length === 0) &&
102
+ (!lastMessage.personaResponses ||
103
+ !lastMessage.personaResponses.some((p) => p.content.length > 0));
104
+
105
+ if (isStreamingAssistant) {
106
+ messagesToHide.add(lastMessage.id);
107
+
108
+ // Also hide the message that triggered this response (the parent of the streaming assistant message)
109
+ // This handles the "User -> Assistant" flow where we want both to appear together.
110
+ // We look up the parent ID from the ancestors list or parent property.
111
+ const parentId = lastMessage.ancestors?.at(-1);
112
+ if (parentId) {
113
+ messagesToHide.add(parentId);
114
+ }
115
+ }
116
+ }
117
+
118
+ const visibleMessages = messages.filter((m) => !messagesToHide.has(m.id));
119
+
120
  // Map messages by ID for easy access
121
+ const messageMap = new Map(visibleMessages.map((m) => [m.id, m]));
122
 
123
  // Identify roots (messages with no parent or parent not in the list)
124
  const getParentId = (m: Message) => m.ancestors?.at(-1);
125
 
126
+ const roots = visibleMessages.filter(
127
  (m) => !m.children?.length && (!getParentId(m) || !messageMap.has(getParentId(m) ?? ""))
128
  );
129
 
130
  // Fallback for roots if none found (circular or system msg issues)
131
+ if (roots.length === 0 && visibleMessages.length > 0) {
132
  // Try finding messages with parentId that doesn't exist in the current set
133
+ const potentialRoots = visibleMessages.filter(
134
  (m) => !getParentId(m) || !messageMap.has(getParentId(m) ?? "")
135
  );
136
  if (potentialRoots.length > 0) {
137
  roots.push(...potentialRoots);
138
  } else {
139
  // Ultimate fallback: just take the first one
140
+ roots.push(visibleMessages[0]);
141
  }
142
  }
143
 
 
154
 
155
  const width = getNodeWidth(message);
156
 
157
+ // Configure ports for multi-persona nodes
158
+ const ports: ElkPort[] = [];
159
+ const isMultiPersona =
160
+ message.from === MessageRole.Assistant &&
161
+ message.personaResponses &&
162
+ message.personaResponses.length > 1;
163
+
164
+ if (isMultiPersona && message.personaResponses) {
165
+ message.personaResponses.forEach((_, index) => {
166
+ ports.push({
167
+ id: `${message.id}-p${index}`,
168
+ width: 0,
169
+ height: 0,
170
+ layoutOptions: {
171
+ "elk.port.side": "SOUTH",
172
+ "elk.port.index": `${index}`,
173
+ },
174
+ });
175
+ });
176
+ }
177
+
178
  elkNodes.push({
179
  id: message.id,
180
  width,
181
+ height: TREE_CONFIG.nodeSize,
182
+ ports,
183
  layoutOptions: {
184
+ "elk.portConstraints": "FIXED_ORDER",
185
  "elk.portAlignment.default": "CENTER",
186
  },
187
  });
188
 
189
  if (message.children) {
190
+ // Sort children based on the persona they branched from to ensure correct left-to-right ordering
191
+ const sortedChildren = [...message.children].sort((aId, bId) => {
192
+ const a = messageMap.get(aId);
193
+ const b = messageMap.get(bId);
194
+
195
+ // Only relevant if parent has multiple personas
196
+ if (!isMultiPersona) return 0;
197
+
198
+ const getPersonaIndex = (m?: Message) => {
199
+ if (!m?.branchedFrom?.personaId || !message.personaResponses) return -1;
200
+ // If branched from this parent's persona, find index
201
+ if (m.branchedFrom.messageId === message.id) {
202
+ return message.personaResponses.findIndex(
203
+ (p) => p.personaId === m.branchedFrom?.personaId
204
+ );
205
+ }
206
+ return -1;
207
+ };
208
+
209
+ const idxA = getPersonaIndex(a);
210
+ const idxB = getPersonaIndex(b);
211
+
212
+ if (idxA === idxB) return 0;
213
+ return idxA - idxB;
214
+ });
215
+
216
+ for (const childId of sortedChildren) {
217
  const child = messageMap.get(childId);
218
  if (child) {
219
+ // Determine source port
220
+ let sourceId = message.id;
221
+ if (isMultiPersona && message.personaResponses) {
222
+ let portIndex = 0; // Default to first persona/port
223
+
224
+ if (child.branchedFrom?.messageId === message.id) {
225
+ const idx = message.personaResponses.findIndex(
226
+ (p) => p.personaId === child.branchedFrom?.personaId
227
+ );
228
+ if (idx !== -1) {
229
+ portIndex = idx;
230
+ }
231
+ }
232
+ sourceId = `${message.id}-p${portIndex}`;
233
+ }
234
+
235
  elkEdges.push({
236
  id: `${message.id}-${childId}`,
237
+ sources: [sourceId],
238
  targets: [childId],
239
  });
240
  buildSubgraph(child);
 
246
  // Build graph from all roots
247
  roots.forEach((root) => buildSubgraph(root));
248
 
249
+ // Scan for any disconnected components
250
+ visibleMessages.forEach((m) => {
 
251
  if (!visited.has(m.id)) {
252
  buildSubgraph(m);
253
  }
 
258
  layoutOptions: {
259
  "elk.algorithm": "layered",
260
  "elk.direction": "DOWN",
 
 
261
  "elk.edgeRouting": "SPLINES",
262
+ "elk.spacing.nodeNode": "20",
263
  "elk.spacing.componentComponent": "30",
264
+ "elk.layered.spacing.nodeNodeBetweenLayers": "15",
265
+ "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", // INTERACTIVE LAYER_SWEEP NONE
266
+ "elk.layered.crossingMinimization.forceNodeModelOrder": "true", // true false
267
+ "elk.layered.nodePlacement.strategy": "SIMPLE", // BRANDES_KOEPF LINEAR_SEGMENTS NETWORK_SIMPLEX SIMPLE NONE
268
+ "elk.layered.considerModelOrder": "NONE", // NODES_AND_EDGES PERFER_NODES PREFER_EDGES NONE
269
  },
270
+
271
  children: elkNodes,
272
  edges: elkEdges,
273
  };
 
335
  const connections: TreeLayoutResult["connections"] = [];
336
 
337
  resultNodes.forEach((node) => {
338
+ // Only check parent if we know it exists in our filtered map
339
  if (node.parentId && nodeMap.has(node.parentId)) {
340
  const parent = nodeMap.get(node.parentId);
341
  if (!parent) return;
src/routes/+layout.svelte CHANGED
@@ -273,7 +273,7 @@
273
 
274
  <MobileNav title={mobileNavTitle}>
275
  <NavMenu
276
- {conversations}
277
  user={data.user}
278
  canLogin={!data.user && data.loginEnabled}
279
  ondeleteConversation={(id) => deleteConversation(id)}
@@ -285,7 +285,7 @@
285
  style={!isNavCollapsed ? "width: var(--sidebar-width, 290px);" : "width: 0;"}
286
  >
287
  <NavMenu
288
- {conversations}
289
  user={data.user}
290
  canLogin={!data.user && data.loginEnabled}
291
  ondeleteConversation={(id) => deleteConversation(id)}
 
273
 
274
  <MobileNav title={mobileNavTitle}>
275
  <NavMenu
276
+ bind:conversations
277
  user={data.user}
278
  canLogin={!data.user && data.loginEnabled}
279
  ondeleteConversation={(id) => deleteConversation(id)}
 
285
  style={!isNavCollapsed ? "width: var(--sidebar-width, 290px);" : "width: 0;"}
286
  >
287
  <NavMenu
288
+ bind:conversations
289
  user={data.user}
290
  canLogin={!data.user && data.loginEnabled}
291
  ondeleteConversation={(id) => deleteConversation(id)}
src/routes/+layout.ts CHANGED
@@ -8,16 +8,25 @@ export const load = async ({ depends, fetch, url }) => {
8
 
9
  const client = useAPIClient({ fetch, origin: url.origin });
10
 
11
- const [settings, models, oldModels, user, publicConfig, featureFlags, conversationsData] =
12
- await Promise.all([
13
- client.user.settings.get().then(handleResponse),
14
- client.models.get().then(handleResponse),
15
- client.models.old.get().then(handleResponse),
16
- client.user.get().then(handleResponse),
17
- client["public-config"].get().then(handleResponse),
18
- client["feature-flags"].get().then(handleResponse),
19
- client.conversations.get({ query: { p: 0 } }).then(handleResponse),
20
- ]);
 
 
 
 
 
 
 
 
 
21
 
22
  const defaultModel = models[0];
23
 
@@ -51,6 +60,12 @@ export const load = async ({ depends, fetch, url }) => {
51
  : null,
52
  },
53
  publicConfig: getConfigManager(publicConfig),
 
 
 
 
 
 
54
  ...featureFlags,
55
  };
56
  };
 
8
 
9
  const client = useAPIClient({ fetch, origin: url.origin });
10
 
11
+ const [
12
+ settings,
13
+ models,
14
+ oldModels,
15
+ user,
16
+ publicConfig,
17
+ featureFlags,
18
+ conversationsData,
19
+ metacognitiveConfig,
20
+ ] = await Promise.all([
21
+ client.user.settings.get().then(handleResponse),
22
+ client.models.get().then(handleResponse),
23
+ client.models.old.get().then(handleResponse),
24
+ client.user.get().then(handleResponse),
25
+ client["public-config"].get().then(handleResponse),
26
+ client["feature-flags"].get().then(handleResponse),
27
+ client.conversations.get({ query: { p: 0 } }).then(handleResponse),
28
+ client["metacognitive-config"].get().then(handleResponse),
29
+ ]);
30
 
31
  const defaultModel = models[0];
32
 
 
60
  : null,
61
  },
62
  publicConfig: getConfigManager(publicConfig),
63
+ metacognitiveConfig: metacognitiveConfig as {
64
+ frequencies: number[];
65
+ comprehensionPrompts: string[];
66
+ perspectivePrompts: string[];
67
+ enabled: boolean;
68
+ },
69
  ...featureFlags,
70
  };
71
  };
src/routes/+page.svelte CHANGED
@@ -25,12 +25,6 @@ const publicConfig = usePublicConfig();
25
  try {
26
  loading = true;
27
 
28
- await resetActivePersonasToDefaults(
29
- settings,
30
- $settings.personas,
31
- $settings.activePersonas
32
- );
33
-
34
  // check if $settings.activeModel is a valid model
35
  // else check if it's an assistant, and use that model
36
  // else use the first model
@@ -78,7 +72,13 @@ const publicConfig = usePublicConfig();
78
  }
79
  }
80
 
81
- onMount(() => {
 
 
 
 
 
 
82
  // check if there's a ?q query param with a message
83
  const query = page.url.searchParams.get("q");
84
  if (query) createConversation(query);
 
25
  try {
26
  loading = true;
27
 
 
 
 
 
 
 
28
  // check if $settings.activeModel is a valid model
29
  // else check if it's an assistant, and use that model
30
  // else use the first model
 
72
  }
73
  }
74
 
75
+ onMount(async () => {
76
+ await resetActivePersonasToDefaults(
77
+ settings,
78
+ $settings.personas,
79
+ $settings.activePersonas
80
+ );
81
+
82
  // check if there's a ?q query param with a message
83
  const query = page.url.searchParams.get("q");
84
  if (query) createConversation(query);
src/routes/api/conversation/[id]/+server.ts CHANGED
@@ -31,6 +31,7 @@ export async function GET({ locals, params }) {
31
  updates: message.updates,
32
  reasoning: message.reasoning,
33
  personaResponses: message.personaResponses,
 
34
  })),
35
  };
36
  return Response.json(res);
 
31
  updates: message.updates,
32
  reasoning: message.reasoning,
33
  personaResponses: message.personaResponses,
34
+ metacognitiveEvents: message.metacognitiveEvents,
35
  })),
36
  };
37
  return Response.json(res);
src/routes/conversation/[id]/+page.svelte CHANGED
@@ -351,6 +351,84 @@ let branchSyncTimeout: ReturnType<typeof setTimeout> | null = null;
351
  }
352
  }, 100);
353
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
354
 
355
  let messages = $state(data.messages);
356
  let lastDataMessages = data.messages;
@@ -391,6 +469,7 @@ let branchSyncTimeout: ReturnType<typeof setTimeout> | null = null;
391
  $effect(() => {
392
  const url = page.url;
393
  const msgIdParam = url.searchParams.get("msgId");
 
394
  const keepBranch = url.searchParams.get("keepBranch") === "true";
395
 
396
  if (!msgIdParam) {
@@ -412,6 +491,7 @@ let branchSyncTimeout: ReturnType<typeof setTimeout> | null = null;
412
  urlObj.searchParams.delete("msgId");
413
  urlObj.searchParams.delete('scrollTo');
414
  urlObj.searchParams.delete('keepBranch');
 
415
  goto(urlObj.pathname + urlObj.search, { replaceState: true, noScroll: true });
416
 
417
  if (keepBranch) {
@@ -435,6 +515,13 @@ let branchSyncTimeout: ReturnType<typeof setTimeout> | null = null;
435
  setTimeout(() => {
436
  const messageElement = document.querySelector(`[data-message-id="${msgIdParam}"]`);
437
  if (messageElement) {
 
 
 
 
 
 
 
438
  messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
439
  }
440
  }, 200);
@@ -639,12 +726,15 @@ let branchSyncTimeout: ReturnType<typeof setTimeout> | null = null;
639
  preprompt={data.preprompt}
640
  personaId={(data as any).personaId}
641
  branchState={activeBranch}
 
 
642
  bind:files
643
  onmessage={onMessage}
644
  onretry={onRetry}
645
  oncontinue={onContinue}
646
  onshowAlternateMsg={onShowAlternateMsg}
647
  onbranch={onBranch}
 
648
  onstop={async () => {
649
  await fetch(`${base}/conversation/${page.params.id}/stop-generating`, {
650
  method: "POST",
 
351
  }
352
  }, 100);
353
  }
354
+
355
+ // Metacognitive branch handler: branches and auto-generates with the suggested persona
356
+ async function onMetacognitiveBranch(
357
+ userMessageId: string,
358
+ suggestedPersonaId: string,
359
+ promptData: {
360
+ type: "comprehension" | "perspective";
361
+ promptText: string;
362
+ triggerFrequency: number;
363
+ suggestedPersonaId?: string;
364
+ suggestedPersonaName?: string;
365
+ } | null
366
+ ) {
367
+ if (!promptData) return;
368
+
369
+ const suggestedPersona = $settings.personas?.find((p) => p.id === suggestedPersonaId);
370
+ if (!suggestedPersona) {
371
+ console.error('Suggested persona not found:', suggestedPersonaId);
372
+ return;
373
+ }
374
+
375
+ // Find the user message we're branching from
376
+ const userMessage = messages.find(m => m.id === userMessageId);
377
+ if (!userMessage) {
378
+ console.error('User message not found for metacognitive branch:', userMessageId);
379
+ return;
380
+ }
381
+
382
+ // Log the metacognitive event to the database
383
+ const lastAssistantMessage = messagesPath.at(-1);
384
+ if (lastAssistantMessage && lastAssistantMessage.from === "assistant") {
385
+ try {
386
+ const response = await fetch(`${base}/api/v2/conversations/${page.params.id}/message/${lastAssistantMessage.id}/metacognitive-event`, {
387
+ method: "POST",
388
+ headers: { "Content-Type": "application/json" },
389
+ body: JSON.stringify({
390
+ type: promptData.type,
391
+ promptText: promptData.promptText,
392
+ triggerFrequency: promptData.triggerFrequency,
393
+ suggestedPersonaId: promptData.suggestedPersonaId,
394
+ suggestedPersonaName: promptData.suggestedPersonaName,
395
+ accepted: true,
396
+ }),
397
+ });
398
+ if (!response.ok) {
399
+ console.error('Failed to log metacognitive event:', response.status, await response.text());
400
+ }
401
+ } catch (e) {
402
+ console.error('Failed to log metacognitive event:', e);
403
+ }
404
+ }
405
+
406
+ // Set branch state pointing to the user message
407
+ updateBranchState({
408
+ messageId: userMessageId,
409
+ personaId: suggestedPersonaId,
410
+ personaName: suggestedPersona.name,
411
+ });
412
+
413
+ // Switch active persona to the suggested one
414
+ await settings.instantSet({
415
+ activePersonas: [suggestedPersonaId],
416
+ });
417
+
418
+ // Navigate to the user message and trigger a retry with the new persona
419
+ // This effectively creates a branch where the new persona responds to the user's question
420
+ targetMessageId = userMessageId;
421
+
422
+ // Wait for state to settle, then trigger the message generation
423
+ await tick();
424
+
425
+ // Trigger a retry from the user message with the new persona
426
+ await writeMessage({
427
+ messageId: userMessageId,
428
+ isRetry: true,
429
+ personaId: suggestedPersonaId,
430
+ });
431
+ }
432
 
433
  let messages = $state(data.messages);
434
  let lastDataMessages = data.messages;
 
469
  $effect(() => {
470
  const url = page.url;
471
  const msgIdParam = url.searchParams.get("msgId");
472
+ const personaIdParam = url.searchParams.get("personaId");
473
  const keepBranch = url.searchParams.get("keepBranch") === "true";
474
 
475
  if (!msgIdParam) {
 
491
  urlObj.searchParams.delete("msgId");
492
  urlObj.searchParams.delete('scrollTo');
493
  urlObj.searchParams.delete('keepBranch');
494
+ urlObj.searchParams.delete('personaId');
495
  goto(urlObj.pathname + urlObj.search, { replaceState: true, noScroll: true });
496
 
497
  if (keepBranch) {
 
515
  setTimeout(() => {
516
  const messageElement = document.querySelector(`[data-message-id="${msgIdParam}"]`);
517
  if (messageElement) {
518
+ if (personaIdParam) {
519
+ const personaElement = messageElement.querySelector(`[data-persona-id="${personaIdParam}"]`);
520
+ if (personaElement) {
521
+ personaElement.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'center' });
522
+ return;
523
+ }
524
+ }
525
  messageElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
526
  }
527
  }, 200);
 
726
  preprompt={data.preprompt}
727
  personaId={(data as any).personaId}
728
  branchState={activeBranch}
729
+ metacognitiveConfig={data.metacognitiveConfig}
730
+ metacognitiveState={(data as any).metacognitiveState}
731
  bind:files
732
  onmessage={onMessage}
733
  onretry={onRetry}
734
  oncontinue={onContinue}
735
  onshowAlternateMsg={onShowAlternateMsg}
736
  onbranch={onBranch}
737
+ onmetacognitivebranch={onMetacognitiveBranch}
738
  onstop={async () => {
739
  await fetch(`${base}/conversation/${page.params.id}/stop-generating`, {
740
  method: "POST",
src/routes/conversation/[id]/+server.ts CHANGED
@@ -268,9 +268,9 @@ export async function POST({ request, locals, params, getClientAddress }) {
268
  files: uploadedFiles,
269
  createdAt: new Date(),
270
  updatedAt: new Date(),
271
- // Copy branchedFrom if it exists
272
- ...(messageToRetry.branchedFrom && {
273
- branchedFrom: messageToRetry.branchedFrom,
274
  }),
275
  },
276
  messageId
@@ -294,6 +294,23 @@ export async function POST({ request, locals, params, getClientAddress }) {
294
  newUserMessageId
295
  );
296
  messagesForPrompt = buildSubtree(conv, newUserMessageId);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  } else if (messageToRetry.from === "assistant") {
298
  // Regenerating assistant response: create new sibling response
299
  messageToWriteToId = addSibling(
@@ -303,9 +320,9 @@ export async function POST({ request, locals, params, getClientAddress }) {
303
  content: "",
304
  createdAt: new Date(),
305
  updatedAt: new Date(),
306
- // Copy branchedFrom if it exists
307
- ...(messageToRetry.branchedFrom && {
308
- branchedFrom: messageToRetry.branchedFrom,
309
  }),
310
  },
311
  messageId
@@ -318,6 +335,17 @@ export async function POST({ request, locals, params, getClientAddress }) {
318
  } else {
319
  // just a normal linear conversation, so we add the user message
320
  // and the blank assistant message back to back
 
 
 
 
 
 
 
 
 
 
 
321
  const newUserMessageId = addChildren(
322
  conv,
323
  {
@@ -387,6 +415,7 @@ export async function POST({ request, locals, params, getClientAddress }) {
387
  const startedEvent = {
388
  type: MessageUpdateType.Status,
389
  status: MessageUpdateStatus.Started,
 
390
  };
391
  try {
392
  controller.enqueue(JSON.stringify(startedEvent) + "\n");
 
268
  files: uploadedFiles,
269
  createdAt: new Date(),
270
  updatedAt: new Date(),
271
+ // Use branchedFrom from request if exists, otherwise copy from original
272
+ ...((branchedFrom || messageToRetry.branchedFrom) && {
273
+ branchedFrom: branchedFrom ?? messageToRetry.branchedFrom,
274
  }),
275
  },
276
  messageId
 
294
  newUserMessageId
295
  );
296
  messagesForPrompt = buildSubtree(conv, newUserMessageId);
297
+ } else if (messageToRetry.from === "user" && !newPrompt) {
298
+ // Branching from existing user message without editing
299
+ messageToWriteToId = addChildren(
300
+ conv,
301
+ {
302
+ from: "assistant",
303
+ content: "",
304
+ createdAt: new Date(),
305
+ updatedAt: new Date(),
306
+ // Use branchedFrom from request if exists, otherwise copy from original
307
+ ...((branchedFrom || messageToRetry.branchedFrom) && {
308
+ branchedFrom: branchedFrom ?? messageToRetry.branchedFrom,
309
+ }),
310
+ },
311
+ messageId
312
+ );
313
+ messagesForPrompt = buildSubtree(conv, messageId);
314
  } else if (messageToRetry.from === "assistant") {
315
  // Regenerating assistant response: create new sibling response
316
  messageToWriteToId = addSibling(
 
320
  content: "",
321
  createdAt: new Date(),
322
  updatedAt: new Date(),
323
+ // Use branchedFrom from request if exists, otherwise copy from original
324
+ ...((branchedFrom || messageToRetry.branchedFrom) && {
325
+ branchedFrom: branchedFrom ?? messageToRetry.branchedFrom,
326
  }),
327
  },
328
  messageId
 
335
  } else {
336
  // just a normal linear conversation, so we add the user message
337
  // and the blank assistant message back to back
338
+
339
+ if (conv.messages.length > 0) {
340
+ if (!messageId) {
341
+ error(400, "Parent message ID is required");
342
+ }
343
+ const parent = conv.messages.find((m) => m.id === messageId);
344
+ if (!parent) {
345
+ error(404, "Parent message not found");
346
+ }
347
+ }
348
+
349
  const newUserMessageId = addChildren(
350
  conv,
351
  {
 
415
  const startedEvent = {
416
  type: MessageUpdateType.Status,
417
  status: MessageUpdateStatus.Started,
418
+ messageId: messageToWriteToId,
419
  };
420
  try {
421
  controller.enqueue(JSON.stringify(startedEvent) + "\n");