Spaces:
Sleeping
Sleeping
Andrew
commited on
Commit
·
3a8705d
1
Parent(s):
e9dc149
refactor(ui): update ChatMessage to support multi-persona and branching
Browse files
src/lib/components/chat/ChatMessage.svelte
CHANGED
|
@@ -8,10 +8,10 @@
|
|
| 8 |
import IconLoading from "../icons/IconLoading.svelte";
|
| 9 |
import CarbonRotate360 from "~icons/carbon/rotate-360";
|
| 10 |
import CarbonBranch from "~icons/carbon/branch";
|
| 11 |
-
import CarbonChevronDown from "~icons/carbon/chevron-down";
|
| 12 |
-
import CarbonChevronUp from "~icons/carbon/chevron-up";
|
| 13 |
import CarbonChevronLeft from "~icons/carbon/chevron-left";
|
| 14 |
import CarbonChevronRight from "~icons/carbon/chevron-right";
|
|
|
|
|
|
|
| 15 |
import CarbonPen from "~icons/carbon/pen";
|
| 16 |
import UploadedFile from "./UploadedFile.svelte";
|
| 17 |
|
|
@@ -47,6 +47,7 @@
|
|
| 47 |
personaId: string;
|
| 48 |
personaName: string;
|
| 49 |
} | null;
|
|
|
|
| 50 |
onretry?: (payload: { id: Message["id"]; content?: string; personaId?: string }) => void;
|
| 51 |
onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
|
| 52 |
onbranch?: (messageId: string, personaId: string) => void;
|
|
@@ -60,6 +61,7 @@
|
|
| 60 |
isAuthor: _isAuthor = true,
|
| 61 |
readOnly: _readOnly = false,
|
| 62 |
isTapped = $bindable(false),
|
|
|
|
| 63 |
alternatives = [],
|
| 64 |
editMsdgId = $bindable(null),
|
| 65 |
isLast = false,
|
|
@@ -81,7 +83,7 @@
|
|
| 81 |
|
| 82 |
let expandedStates = $state<Record<string, boolean>>({});
|
| 83 |
let focusedPersonaId = $state<string | null>(null);
|
| 84 |
-
|
| 85 |
let contentElements = $state<Record<string, HTMLElement | null>>({});
|
| 86 |
const MAX_COLLAPSED_HEIGHT = 400;
|
| 87 |
|
|
@@ -112,14 +114,6 @@
|
|
| 112 |
[]) as MessageReasoningUpdate[]
|
| 113 |
);
|
| 114 |
|
| 115 |
-
// const messageFinalAnswer = $derived(
|
| 116 |
-
// message.updates?.find(
|
| 117 |
-
// ({ type }) => type === MessageUpdateType.FinalAnswer
|
| 118 |
-
// ) as MessageFinalAnswerUpdate
|
| 119 |
-
// );
|
| 120 |
-
// const urlNotTrailing = $derived(page.url.pathname.replace(/\/$/, ""));
|
| 121 |
-
// let downloadLink = $derived(urlNotTrailing + `/message/${message.id}/prompt`);
|
| 122 |
-
|
| 123 |
let thinkSegments = $derived.by(() => splitThinkSegments(message.content));
|
| 124 |
let hasServerReasoning = $derived(
|
| 125 |
reasoningUpdates &&
|
|
@@ -129,8 +123,7 @@ let thinkSegments = $derived.by(() => splitThinkSegments(message.content));
|
|
| 129 |
);
|
| 130 |
let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.content));
|
| 131 |
|
| 132 |
-
// Check if using persona-based response structure
|
| 133 |
-
// Check for existence of the property, not length - empty array [] means "loading personas"
|
| 134 |
let isPersonaMode = $derived(message.personaResponses !== undefined);
|
| 135 |
|
| 136 |
// Unified responses array: use personaResponses if available, otherwise wrap message as single response
|
|
@@ -221,6 +214,16 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
|
|
| 221 |
} else {
|
| 222 |
// Otherwise, just toggle the individual card's state
|
| 223 |
expandedStates[personaId] = !isCurrentlyExpanded;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
}
|
| 225 |
}
|
| 226 |
|
|
@@ -246,15 +249,48 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
|
|
| 246 |
}
|
| 247 |
|
| 248 |
// Reactive overflow detection - updates during streaming
|
| 249 |
-
let overflowStates = $
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
responses.forEach(r => {
|
|
|
|
|
|
|
| 252 |
const element = contentElements[r.personaId];
|
| 253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
});
|
| 255 |
-
|
| 256 |
});
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
function hasOverflow(personaId: string): boolean {
|
| 259 |
return overflowStates[personaId] || false;
|
| 260 |
}
|
|
@@ -322,9 +358,7 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
|
|
| 322 |
<div class="{hasMultipleCards && !focusedPersonaId ? 'persona-scroll-container flex gap-3 overflow-x-auto pb-2 px-12' : ''}">
|
| 323 |
{#if isPersonaMode && responses.length === 0 && isLast && loading}
|
| 324 |
<!-- Loading state: waiting for personas to start responding -->
|
| 325 |
-
<div 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">
|
| 326 |
<IconLoading classNames="loading inline ml-2" />
|
| 327 |
-
</div>
|
| 328 |
{/if}
|
| 329 |
{#each responses as response (response.personaId)}
|
| 330 |
{@const isExpanded = expandedStates[response.personaId]}
|
|
@@ -358,51 +392,72 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
|
|
| 358 |
{/if}
|
| 359 |
|
| 360 |
<div class="flex items-center gap-1">
|
| 361 |
-
{#if
|
| 362 |
<button
|
| 363 |
type="button"
|
| 364 |
class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
|
| 365 |
onclick={(e) => {
|
| 366 |
e.stopPropagation();
|
| 367 |
-
|
| 368 |
}}
|
| 369 |
-
aria-label=
|
| 370 |
-
title="
|
| 371 |
>
|
| 372 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
</button>
|
| 374 |
{/if}
|
| 375 |
-
|
| 376 |
-
|
| 377 |
<button
|
| 378 |
type="button"
|
| 379 |
class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
|
| 380 |
onclick={(e) => {
|
| 381 |
e.stopPropagation();
|
| 382 |
-
|
| 383 |
-
// Trigger animation
|
| 384 |
-
branchClickedPersonaId = response.personaId;
|
| 385 |
-
if (branchClickTimeout) {
|
| 386 |
-
clearTimeout(branchClickTimeout);
|
| 387 |
-
}
|
| 388 |
-
branchClickTimeout = setTimeout(() => {
|
| 389 |
-
branchClickedPersonaId = null;
|
| 390 |
-
}, 500);
|
| 391 |
-
|
| 392 |
-
onbranch?.(message.id, response.personaId);
|
| 393 |
}}
|
| 394 |
-
aria-label="
|
| 395 |
-
title="
|
| 396 |
>
|
| 397 |
-
<
|
| 398 |
-
<CarbonBranch class="text-base" />
|
| 399 |
-
</div>
|
| 400 |
</button>
|
| 401 |
{/if}
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
</div>
|
| 407 |
</div>
|
| 408 |
|
|
@@ -457,26 +512,6 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
|
|
| 457 |
</div>
|
| 458 |
{/if}
|
| 459 |
</div>
|
| 460 |
-
|
| 461 |
-
<!-- Expand/Collapse button for cards with overflow -->
|
| 462 |
-
{#if hasOverflow(response.personaId)}
|
| 463 |
-
<button
|
| 464 |
-
onclick={() => {
|
| 465 |
-
// In multi-card view, "Show more" enters focus mode
|
| 466 |
-
// "Show less" collapses all and exits focus
|
| 467 |
-
!isExpanded && hasMultipleCards ? setFocus(response.personaId) : toggleExpanded(response.personaId);
|
| 468 |
-
}}
|
| 469 |
-
class="mt-3 flex w-full items-center justify-center gap-1 rounded-md border border-gray-200 bg-gray-50 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
|
| 470 |
-
>
|
| 471 |
-
{#if isExpanded}
|
| 472 |
-
<CarbonChevronUp class="text-base" />
|
| 473 |
-
<span>Show less</span>
|
| 474 |
-
{:else}
|
| 475 |
-
<CarbonChevronDown class="text-base" />
|
| 476 |
-
<span>Show more</span>
|
| 477 |
-
{/if}
|
| 478 |
-
</button>
|
| 479 |
-
{/if}
|
| 480 |
</div>
|
| 481 |
{/if}
|
| 482 |
{/each}
|
|
@@ -623,6 +658,8 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
|
|
| 623 |
/>
|
| 624 |
{/if}
|
| 625 |
{#if (alternatives.length > 1 && editMsdgId === null) || (!loading && !editMode)}
|
|
|
|
|
|
|
| 626 |
<button
|
| 627 |
class="hidden cursor-pointer items-center gap-1 rounded-md border border-gray-200 px-1.5 py-0.5 text-xs text-gray-400 group-hover:flex hover:flex hover:text-gray-500 dark:border-gray-700 dark:text-gray-400 dark:hover:text-gray-300 lg:-right-2"
|
| 628 |
title="Edit"
|
|
@@ -632,6 +669,7 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
|
|
| 632 |
<CarbonPen />
|
| 633 |
Edit
|
| 634 |
</button>
|
|
|
|
| 635 |
{/if}
|
| 636 |
</div>
|
| 637 |
</div>
|
|
|
|
| 8 |
import IconLoading from "../icons/IconLoading.svelte";
|
| 9 |
import CarbonRotate360 from "~icons/carbon/rotate-360";
|
| 10 |
import CarbonBranch from "~icons/carbon/branch";
|
|
|
|
|
|
|
| 11 |
import CarbonChevronLeft from "~icons/carbon/chevron-left";
|
| 12 |
import CarbonChevronRight from "~icons/carbon/chevron-right";
|
| 13 |
+
import CarbonMaximize from "~icons/carbon/maximize";
|
| 14 |
+
import CarbonMinimize from "~icons/carbon/minimize";
|
| 15 |
import CarbonPen from "~icons/carbon/pen";
|
| 16 |
import UploadedFile from "./UploadedFile.svelte";
|
| 17 |
|
|
|
|
| 47 |
personaId: string;
|
| 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;
|
|
|
|
| 61 |
isAuthor: _isAuthor = true,
|
| 62 |
readOnly: _readOnly = false,
|
| 63 |
isTapped = $bindable(false),
|
| 64 |
+
branchPersonas = [],
|
| 65 |
alternatives = [],
|
| 66 |
editMsdgId = $bindable(null),
|
| 67 |
isLast = false,
|
|
|
|
| 83 |
|
| 84 |
let expandedStates = $state<Record<string, boolean>>({});
|
| 85 |
let focusedPersonaId = $state<string | null>(null);
|
| 86 |
+
|
| 87 |
let contentElements = $state<Record<string, HTMLElement | null>>({});
|
| 88 |
const MAX_COLLAPSED_HEIGHT = 400;
|
| 89 |
|
|
|
|
| 114 |
[]) as MessageReasoningUpdate[]
|
| 115 |
);
|
| 116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
let thinkSegments = $derived.by(() => splitThinkSegments(message.content));
|
| 118 |
let hasServerReasoning = $derived(
|
| 119 |
reasoningUpdates &&
|
|
|
|
| 123 |
);
|
| 124 |
let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.content));
|
| 125 |
|
| 126 |
+
// Check if using persona-based response structure
|
|
|
|
| 127 |
let isPersonaMode = $derived(message.personaResponses !== undefined);
|
| 128 |
|
| 129 |
// Unified responses array: use personaResponses if available, otherwise wrap message as single response
|
|
|
|
| 214 |
} else {
|
| 215 |
// Otherwise, just toggle the individual card's state
|
| 216 |
expandedStates[personaId] = !isCurrentlyExpanded;
|
| 217 |
+
|
| 218 |
+
// If expanding, scroll to show the bottom of the content
|
| 219 |
+
if (!isCurrentlyExpanded) {
|
| 220 |
+
setTimeout(() => {
|
| 221 |
+
const element = contentElements[personaId];
|
| 222 |
+
if (element) {
|
| 223 |
+
element.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
| 224 |
+
}
|
| 225 |
+
}, 50);
|
| 226 |
+
}
|
| 227 |
}
|
| 228 |
}
|
| 229 |
|
|
|
|
| 249 |
}
|
| 250 |
|
| 251 |
// Reactive overflow detection - updates during streaming
|
| 252 |
+
let overflowStates = $state<Record<string, boolean>>({});
|
| 253 |
+
|
| 254 |
+
// Effect to detect overflow states during rendering and expansion
|
| 255 |
+
$effect(() => {
|
| 256 |
+
// Recompute overflow states whenever responses change or elements are bound
|
| 257 |
+
const newStates: Record<string, boolean> = {};
|
| 258 |
responses.forEach(r => {
|
| 259 |
+
// Access content to make this reactive to content changes during streaming
|
| 260 |
+
const contentLength = r.content?.length || 0;
|
| 261 |
const element = contentElements[r.personaId];
|
| 262 |
+
const isExpanded = expandedStates[r.personaId];
|
| 263 |
+
// Only check overflow if we have content and an element
|
| 264 |
+
// If expanded, always recheck after DOM updates
|
| 265 |
+
if (element && contentLength > 0) {
|
| 266 |
+
newStates[r.personaId] = element.scrollHeight > MAX_COLLAPSED_HEIGHT;
|
| 267 |
+
} else {
|
| 268 |
+
newStates[r.personaId] = false;
|
| 269 |
+
}
|
| 270 |
});
|
| 271 |
+
overflowStates = newStates;
|
| 272 |
});
|
| 273 |
|
| 274 |
+
// Additional effect to force recheck after expansion state changes
|
| 275 |
+
$effect(() => {
|
| 276 |
+
// Track expanded states
|
| 277 |
+
const expandedKeys = Object.keys(expandedStates);
|
| 278 |
+
if (expandedKeys.length > 0) {
|
| 279 |
+
// Delay recheck to allow DOM to update
|
| 280 |
+
const timeout = setTimeout(() => {
|
| 281 |
+
const newStates: Record<string, boolean> = {};
|
| 282 |
+
responses.forEach(r => {
|
| 283 |
+
const element = contentElements[r.personaId];
|
| 284 |
+
if (element && r.content) {
|
| 285 |
+
newStates[r.personaId] = element.scrollHeight > MAX_COLLAPSED_HEIGHT;
|
| 286 |
+
}
|
| 287 |
+
});
|
| 288 |
+
overflowStates = { ...overflowStates, ...newStates };
|
| 289 |
+
}, 100);
|
| 290 |
+
return () => clearTimeout(timeout);
|
| 291 |
+
}
|
| 292 |
+
});
|
| 293 |
+
|
| 294 |
function hasOverflow(personaId: string): boolean {
|
| 295 |
return overflowStates[personaId] || false;
|
| 296 |
}
|
|
|
|
| 358 |
<div class="{hasMultipleCards && !focusedPersonaId ? 'persona-scroll-container flex gap-3 overflow-x-auto pb-2 px-12' : ''}">
|
| 359 |
{#if isPersonaMode && responses.length === 0 && isLast && loading}
|
| 360 |
<!-- Loading state: waiting for personas to start responding -->
|
|
|
|
| 361 |
<IconLoading classNames="loading inline ml-2" />
|
|
|
|
| 362 |
{/if}
|
| 363 |
{#each responses as response (response.personaId)}
|
| 364 |
{@const isExpanded = expandedStates[response.personaId]}
|
|
|
|
| 392 |
{/if}
|
| 393 |
|
| 394 |
<div class="flex items-center gap-1">
|
| 395 |
+
{#if hasMultipleCards}
|
| 396 |
<button
|
| 397 |
type="button"
|
| 398 |
class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
|
| 399 |
onclick={(e) => {
|
| 400 |
e.stopPropagation();
|
| 401 |
+
focusedPersonaId === response.personaId ? toggleExpanded(response.personaId) : setFocus(response.personaId);
|
| 402 |
}}
|
| 403 |
+
aria-label={focusedPersonaId === response.personaId ? "Exit focus mode" : "Focus this persona"}
|
| 404 |
+
title={focusedPersonaId === response.personaId ? "Show all cards" : "Focus on this card"}
|
| 405 |
>
|
| 406 |
+
{#if focusedPersonaId === response.personaId}
|
| 407 |
+
<CarbonMinimize class="text-base" />
|
| 408 |
+
{:else}
|
| 409 |
+
<CarbonMaximize class="text-base" />
|
| 410 |
+
{/if}
|
| 411 |
</button>
|
| 412 |
{/if}
|
| 413 |
+
|
| 414 |
+
{#if !loading && onretry}
|
| 415 |
<button
|
| 416 |
type="button"
|
| 417 |
class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
|
| 418 |
onclick={(e) => {
|
| 419 |
e.stopPropagation();
|
| 420 |
+
onretry?.({ id: message.id, personaId: response.personaId });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
}}
|
| 422 |
+
aria-label="Regenerate {displayName}'s response"
|
| 423 |
+
title="Regenerate this response"
|
| 424 |
>
|
| 425 |
+
<CarbonRotate360 class="text-base" />
|
|
|
|
|
|
|
| 426 |
</button>
|
| 427 |
{/if}
|
| 428 |
+
{#if !loading && onbranch}
|
| 429 |
+
{@const isBranchClicked = branchClickedPersonaId === response.personaId}
|
| 430 |
+
<button
|
| 431 |
+
type="button"
|
| 432 |
+
class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
|
| 433 |
+
onclick={(e) => {
|
| 434 |
+
e.stopPropagation();
|
| 435 |
+
|
| 436 |
+
// Trigger animation
|
| 437 |
+
branchClickedPersonaId = response.personaId;
|
| 438 |
+
if (branchClickTimeout) {
|
| 439 |
+
clearTimeout(branchClickTimeout);
|
| 440 |
+
}
|
| 441 |
+
branchClickTimeout = setTimeout(() => {
|
| 442 |
+
branchClickedPersonaId = null;
|
| 443 |
+
}, 500);
|
| 444 |
+
|
| 445 |
+
onbranch?.(message.id, response.personaId);
|
| 446 |
+
}}
|
| 447 |
+
aria-label="Branch conversation with {displayName}"
|
| 448 |
+
title="Start private conversation with {displayName}"
|
| 449 |
+
>
|
| 450 |
+
<div class="relative transition-transform duration-200 {isBranchClicked ? 'scale-125' : 'scale-100'}">
|
| 451 |
+
<CarbonBranch class="text-base" />
|
| 452 |
+
</div>
|
| 453 |
+
</button>
|
| 454 |
+
{/if}
|
| 455 |
+
{#if !loading}
|
| 456 |
+
<CopyToClipBoardBtn
|
| 457 |
+
classNames="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800"
|
| 458 |
+
value={response.content}
|
| 459 |
+
/>
|
| 460 |
+
{/if}
|
| 461 |
</div>
|
| 462 |
</div>
|
| 463 |
|
|
|
|
| 512 |
</div>
|
| 513 |
{/if}
|
| 514 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
</div>
|
| 516 |
{/if}
|
| 517 |
{/each}
|
|
|
|
| 658 |
/>
|
| 659 |
{/if}
|
| 660 |
{#if (alternatives.length > 1 && editMsdgId === null) || (!loading && !editMode)}
|
| 661 |
+
{@const isRootMessage = message.from === "user" && (!message.ancestors || message.ancestors.length === 0)}
|
| 662 |
+
{#if !isRootMessage}
|
| 663 |
<button
|
| 664 |
class="hidden cursor-pointer items-center gap-1 rounded-md border border-gray-200 px-1.5 py-0.5 text-xs text-gray-400 group-hover:flex hover:flex hover:text-gray-500 dark:border-gray-700 dark:text-gray-400 dark:hover:text-gray-300 lg:-right-2"
|
| 665 |
title="Edit"
|
|
|
|
| 669 |
<CarbonPen />
|
| 670 |
Edit
|
| 671 |
</button>
|
| 672 |
+
{/if}
|
| 673 |
{/if}
|
| 674 |
</div>
|
| 675 |
</div>
|