Spaces:
Runtime error
Runtime error
| <script lang="ts"> | |
| import type { PersonaResponse } from "$lib/types/Message"; | |
| import CarbonChevronLeft from "~icons/carbon/chevron-left"; | |
| import CarbonChevronRight from "~icons/carbon/chevron-right"; | |
| import CarbonChevronDown from "~icons/carbon/chevron-down"; | |
| import CarbonChevronUp from "~icons/carbon/chevron-up"; | |
| import MarkdownRenderer from "./MarkdownRenderer.svelte"; | |
| import OpenReasoningResults from "./OpenReasoningResults.svelte"; | |
| import CopyToClipBoardBtn from "../CopyToClipBoardBtn.svelte"; | |
| import CarbonRotate360 from "~icons/carbon/rotate-360"; | |
| import ThinkingPlaceholder from "./ThinkingPlaceholder.svelte"; | |
| import { hasThinkSegments, splitThinkSegments } from "$lib/utils/stripThinkBlocks"; | |
| interface Props { | |
| personaResponses: PersonaResponse[]; | |
| loading?: boolean; | |
| onretry?: (personaId: string) => void; | |
| } | |
| let { personaResponses, loading = false, onretry }: Props = $props(); | |
| let currentIndex = $state(0); | |
| let expandedStates = $state<Record<string, boolean>>({}); | |
| let isDragging = $state(false); | |
| let startX = $state(0); | |
| let currentX = $state(0); | |
| let dragOffset = $state(0); | |
| // Detect if device has touch/coarse pointer (mobile/tablet) | |
| let isTouchDevice = $state(false); | |
| $effect(() => { | |
| if (typeof window !== 'undefined') { | |
| // Check if device has coarse pointer (touchscreen) or no pointer (touch-only) | |
| isTouchDevice = window.matchMedia('(pointer: coarse)').matches || | |
| window.matchMedia('(pointer: none)').matches || | |
| 'ontouchstart' in window; | |
| } | |
| }); | |
| // Track content heights for overflow detection | |
| let contentElements = $state<Record<string, HTMLElement | null>>({}); | |
| const MAX_COLLAPSED_HEIGHT = 400; | |
| // Track which version of each persona's response is being shown | |
| let personaVersionIndices = $state<Record<string, number>>({}); | |
| // Get the currently displayed version of a persona response | |
| function getDisplayedResponse(response: PersonaResponse): PersonaResponse { | |
| const versionIndex = personaVersionIndices[response.personaId] ?? response.currentChildIndex ?? 0; | |
| if (versionIndex === 0 || !response.children || response.children.length === 0) { | |
| return response; // Show current response | |
| } | |
| // Show a previous version from children | |
| const childIndex = versionIndex - 1; | |
| return response.children[childIndex] ?? response; | |
| } | |
| // Get all versions of a persona response (current + children) | |
| function getAllVersions(response: PersonaResponse): PersonaResponse[] { | |
| const versions = [response]; | |
| if (response.children && response.children.length > 0) { | |
| versions.push(...response.children); | |
| } | |
| return versions; | |
| } | |
| // Navigate to a different version of a persona's response | |
| function navigateToVersion(personaId: string, versionIndex: number) { | |
| personaVersionIndices[personaId] = versionIndex; | |
| } | |
| function next() { | |
| if (currentIndex < personaResponses.length - 1) { | |
| // Collapse current card if expanded before navigating | |
| const currentPersonaId = personaResponses[currentIndex]?.personaId; | |
| if (currentPersonaId && expandedStates[currentPersonaId]) { | |
| expandedStates[currentPersonaId] = false; | |
| } | |
| currentIndex = currentIndex + 1; | |
| } | |
| } | |
| function previous() { | |
| if (currentIndex > 0) { | |
| // Collapse current card if expanded before navigating | |
| const currentPersonaId = personaResponses[currentIndex]?.personaId; | |
| if (currentPersonaId && expandedStates[currentPersonaId]) { | |
| expandedStates[currentPersonaId] = false; | |
| } | |
| currentIndex = currentIndex - 1; | |
| } | |
| } | |
| function goToIndex(index: number) { | |
| // Collapse current card if expanded before navigating | |
| const currentPersonaId = personaResponses[currentIndex]?.personaId; | |
| if (currentPersonaId && expandedStates[currentPersonaId]) { | |
| expandedStates[currentPersonaId] = false; | |
| } | |
| currentIndex = index; | |
| } | |
| function handleDragStart(event: MouseEvent | TouchEvent) { | |
| // Only allow mouse dragging on touch devices | |
| if (!isTouchDevice && !('touches' in event)) { | |
| return; | |
| } | |
| isDragging = true; | |
| startX = 'touches' in event ? event.touches[0].clientX : event.clientX; | |
| currentX = startX; | |
| } | |
| function handleDragMove(event: MouseEvent | TouchEvent) { | |
| if (!isDragging) return; | |
| // Only allow mouse dragging on touch devices | |
| if (!isTouchDevice && !('touches' in event)) { | |
| return; | |
| } | |
| event.preventDefault(); | |
| currentX = 'touches' in event ? event.touches[0].clientX : event.clientX; | |
| dragOffset = currentX - startX; | |
| } | |
| function handleDragEnd() { | |
| if (!isDragging) return; | |
| isDragging = false; | |
| const threshold = 50; | |
| if (dragOffset < -threshold && currentIndex < personaResponses.length - 1) { | |
| next(); | |
| } else if (dragOffset > threshold && currentIndex > 0) { | |
| previous(); | |
| } | |
| dragOffset = 0; | |
| } | |
| function toggleExpanded(personaId: string) { | |
| expandedStates[personaId] = !expandedStates[personaId]; | |
| } | |
| // Check if content has overflow | |
| function hasOverflow(personaId: string): boolean { | |
| const element = contentElements[personaId]; | |
| if (!element) return false; | |
| return element.scrollHeight > MAX_COLLAPSED_HEIGHT; | |
| } | |
| let currentResponse = $derived(personaResponses[currentIndex]); | |
| // Check if content has <think> blocks | |
| function hasClientThink(content: string | undefined): boolean { | |
| return content ? hasThinkSegments(content) : false; | |
| } | |
| let showLeftArrow = $derived(currentIndex > 0); | |
| let showRightArrow = $derived(currentIndex < personaResponses.length - 1); | |
| let showPositionIndicator = $derived(personaResponses.length > 3); | |
| let personaCount = $derived(Math.max(personaResponses.length, 1)); | |
| let cardWidthPercent = $derived(100 / personaCount); | |
| let trackWidthPercent = $derived(personaCount * 100); | |
| let trackTranslatePercent = $derived(currentIndex * cardWidthPercent); | |
| </script> | |
| <!-- Outer wrapper for arrows --> | |
| <div class="relative w-full"> | |
| <!-- Left Navigation Arrow - positioned outside cards --> | |
| {#if personaResponses.length > 1 && showLeftArrow} | |
| <button | |
| onclick={previous} | |
| class="absolute left-2 top-1/2 z-20 -translate-y-1/2 p-2 text-gray-600 transition-all hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200" | |
| aria-label="Previous persona" | |
| > | |
| <CarbonChevronLeft class="text-3xl" /> | |
| </button> | |
| {/if} | |
| <!-- Right Navigation Arrow - positioned outside cards --> | |
| {#if personaResponses.length > 1 && showRightArrow} | |
| <button | |
| onclick={next} | |
| class="absolute right-2 top-1/2 z-20 -translate-y-1/2 p-2 text-gray-600 transition-all hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200" | |
| aria-label="Next persona" | |
| > | |
| <CarbonChevronRight class="text-3xl" /> | |
| </button> | |
| {/if} | |
| <!-- svelte-ignore a11y_no_static_element_interactions --> | |
| <div | |
| class="carousel-container relative w-full overflow-hidden transition-all duration-300" | |
| onmousedown={handleDragStart} | |
| onmousemove={handleDragMove} | |
| onmouseup={handleDragEnd} | |
| onmouseleave={handleDragEnd} | |
| ontouchstart={handleDragStart} | |
| ontouchmove={handleDragMove} | |
| ontouchend={handleDragEnd} | |
| > | |
| <div | |
| class="carousel-track flex transition-all duration-300 ease-out" | |
| class:dragging={isDragging} | |
| style={`width: ${trackWidthPercent}%; transform: translateX(calc(-${trackTranslatePercent}% + ${dragOffset}px));`} | |
| > | |
| {#each personaResponses as response, index (response.personaId)} | |
| {@const isActive = index === currentIndex} | |
| {@const isPrevious = index === currentIndex - 1} | |
| {@const isNext = index === currentIndex + 1} | |
| {@const isExpanded = expandedStates[response.personaId]} | |
| {@const displayedResponse = getDisplayedResponse(response)} | |
| {@const allVersions = getAllVersions(response)} | |
| {@const currentVersionIndex = personaVersionIndices[response.personaId] ?? response.currentChildIndex ?? 0} | |
| <div | |
| class="carousel-card flex-shrink-0 transition-all duration-300" | |
| class:active={isActive} | |
| class:peek={!isActive} | |
| style={`width: ${cardWidthPercent}%;`} | |
| > | |
| <div | |
| class="relative w-full rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-3.5 text-gray-600 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300" | |
| class:pointer-events-none={!isActive} | |
| > | |
| <!-- Persona Name at Top Left --> | |
| <div class="mb-4 flex items-center justify-between border-b border-gray-200 pb-2 dark:border-gray-700"> | |
| <div> | |
| <h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200"> | |
| {response.personaName} | |
| </h3> | |
| {#if response.personaOccupation || response.personaStance} | |
| <div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400"> | |
| {#if response.personaOccupation}<span>{response.personaOccupation}</span>{/if}{#if response.personaOccupation && response.personaStance}<span class="mx-1">•</span>{/if}{#if response.personaStance}<span>{response.personaStance}</span>{/if} | |
| </div> | |
| {/if} | |
| </div> | |
| <!-- Position Indicator Dots (inside card) --> | |
| {#if personaResponses.length > 1} | |
| <div class="flex items-center gap-2"> | |
| {#if showPositionIndicator} | |
| <!-- Text indicator for N > 3 --> | |
| <div class="text-sm text-gray-600 dark:text-gray-400"> | |
| {currentIndex + 1} of {personaResponses.length} | |
| </div> | |
| {/if} | |
| <!-- Dot indicator --> | |
| <div class="flex gap-1.5"> | |
| {#each personaResponses as _, idx} | |
| <button | |
| onclick={() => goToIndex(idx)} | |
| class="size-2 rounded-full transition-all {idx === currentIndex | |
| ? 'bg-gray-700 dark:bg-gray-300' | |
| : 'bg-gray-400 dark:bg-gray-600 hover:bg-gray-500 dark:hover:bg-gray-500'}" | |
| aria-label={`Go to ${personaResponses[idx].personaName}`} | |
| ></button> | |
| {/each} | |
| </div> | |
| </div> | |
| {/if} | |
| </div> | |
| <!-- Persona Content --> | |
| <div | |
| bind:this={contentElements[response.personaId]} | |
| class="content-wrapper relative" | |
| style={isExpanded ? '' : `max-height: ${MAX_COLLAPSED_HEIGHT}px; overflow: hidden;`} | |
| > | |
| {#if hasClientThink(displayedResponse.content)} | |
| {@const segments = splitThinkSegments(displayedResponse.content ?? "")} | |
| {#each segments as part, _i} | |
| {#if part && part.startsWith("<think>")} | |
| {@const trimmed = part.trimEnd()} | |
| {@const isClosed = trimmed.endsWith("</think>")} | |
| {#if isClosed} | |
| {@const thinkContent = trimmed.slice(7, -8)} | |
| {@const summary = thinkContent.trim().split(/\n+/)[0] || "Reasoning"} | |
| <OpenReasoningResults {summary} content={thinkContent} loading={false} /> | |
| {:else} | |
| <ThinkingPlaceholder /> | |
| {/if} | |
| {:else if part && part.trim().length > 0} | |
| <div | |
| class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 dark:prose-pre:bg-gray-900" | |
| > | |
| <MarkdownRenderer content={part} {loading} /> | |
| </div> | |
| {/if} | |
| {/each} | |
| {:else} | |
| <div | |
| class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 dark:prose-pre:bg-gray-900" | |
| > | |
| <MarkdownRenderer content={displayedResponse.content} {loading} /> | |
| </div> | |
| {/if} | |
| {#if displayedResponse.routerMetadata} | |
| <div class="mt-2 text-xs text-gray-400 dark:text-gray-500"> | |
| <span class="font-medium">{displayedResponse.routerMetadata.route}</span> | |
| <span class="mx-1">•</span> | |
| <span>{displayedResponse.routerMetadata.model}</span> | |
| </div> | |
| {/if} | |
| </div> | |
| <!-- Bottom Actions Row --> | |
| <div class="mt-4 flex items-center justify-between"> | |
| <!-- Left Side: Show More Button or Version Navigation --> | |
| <div class="flex items-center gap-2"> | |
| {#if hasOverflow(response.personaId)} | |
| <button | |
| onclick={() => toggleExpanded(response.personaId)} | |
| class="flex items-center gap-1 rounded-md px-3 py-1.5 text-sm text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/50" | |
| > | |
| {#if isExpanded} | |
| <CarbonChevronUp class="text-base" /> | |
| <span>Show less</span> | |
| {:else} | |
| <CarbonChevronDown class="text-base" /> | |
| <span>Show more</span> | |
| {/if} | |
| </button> | |
| {/if} | |
| <!-- Version Navigation (if multiple versions exist) --> | |
| {#if allVersions.length > 1} | |
| <div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400"> | |
| <button | |
| class="rounded-md p-1 hover:bg-gray-100 dark:hover:bg-gray-700/50 disabled:opacity-30 disabled:cursor-not-allowed" | |
| onclick={() => navigateToVersion(response.personaId, Math.max(0, currentVersionIndex - 1))} | |
| disabled={currentVersionIndex === 0 || loading} | |
| aria-label="Previous version" | |
| > | |
| <CarbonChevronLeft class="text-base" /> | |
| </button> | |
| <span class="text-xs"> | |
| {currentVersionIndex + 1} / {allVersions.length} | |
| </span> | |
| <button | |
| class="rounded-md p-1 hover:bg-gray-100 dark:hover:bg-gray-700/50 disabled:opacity-30 disabled:cursor-not-allowed" | |
| onclick={() => navigateToVersion(response.personaId, Math.min(allVersions.length - 1, currentVersionIndex + 1))} | |
| disabled={currentVersionIndex === allVersions.length - 1 || loading} | |
| aria-label="Next version" | |
| > | |
| <CarbonChevronRight class="text-base" /> | |
| </button> | |
| </div> | |
| {/if} | |
| </div> | |
| <!-- Copy and Regenerate Icons (Bottom Right) --> | |
| <div class="flex items-center gap-1"> | |
| <CopyToClipBoardBtn | |
| classNames="!rounded-md !p-2 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-700/50" | |
| value={displayedResponse.content} | |
| /> | |
| <!-- Regenerate button commented out - regeneration disabled --> | |
| <!-- {#if onretry} | |
| <button | |
| type="button" | |
| class="rounded-md p-2 text-gray-600 hover:bg-gray-200/50 dark:text-gray-400 dark:hover:bg-gray-700/50" | |
| onclick={() => onretry?.(response.personaId)} | |
| aria-label="Regenerate response" | |
| > | |
| <CarbonRotate360 class="text-base" /> | |
| </button> | |
| {/if} --> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/each} | |
| </div> | |
| </div> | |
| </div> | |
| <style> | |
| .carousel-container { | |
| touch-action: pan-y pinch-zoom; | |
| -ms-overflow-style: none; | |
| scrollbar-width: none; | |
| user-select: none; | |
| -webkit-user-select: none; | |
| } | |
| /* Only show grab cursor on touch devices */ | |
| @media (pointer: coarse) { | |
| .carousel-container { | |
| cursor: grab; | |
| } | |
| .carousel-container:active { | |
| cursor: grabbing; | |
| } | |
| } | |
| .carousel-container::-webkit-scrollbar { | |
| display: none; | |
| } | |
| .carousel-track { | |
| display: flex; | |
| gap: 0; | |
| transition: transform 0.3s ease-out, height 0.3s ease-out; | |
| } | |
| .carousel-track.dragging { | |
| transition: height 0.3s ease-out; | |
| } | |
| .carousel-card { | |
| transition: opacity 0.3s ease, transform 0.3s ease, filter 0.3s ease; | |
| } | |
| .carousel-card.active { | |
| opacity: 1; | |
| transform: scale(1); | |
| z-index: 10; | |
| } | |
| .carousel-card.peek { | |
| opacity: 1; | |
| transform: scale(0.92); | |
| pointer-events: none; | |
| } | |
| /* Additional styling for better peeking effect - subtle dimming */ | |
| .carousel-card.peek > div { | |
| filter: brightness(0.92) saturate(0.9); | |
| } | |
| :global(.dark) .carousel-card.peek > div { | |
| filter: brightness(0.75) saturate(0.9); | |
| } | |
| /* Content wrapper auto-sizing */ | |
| .content-wrapper { | |
| transition: max-height 0.3s ease; | |
| } | |
| </style> | |