Spaces:
Running
Running
Andrew
commited on
Commit
·
cb5990d
1
Parent(s):
8967568
feat(tree): Add ELK port-based layout and persona-specific branching
Browse files- src/lib/actions/snapScrollToBottom.ts +40 -25
- src/lib/components/ConversationTreeGraph.svelte +120 -54
- src/lib/components/CopyToClipBoardBtn.svelte +10 -3
- src/lib/components/NavConversationItem.svelte +56 -8
- src/lib/components/ShareConversationModal.svelte +3 -0
- src/lib/components/chat/ChatMessage.svelte +144 -97
- src/lib/components/chat/ChatWindow.svelte +33 -3
- src/lib/components/chat/MetacognitivePrompt.svelte +98 -0
- src/lib/constants/treeConfig.ts +7 -0
- src/lib/hooks/useMetacognitiveEngine.svelte.ts +307 -0
- src/lib/server/api/routes/groups/conversations.ts +112 -0
- src/lib/server/api/routes/groups/misc.ts +10 -0
- src/lib/server/config.ts +8 -1
- src/lib/server/endpoints/preprocessMessages.ts +1 -1
- src/lib/server/metacognitiveConfig.ts +133 -0
- src/lib/stores/treeVisibility.ts +17 -0
- src/lib/types/Conversation.ts +6 -0
- src/lib/types/Message.ts +16 -0
- src/lib/types/MessageContext.ts +41 -0
- src/lib/types/MessageUpdate.ts +1 -0
- src/lib/types/Metacognitive.ts +18 -0
- src/lib/types/Persona.ts +9 -4
- src/lib/utils/message/ConversationTreeManager.ts +268 -0
- src/lib/utils/message/MessageStreamHandler.ts +228 -0
- src/lib/utils/messageSender.ts +21 -347
- src/lib/utils/metacognitiveLogic.spec.ts +148 -0
- src/lib/utils/metacognitiveLogic.ts +217 -0
- src/lib/utils/tree/addChildren.ts +9 -8
- src/lib/utils/tree/layout.ts +123 -24
- src/routes/+layout.svelte +2 -2
- src/routes/+layout.ts +25 -10
- src/routes/+page.svelte +7 -7
- src/routes/api/conversation/[id]/+server.ts +1 -0
- src/routes/conversation/[id]/+page.svelte +90 -0
- 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
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
isDetached = false;
|
|
|
|
|
|
|
| 23 |
}
|
| 24 |
-
|
| 25 |
-
prevScrollValue = node.scrollTop;
|
| 26 |
};
|
| 27 |
|
| 28 |
-
const
|
| 29 |
-
const
|
| 30 |
-
const options = { ...defaultOptions, ..._options };
|
| 31 |
-
const { force } = options;
|
| 32 |
|
| 33 |
if (!force && isDetached && !navigating.to) return;
|
| 34 |
|
| 35 |
-
//
|
| 36 |
await tick();
|
| 37 |
|
| 38 |
-
node.scrollTo({ top: node.scrollHeight });
|
| 39 |
};
|
| 40 |
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
|
|
|
| 43 |
if (dependency) {
|
| 44 |
-
|
| 45 |
}
|
| 46 |
|
| 47 |
return {
|
| 48 |
-
update
|
| 49 |
destroy: () => {
|
| 50 |
-
node.removeEventListener("scroll",
|
|
|
|
| 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 =
|
| 18 |
-
{@const iconSize =
|
| 19 |
-
{@const svgWidth = Math.max(treeData.width,
|
| 20 |
-
{@const svgHeight = Math.max(treeData.height,
|
| 21 |
|
| 22 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 38 |
{@const totalWidth = personaCount * iconSize + (personaCount - 1) * spacing}
|
| 39 |
|
| 40 |
<!-- Use ELK coordinates directly -->
|
| 41 |
-
{@const
|
|
|
|
| 42 |
{@const rightmostX = leftmostX + (personaCount - 1) * (iconSize + spacing)}
|
| 43 |
-
{@const childCenterX = node.x +
|
| 44 |
|
| 45 |
-
{@const
|
|
|
|
|
|
|
|
|
|
| 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},{
|
| 52 |
-
C {parentCenterX},{
|
| 53 |
-
{childCenterX},{junctionY -
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 +
|
| 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 =
|
| 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
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
>
|
| 176 |
-
<
|
| 177 |
-
|
| 178 |
-
|
| 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 |
-
|
| 77 |
-
|
|
|
|
|
|
|
| 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 {
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 221 |
const element = contentElements[personaId];
|
| 222 |
if (element) {
|
| 223 |
element.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
| 224 |
}
|
| 225 |
-
}
|
| 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 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
{
|
|
|
|
| 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 &&
|
| 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
|
| 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
|
|
|
|
|
|
|
| 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 (
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 41 |
-
-
|
|
|
|
| 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 |
-
-
|
| 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 |
-
-
|
| 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
|
| 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 |
-
|
|
|
|
| 76 |
|
| 77 |
try {
|
| 78 |
ctx.setIsAborted(false);
|
|
@@ -90,306 +46,32 @@ export async function writeMessage(
|
|
| 90 |
)
|
| 91 |
);
|
| 92 |
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 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 |
-
|
| 238 |
conversationId,
|
| 239 |
{
|
| 240 |
base,
|
| 241 |
-
|
| 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 |
-
|
| 255 |
-
)
|
| 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 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 40 |
-
|
| 41 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 =
|
| 67 |
-
const spacing =
|
| 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(
|
| 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 =
|
| 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 &&
|
| 102 |
// Try finding messages with parentId that doesn't exist in the current set
|
| 103 |
-
const potentialRoots =
|
| 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(
|
| 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:
|
|
|
|
| 131 |
layoutOptions: {
|
| 132 |
-
"elk.portConstraints": "
|
| 133 |
"elk.portAlignment.default": "CENTER",
|
| 134 |
},
|
| 135 |
});
|
| 136 |
|
| 137 |
if (message.children) {
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
const child = messageMap.get(childId);
|
| 140 |
if (child) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
elkEdges.push({
|
| 142 |
id: `${message.id}-${childId}`,
|
| 143 |
-
sources: [
|
| 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 |
-
//
|
| 156 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 [
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 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 |
-
//
|
| 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 |
-
//
|
| 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");
|