Spaces:
Sleeping
Sleeping
Andrew
commited on
Commit
·
9cdcacf
1
Parent(s):
257862e
Refactor ChatMessage to render persona responses inline with expand/collapse and focus modes
Browse files
src/lib/components/chat/ChatMessage.svelte
CHANGED
|
@@ -8,7 +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 |
-
|
|
|
|
|
|
|
|
|
|
| 12 |
import CarbonPen from "~icons/carbon/pen";
|
| 13 |
import UploadedFile from "./UploadedFile.svelte";
|
| 14 |
|
|
@@ -20,9 +23,11 @@
|
|
| 20 |
import MarkdownRenderer from "./MarkdownRenderer.svelte";
|
| 21 |
import Alternatives from "./Alternatives.svelte";
|
| 22 |
import MessageAvatar from "./MessageAvatar.svelte";
|
| 23 |
-
import PersonaResponseCarousel from "./PersonaResponseCarousel.svelte";
|
| 24 |
import ThinkingPlaceholder from "./ThinkingPlaceholder.svelte";
|
| 25 |
import { hasThinkSegments, splitThinkSegments } from "$lib/utils/stripThinkBlocks";
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
interface Props {
|
| 28 |
message: Message;
|
|
@@ -63,10 +68,19 @@
|
|
| 63 |
}: Props = $props();
|
| 64 |
|
| 65 |
let contentEl: HTMLElement | undefined = $state();
|
| 66 |
-
let isCopied = $state(false);
|
| 67 |
let messageWidth: number = $state(0);
|
| 68 |
let messageInfoWidth: number = $state(0);
|
| 69 |
let isBranching = $state(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
$effect(() => {
|
| 72 |
// referenced to appease linter for currently-unused props
|
|
@@ -107,16 +121,32 @@ let thinkSegments = $derived.by(() => splitThinkSegments(message.content));
|
|
| 107 |
message.reasoning.trim().length > 0
|
| 108 |
);
|
| 109 |
let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.content));
|
| 110 |
-
let hasPersonaResponses = $derived((message.personaResponses?.length ?? 0) > 0);
|
| 111 |
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
| 117 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
});
|
| 119 |
|
|
|
|
|
|
|
|
|
|
| 120 |
let editMode = $derived(editMsdgId === message.id);
|
| 121 |
$effect(() => {
|
| 122 |
if (editMode) {
|
|
@@ -170,6 +200,57 @@ let hasPersonaResponses = $derived((message.personaResponses?.length ?? 0) > 0);
|
|
| 170 |
handleBranchClick();
|
| 171 |
}
|
| 172 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
</script>
|
| 174 |
|
| 175 |
{#if message.from === "assistant"}
|
|
@@ -179,8 +260,8 @@ let hasPersonaResponses = $derived((message.personaResponses?.length ?? 0) > 0);
|
|
| 179 |
messageInfoWidth >= messageWidth
|
| 180 |
? 'mb-1'
|
| 181 |
: ''}"
|
| 182 |
-
class:w-full={
|
| 183 |
-
class:w-fit={!
|
| 184 |
data-message-id={message.id}
|
| 185 |
data-message-role="assistant"
|
| 186 |
role="presentation"
|
|
@@ -192,94 +273,169 @@ let hasPersonaResponses = $derived((message.personaResponses?.length ?? 0) > 0);
|
|
| 192 |
animating={isLast && loading}
|
| 193 |
/>
|
| 194 |
|
| 195 |
-
|
| 196 |
-
<!--
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
class="relative flex min-w-[60px] flex-col gap-2 break-words rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-3.5 text-gray-600 prose-pre:my-2 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300"
|
| 211 |
-
>
|
| 212 |
-
<!-- Persona Name Header (for single-persona mode) -->
|
| 213 |
-
{#if personaName}
|
| 214 |
-
<div class="mb-2 flex items-start justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
|
| 215 |
-
<div>
|
| 216 |
-
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
| 217 |
-
{personaName}
|
| 218 |
-
</h3>
|
| 219 |
-
{#if personaOccupation || personaStance}
|
| 220 |
-
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
| 221 |
-
{#if personaOccupation}<span>{personaOccupation}</span>{/if}{#if personaOccupation && personaStance}<span class="mx-1">•</span>{/if}{#if personaStance}<span>{personaStance}</span>{/if}
|
| 222 |
-
</div>
|
| 223 |
-
{/if}
|
| 224 |
-
</div>
|
| 225 |
-
</div>
|
| 226 |
{/if}
|
| 227 |
-
|
| 228 |
-
{#if
|
| 229 |
-
<
|
| 230 |
-
{
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
| 234 |
{/if}
|
| 235 |
-
|
| 236 |
-
{#if hasServerReasoning && loading && message.content.length === 0}
|
| 237 |
-
<!-- Show loading indicator while reasoning is in progress -->
|
| 238 |
-
<ThinkingPlaceholder />
|
| 239 |
{/if}
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
|
|
|
|
|
|
|
|
|
| 251 |
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
{:else}
|
| 255 |
<ThinkingPlaceholder />
|
| 256 |
{/if}
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
|
|
|
|
|
|
|
|
|
| 260 |
>
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 262 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
{/if}
|
| 264 |
-
{/each}
|
| 265 |
-
{:else}
|
| 266 |
-
<div
|
| 267 |
-
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"
|
| 268 |
-
>
|
| 269 |
-
<MarkdownRenderer content={message.content} loading={isLast && loading} />
|
| 270 |
</div>
|
| 271 |
-
|
|
|
|
| 272 |
</div>
|
| 273 |
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
{@const branchCount = personaBranches.length}
|
| 281 |
-
{@const hasExistingBranches = branchCount > 0}
|
| 282 |
-
|
| 283 |
<button
|
| 284 |
type="button"
|
| 285 |
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' : ''}"
|
|
@@ -292,18 +448,9 @@ let hasPersonaResponses = $derived((message.personaResponses?.length ?? 0) > 0);
|
|
| 292 |
<span>({branchCount})</span>
|
| 293 |
{/if}
|
| 294 |
</button>
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
isCopied = true;
|
| 299 |
-
}}
|
| 300 |
-
classNames="btn rounded-md p-2 text-sm text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/50"
|
| 301 |
-
value={message.content}
|
| 302 |
-
iconClassNames="text-xs"
|
| 303 |
-
/>
|
| 304 |
-
</div>
|
| 305 |
-
{/if}
|
| 306 |
-
{/if}
|
| 307 |
|
| 308 |
{#if message.routerMetadata && (!isLast || !loading)}
|
| 309 |
<div
|
|
@@ -445,4 +592,43 @@ let hasPersonaResponses = $derived((message.personaResponses?.length ?? 0) > 0);
|
|
| 445 |
stroke-dashoffset: 122.9;
|
| 446 |
}
|
| 447 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
</style>
|
|
|
|
| 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 |
|
|
|
|
| 23 |
import MarkdownRenderer from "./MarkdownRenderer.svelte";
|
| 24 |
import Alternatives from "./Alternatives.svelte";
|
| 25 |
import MessageAvatar from "./MessageAvatar.svelte";
|
|
|
|
| 26 |
import ThinkingPlaceholder from "./ThinkingPlaceholder.svelte";
|
| 27 |
import { hasThinkSegments, splitThinkSegments } from "$lib/utils/stripThinkBlocks";
|
| 28 |
+
import { goto } from "$app/navigation";
|
| 29 |
+
import { base } from "$app/paths";
|
| 30 |
+
import type { PersonaResponse } from "$lib/types/Message";
|
| 31 |
|
| 32 |
interface Props {
|
| 33 |
message: Message;
|
|
|
|
| 68 |
}: Props = $props();
|
| 69 |
|
| 70 |
let contentEl: HTMLElement | undefined = $state();
|
|
|
|
| 71 |
let messageWidth: number = $state(0);
|
| 72 |
let messageInfoWidth: number = $state(0);
|
| 73 |
let isBranching = $state(false);
|
| 74 |
+
|
| 75 |
+
// Track expanded state for each persona card
|
| 76 |
+
let expandedStates = $state<Record<string, boolean>>({});
|
| 77 |
+
|
| 78 |
+
// Track which persona is currently "focused" (full-width carousel mode)
|
| 79 |
+
let focusedPersonaId = $state<string | null>(null);
|
| 80 |
+
|
| 81 |
+
// Track content elements for overflow detection
|
| 82 |
+
let contentElements = $state<Record<string, HTMLElement | null>>({});
|
| 83 |
+
const MAX_COLLAPSED_HEIGHT = 400;
|
| 84 |
|
| 85 |
$effect(() => {
|
| 86 |
// referenced to appease linter for currently-unused props
|
|
|
|
| 121 |
message.reasoning.trim().length > 0
|
| 122 |
);
|
| 123 |
let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.content));
|
|
|
|
| 124 |
|
| 125 |
+
// Check if using persona-based response structure (vs legacy message structure)
|
| 126 |
+
// Check for existence of the property, not length - empty array [] means "loading personas"
|
| 127 |
+
let isPersonaMode = $derived(message.personaResponses !== undefined);
|
| 128 |
+
|
| 129 |
+
// Unified responses array: use personaResponses if available, otherwise wrap message as single response
|
| 130 |
+
let responses = $derived.by((): PersonaResponse[] => {
|
| 131 |
+
if (isPersonaMode) {
|
| 132 |
+
return message.personaResponses!;
|
| 133 |
}
|
| 134 |
+
// Legacy mode: convert message to PersonaResponse format for unified rendering
|
| 135 |
+
return [{
|
| 136 |
+
personaId: 'single',
|
| 137 |
+
personaName: personaName || '',
|
| 138 |
+
personaOccupation,
|
| 139 |
+
personaStance,
|
| 140 |
+
content: message.content,
|
| 141 |
+
reasoning: message.reasoning,
|
| 142 |
+
updates: message.updates,
|
| 143 |
+
routerMetadata: message.routerMetadata,
|
| 144 |
+
}];
|
| 145 |
});
|
| 146 |
|
| 147 |
+
// Multiple cards need horizontal scroll layout
|
| 148 |
+
let hasMultipleCards = $derived(responses.length > 1);
|
| 149 |
+
|
| 150 |
let editMode = $derived(editMsdgId === message.id);
|
| 151 |
$effect(() => {
|
| 152 |
if (editMode) {
|
|
|
|
| 200 |
handleBranchClick();
|
| 201 |
}
|
| 202 |
}
|
| 203 |
+
|
| 204 |
+
// Unified helper functions for card rendering
|
| 205 |
+
function toggleExpanded(personaId: string) {
|
| 206 |
+
const isCurrentlyExpanded = expandedStates[personaId];
|
| 207 |
+
|
| 208 |
+
// In multi-card view, "Show less" should collapse all cards
|
| 209 |
+
if (hasMultipleCards && isCurrentlyExpanded) {
|
| 210 |
+
responses.forEach(r => {
|
| 211 |
+
expandedStates[r.personaId] = false;
|
| 212 |
+
});
|
| 213 |
+
focusedPersonaId = null;
|
| 214 |
+
} else {
|
| 215 |
+
// Otherwise, just toggle the individual card's state
|
| 216 |
+
expandedStates[personaId] = !isCurrentlyExpanded;
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function setFocus(personaId: string) {
|
| 221 |
+
if (hasMultipleCards) {
|
| 222 |
+
// Enter focused mode and ensure the card is expanded
|
| 223 |
+
focusedPersonaId = personaId;
|
| 224 |
+
expandedStates[personaId] = true;
|
| 225 |
+
}
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function navigateFocused(direction: 'prev' | 'next') {
|
| 229 |
+
if (!focusedPersonaId || !hasMultipleCards) return;
|
| 230 |
+
|
| 231 |
+
const currentIndex = responses.findIndex(r => r.personaId === focusedPersonaId);
|
| 232 |
+
if (currentIndex === -1) return;
|
| 233 |
+
|
| 234 |
+
const nextIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1;
|
| 235 |
+
if (nextIndex >= 0 && nextIndex < responses.length) {
|
| 236 |
+
focusedPersonaId = responses[nextIndex].personaId;
|
| 237 |
+
expandedStates[focusedPersonaId] = true;
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
function hasClientThinkInContent(content: string | undefined): boolean {
|
| 242 |
+
return content ? hasThinkSegments(content) : false;
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
function hasOverflow(personaId: string): boolean {
|
| 246 |
+
const element = contentElements[personaId];
|
| 247 |
+
if (!element) return false;
|
| 248 |
+
return element.scrollHeight > MAX_COLLAPSED_HEIGHT;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
function openPersonaSettings(personaId: string) {
|
| 252 |
+
goto(`${base}/settings/personas/${personaId}`);
|
| 253 |
+
}
|
| 254 |
</script>
|
| 255 |
|
| 256 |
{#if message.from === "assistant"}
|
|
|
|
| 260 |
messageInfoWidth >= messageWidth
|
| 261 |
? 'mb-1'
|
| 262 |
: ''}"
|
| 263 |
+
class:w-full={isPersonaMode}
|
| 264 |
+
class:w-fit={!isPersonaMode}
|
| 265 |
data-message-id={message.id}
|
| 266 |
data-message-role="assistant"
|
| 267 |
role="presentation"
|
|
|
|
| 273 |
animating={isLast && loading}
|
| 274 |
/>
|
| 275 |
|
| 276 |
+
<div class="flex-1 min-w-0 relative">
|
| 277 |
+
<!-- Focused mode carousel navigation arrows -->
|
| 278 |
+
{#if focusedPersonaId && hasMultipleCards}
|
| 279 |
+
{@const currentIndex = responses.findIndex(r => r.personaId === focusedPersonaId)}
|
| 280 |
+
{@const hasPrev = currentIndex > 0}
|
| 281 |
+
{@const hasNext = currentIndex < responses.length - 1}
|
| 282 |
+
|
| 283 |
+
{#if hasPrev}
|
| 284 |
+
<button
|
| 285 |
+
onclick={() => navigateFocused('prev')}
|
| 286 |
+
class="absolute -left-12 top-1/2 z-10 -translate-y-1/2 rounded-full p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 transition-all"
|
| 287 |
+
aria-label="Previous persona"
|
| 288 |
+
>
|
| 289 |
+
<CarbonChevronLeft class="text-3xl" />
|
| 290 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
{/if}
|
| 292 |
+
|
| 293 |
+
{#if hasNext}
|
| 294 |
+
<button
|
| 295 |
+
onclick={() => navigateFocused('next')}
|
| 296 |
+
class="absolute -right-12 top-1/2 z-10 -translate-y-1/2 rounded-full p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 transition-all"
|
| 297 |
+
aria-label="Next persona"
|
| 298 |
+
>
|
| 299 |
+
<CarbonChevronRight class="text-3xl" />
|
| 300 |
+
</button>
|
| 301 |
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
{/if}
|
| 303 |
+
|
| 304 |
+
<!-- Container: horizontal scroll for multiple cards (unless focused), single card otherwise -->
|
| 305 |
+
<div class="{hasMultipleCards && !focusedPersonaId ? 'flex gap-3 overflow-x-auto pb-2' : ''}">
|
| 306 |
+
{#if isPersonaMode && responses.length === 0 && isLast && loading}
|
| 307 |
+
<!-- Loading state: waiting for personas to start responding -->
|
| 308 |
+
<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">
|
| 309 |
+
<IconLoading classNames="loading inline ml-2" />
|
| 310 |
+
</div>
|
| 311 |
{/if}
|
| 312 |
+
{#each responses as response (response.personaId)}
|
| 313 |
+
{@const isExpanded = expandedStates[response.personaId]}
|
| 314 |
+
{@const displayName = response.personaName || personaName || 'Assistant'}
|
| 315 |
+
{@const isFocused = focusedPersonaId === response.personaId}
|
| 316 |
+
{@const shouldHide = focusedPersonaId && !isFocused}
|
| 317 |
+
|
| 318 |
+
{#if !shouldHide}
|
| 319 |
+
<!-- Card: ALL use gradient bubble styling for consistency -->
|
| 320 |
+
<div
|
| 321 |
+
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' : ''}"
|
| 322 |
+
style={hasMultipleCards && !focusedPersonaId ? `min-width: 320px; max-width: ${isExpanded ? '600px' : '420px'};` : ''}
|
| 323 |
+
>
|
| 324 |
+
<!-- Persona Header: persona name + copy button (simplified, consistent for all) -->
|
| 325 |
+
<div class="mb-3 flex items-center justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
|
| 326 |
+
{#if isPersonaMode}
|
| 327 |
+
<button
|
| 328 |
+
type="button"
|
| 329 |
+
class="truncate text-left text-lg font-semibold text-gray-700 hover:text-gray-900 dark:text-gray-200 dark:hover:text-gray-50 transition-colors"
|
| 330 |
+
onclick={() => openPersonaSettings(response.personaId)}
|
| 331 |
+
aria-label="Open settings for {displayName}"
|
| 332 |
+
title="View {displayName} settings"
|
| 333 |
+
>
|
| 334 |
+
{displayName}
|
| 335 |
+
</button>
|
| 336 |
+
{:else}
|
| 337 |
+
<h3 class="truncate text-lg font-semibold text-gray-700 dark:text-gray-200">
|
| 338 |
+
{displayName}
|
| 339 |
+
</h3>
|
| 340 |
+
{/if}
|
| 341 |
+
|
| 342 |
+
<CopyToClipBoardBtn
|
| 343 |
+
classNames="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800"
|
| 344 |
+
value={response.content}
|
| 345 |
+
/>
|
| 346 |
+
</div>
|
| 347 |
|
| 348 |
+
<!-- File attachments: only for legacy mode (message-level, not persona-level) -->
|
| 349 |
+
{#if !isPersonaMode && message.files?.length}
|
| 350 |
+
<div class="flex h-fit flex-wrap gap-x-5 gap-y-2 mb-2">
|
| 351 |
+
{#each message.files as file (file.value)}
|
| 352 |
+
<UploadedFile {file} canClose={false} />
|
| 353 |
+
{/each}
|
| 354 |
+
</div>
|
| 355 |
+
{/if}
|
| 356 |
|
| 357 |
+
<!-- Thinking indicator for server reasoning (legacy mode only) -->
|
| 358 |
+
{#if !isPersonaMode && hasServerReasoning && loading && message.content.length === 0}
|
|
|
|
| 359 |
<ThinkingPlaceholder />
|
| 360 |
{/if}
|
| 361 |
+
|
| 362 |
+
<!-- Content -->
|
| 363 |
+
<div
|
| 364 |
+
bind:this={contentElements[response.personaId]}
|
| 365 |
+
class="mt-2"
|
| 366 |
+
style={isExpanded ? '' : `max-height: ${MAX_COLLAPSED_HEIGHT}px; overflow: hidden;`}
|
| 367 |
>
|
| 368 |
+
{#if isLast && loading && message.content.length === 0 && !hasServerReasoning}
|
| 369 |
+
<IconLoading classNames="loading inline ml-2 first:ml-0" />
|
| 370 |
+
{/if}
|
| 371 |
+
|
| 372 |
+
{#if hasClientThinkInContent(response.content)}
|
| 373 |
+
{@const segments = splitThinkSegments(response.content ?? "")}
|
| 374 |
+
{#each segments as part, _i}
|
| 375 |
+
{#if part && part.startsWith("<think>")}
|
| 376 |
+
{@const trimmed = part.trimEnd()}
|
| 377 |
+
{@const isClosed = trimmed.endsWith("</think>")}
|
| 378 |
+
|
| 379 |
+
{#if isClosed}
|
| 380 |
+
<!-- Skip closed think tags - don't show reasoning content -->
|
| 381 |
+
{:else}
|
| 382 |
+
<ThinkingPlaceholder />
|
| 383 |
+
{/if}
|
| 384 |
+
{:else if part && part.trim().length > 0}
|
| 385 |
+
<div
|
| 386 |
+
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"
|
| 387 |
+
>
|
| 388 |
+
<MarkdownRenderer content={part} loading={isLast && loading} />
|
| 389 |
+
</div>
|
| 390 |
+
{/if}
|
| 391 |
+
{/each}
|
| 392 |
+
{:else}
|
| 393 |
+
<div
|
| 394 |
+
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"
|
| 395 |
+
>
|
| 396 |
+
<MarkdownRenderer content={response.content} loading={isLast && loading} />
|
| 397 |
+
</div>
|
| 398 |
+
{/if}
|
| 399 |
+
|
| 400 |
+
{#if response.routerMetadata}
|
| 401 |
+
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
| 402 |
+
<span class="font-medium">{response.routerMetadata.route}</span>
|
| 403 |
+
<span class="mx-1">•</span>
|
| 404 |
+
<span>{response.routerMetadata.model}</span>
|
| 405 |
+
</div>
|
| 406 |
+
{/if}
|
| 407 |
</div>
|
| 408 |
+
|
| 409 |
+
<!-- Expand/Collapse button for cards with overflow -->
|
| 410 |
+
{#if hasOverflow(response.personaId)}
|
| 411 |
+
<button
|
| 412 |
+
onclick={() => {
|
| 413 |
+
// In multi-card view, "Show more" enters focus mode
|
| 414 |
+
// "Show less" collapses all and exits focus
|
| 415 |
+
!isExpanded && hasMultipleCards ? setFocus(response.personaId) : toggleExpanded(response.personaId);
|
| 416 |
+
}}
|
| 417 |
+
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"
|
| 418 |
+
>
|
| 419 |
+
{#if isExpanded}
|
| 420 |
+
<CarbonChevronUp class="text-base" />
|
| 421 |
+
<span>Show less</span>
|
| 422 |
+
{:else}
|
| 423 |
+
<CarbonChevronDown class="text-base" />
|
| 424 |
+
<span>Show more</span>
|
| 425 |
+
{/if}
|
| 426 |
+
</button>
|
| 427 |
{/if}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 428 |
</div>
|
| 429 |
+
{/if}
|
| 430 |
+
{/each}
|
| 431 |
</div>
|
| 432 |
|
| 433 |
+
<!-- Branch button for legacy mode (outside card border) -->
|
| 434 |
+
{#if !isPersonaMode && (!isLast || !loading) && onbranch && personaName}
|
| 435 |
+
{@const branchCount = personaBranches.length}
|
| 436 |
+
{@const hasExistingBranches = branchCount > 0}
|
| 437 |
+
|
| 438 |
+
<div class="mt-1.5 flex items-center justify-end gap-1 px-2">
|
|
|
|
|
|
|
|
|
|
| 439 |
<button
|
| 440 |
type="button"
|
| 441 |
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' : ''}"
|
|
|
|
| 448 |
<span>({branchCount})</span>
|
| 449 |
{/if}
|
| 450 |
</button>
|
| 451 |
+
</div>
|
| 452 |
+
{/if}
|
| 453 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
|
| 455 |
{#if message.routerMetadata && (!isLast || !loading)}
|
| 456 |
<div
|
|
|
|
| 592 |
stroke-dashoffset: 122.9;
|
| 593 |
}
|
| 594 |
}
|
| 595 |
+
|
| 596 |
+
.persona-card {
|
| 597 |
+
transition: all 0.3s ease;
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
/* Smooth scrollbar styling for multi-persona horizontal scroll */
|
| 601 |
+
.overflow-x-auto {
|
| 602 |
+
scrollbar-width: thin;
|
| 603 |
+
scrollbar-color: rgb(209 213 219) transparent;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
.overflow-x-auto::-webkit-scrollbar {
|
| 607 |
+
height: 8px;
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
.overflow-x-auto::-webkit-scrollbar-track {
|
| 611 |
+
background: transparent;
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
.overflow-x-auto::-webkit-scrollbar-thumb {
|
| 615 |
+
background-color: rgb(209 213 219);
|
| 616 |
+
border-radius: 4px;
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
.overflow-x-auto::-webkit-scrollbar-thumb:hover {
|
| 620 |
+
background-color: rgb(156 163 175);
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
:global(.dark) .overflow-x-auto {
|
| 624 |
+
scrollbar-color: rgb(75 85 99) transparent;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
:global(.dark) .overflow-x-auto::-webkit-scrollbar-thumb {
|
| 628 |
+
background-color: rgb(75 85 99);
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
:global(.dark) .overflow-x-auto::-webkit-scrollbar-thumb:hover {
|
| 632 |
+
background-color: rgb(107 114 128);
|
| 633 |
+
}
|
| 634 |
</style>
|
src/lib/components/chat/PersonaResponseCards.svelte
DELETED
|
@@ -1,190 +0,0 @@
|
|
| 1 |
-
<script lang="ts">
|
| 2 |
-
import type { PersonaResponse } from "$lib/types/Message";
|
| 3 |
-
import MarkdownRenderer from "./MarkdownRenderer.svelte";
|
| 4 |
-
import CopyToClipBoardBtn from "../CopyToClipBoardBtn.svelte";
|
| 5 |
-
import CarbonRotate360 from "~icons/carbon/rotate-360";
|
| 6 |
-
import CarbonChevronDown from "~icons/carbon/chevron-down";
|
| 7 |
-
import CarbonChevronUp from "~icons/carbon/chevron-up";
|
| 8 |
-
import ThinkingPlaceholder from "./ThinkingPlaceholder.svelte";
|
| 9 |
-
import { hasThinkSegments, splitThinkSegments } from "$lib/utils/stripThinkBlocks";
|
| 10 |
-
import { goto } from "$app/navigation";
|
| 11 |
-
import { base } from "$app/paths";
|
| 12 |
-
|
| 13 |
-
interface Props {
|
| 14 |
-
personaResponses: PersonaResponse[];
|
| 15 |
-
loading?: boolean;
|
| 16 |
-
onretry?: (personaId: string) => void;
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
let { personaResponses, loading = false, onretry }: Props = $props();
|
| 20 |
-
|
| 21 |
-
// Track expanded state for each persona
|
| 22 |
-
let expandedStates = $state<Record<string, boolean>>({});
|
| 23 |
-
|
| 24 |
-
// Track content elements for overflow detection
|
| 25 |
-
let contentElements = $state<Record<string, HTMLElement | null>>({});
|
| 26 |
-
const MAX_COLLAPSED_HEIGHT = 400;
|
| 27 |
-
|
| 28 |
-
function toggleExpanded(personaId: string) {
|
| 29 |
-
expandedStates[personaId] = !expandedStates[personaId];
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
// Check if content has <think> blocks
|
| 33 |
-
function hasClientThink(content: string | undefined): boolean {
|
| 34 |
-
return content ? hasThinkSegments(content) : false;
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
// Check if content has overflow
|
| 38 |
-
function hasOverflow(personaId: string): boolean {
|
| 39 |
-
const element = contentElements[personaId];
|
| 40 |
-
if (!element) return false;
|
| 41 |
-
return element.scrollHeight > MAX_COLLAPSED_HEIGHT;
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
// Navigate to persona settings
|
| 45 |
-
function openPersonaSettings(personaId: string) {
|
| 46 |
-
goto(`${base}/settings/personas/${personaId}`);
|
| 47 |
-
}
|
| 48 |
-
</script>
|
| 49 |
-
|
| 50 |
-
<!-- Horizontal scrollable cards -->
|
| 51 |
-
<div class="flex gap-3 overflow-x-auto pb-2">
|
| 52 |
-
{#each personaResponses as response (response.personaId)}
|
| 53 |
-
{@const isExpanded = expandedStates[response.personaId]}
|
| 54 |
-
|
| 55 |
-
<div
|
| 56 |
-
class="persona-card flex-shrink-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-850"
|
| 57 |
-
style="min-width: 300px; max-width: {isExpanded ? '600px' : '400px'};"
|
| 58 |
-
>
|
| 59 |
-
<!-- Persona Header -->
|
| 60 |
-
<div class="mb-3 flex items-center justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
|
| 61 |
-
<button
|
| 62 |
-
type="button"
|
| 63 |
-
class="font-semibold text-gray-900 hover:text-gray-700 dark:text-gray-100 dark:hover:text-gray-300 transition-colors"
|
| 64 |
-
onclick={() => openPersonaSettings(response.personaId)}
|
| 65 |
-
aria-label="Open persona settings"
|
| 66 |
-
>
|
| 67 |
-
{response.personaName}
|
| 68 |
-
</button>
|
| 69 |
-
<div class="flex items-center gap-1">
|
| 70 |
-
<CopyToClipBoardBtn
|
| 71 |
-
classNames="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800"
|
| 72 |
-
value={response.content}
|
| 73 |
-
/>
|
| 74 |
-
<!-- Regenerate button commented out - regeneration disabled -->
|
| 75 |
-
<!-- {#if onretry}
|
| 76 |
-
<button
|
| 77 |
-
type="button"
|
| 78 |
-
class="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
| 79 |
-
onclick={() => onretry?.(response.personaId)}
|
| 80 |
-
aria-label="Regenerate response"
|
| 81 |
-
>
|
| 82 |
-
<CarbonRotate360 class="text-base" />
|
| 83 |
-
</button>
|
| 84 |
-
{/if} -->
|
| 85 |
-
</div>
|
| 86 |
-
</div>
|
| 87 |
-
|
| 88 |
-
<!-- Persona Content -->
|
| 89 |
-
<div
|
| 90 |
-
bind:this={contentElements[response.personaId]}
|
| 91 |
-
class="mt-2"
|
| 92 |
-
style={isExpanded ? '' : `max-height: ${MAX_COLLAPSED_HEIGHT}px; overflow: hidden;`}
|
| 93 |
-
>
|
| 94 |
-
{#if hasClientThink(response.content)}
|
| 95 |
-
{@const segments = splitThinkSegments(response.content ?? "")}
|
| 96 |
-
{#each segments as part, _i}
|
| 97 |
-
{#if part && part.startsWith("<think>")}
|
| 98 |
-
{@const trimmed = part.trimEnd()}
|
| 99 |
-
{@const isClosed = trimmed.endsWith("</think>")}
|
| 100 |
-
|
| 101 |
-
{#if isClosed}
|
| 102 |
-
<!-- Skip closed think tags - don't show reasoning content -->
|
| 103 |
-
{:else}
|
| 104 |
-
<ThinkingPlaceholder />
|
| 105 |
-
{/if}
|
| 106 |
-
{:else if part && part.trim().length > 0}
|
| 107 |
-
<div
|
| 108 |
-
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"
|
| 109 |
-
>
|
| 110 |
-
<MarkdownRenderer content={part} {loading} />
|
| 111 |
-
</div>
|
| 112 |
-
{/if}
|
| 113 |
-
{/each}
|
| 114 |
-
{:else}
|
| 115 |
-
<div
|
| 116 |
-
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"
|
| 117 |
-
>
|
| 118 |
-
<MarkdownRenderer content={response.content} {loading} />
|
| 119 |
-
</div>
|
| 120 |
-
{/if}
|
| 121 |
-
|
| 122 |
-
{#if response.routerMetadata}
|
| 123 |
-
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
| 124 |
-
<span class="font-medium">{response.routerMetadata.route}</span>
|
| 125 |
-
<span class="mx-1">•</span>
|
| 126 |
-
<span>{response.routerMetadata.model}</span>
|
| 127 |
-
</div>
|
| 128 |
-
{/if}
|
| 129 |
-
</div>
|
| 130 |
-
|
| 131 |
-
<!-- Expand/Collapse button - only show if overflow exists -->
|
| 132 |
-
{#if hasOverflow(response.personaId)}
|
| 133 |
-
<button
|
| 134 |
-
onclick={() => toggleExpanded(response.personaId)}
|
| 135 |
-
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"
|
| 136 |
-
>
|
| 137 |
-
{#if isExpanded}
|
| 138 |
-
<CarbonChevronUp class="text-base" />
|
| 139 |
-
<span>Show less</span>
|
| 140 |
-
{:else}
|
| 141 |
-
<CarbonChevronDown class="text-base" />
|
| 142 |
-
<span>Show more</span>
|
| 143 |
-
{/if}
|
| 144 |
-
</button>
|
| 145 |
-
{/if}
|
| 146 |
-
</div>
|
| 147 |
-
{/each}
|
| 148 |
-
</div>
|
| 149 |
-
|
| 150 |
-
<style>
|
| 151 |
-
.persona-card {
|
| 152 |
-
transition: all 0.3s ease;
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
/* Smooth scrollbar styling */
|
| 156 |
-
.overflow-x-auto {
|
| 157 |
-
scrollbar-width: thin;
|
| 158 |
-
scrollbar-color: rgb(209 213 219) transparent;
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
.overflow-x-auto::-webkit-scrollbar {
|
| 162 |
-
height: 8px;
|
| 163 |
-
}
|
| 164 |
-
|
| 165 |
-
.overflow-x-auto::-webkit-scrollbar-track {
|
| 166 |
-
background: transparent;
|
| 167 |
-
}
|
| 168 |
-
|
| 169 |
-
.overflow-x-auto::-webkit-scrollbar-thumb {
|
| 170 |
-
background-color: rgb(209 213 219);
|
| 171 |
-
border-radius: 4px;
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
.overflow-x-auto::-webkit-scrollbar-thumb:hover {
|
| 175 |
-
background-color: rgb(156 163 175);
|
| 176 |
-
}
|
| 177 |
-
|
| 178 |
-
:global(.dark) .overflow-x-auto {
|
| 179 |
-
scrollbar-color: rgb(75 85 99) transparent;
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
:global(.dark) .overflow-x-auto::-webkit-scrollbar-thumb {
|
| 183 |
-
background-color: rgb(75 85 99);
|
| 184 |
-
}
|
| 185 |
-
|
| 186 |
-
:global(.dark) .overflow-x-auto::-webkit-scrollbar-thumb:hover {
|
| 187 |
-
background-color: rgb(107 114 128);
|
| 188 |
-
}
|
| 189 |
-
</style>
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lib/components/chat/PersonaResponseCarousel.svelte
DELETED
|
@@ -1,447 +0,0 @@
|
|
| 1 |
-
<script lang="ts">
|
| 2 |
-
import type { PersonaResponse } from "$lib/types/Message";
|
| 3 |
-
import CarbonChevronLeft from "~icons/carbon/chevron-left";
|
| 4 |
-
import CarbonChevronRight from "~icons/carbon/chevron-right";
|
| 5 |
-
import CarbonChevronDown from "~icons/carbon/chevron-down";
|
| 6 |
-
import CarbonChevronUp from "~icons/carbon/chevron-up";
|
| 7 |
-
import MarkdownRenderer from "./MarkdownRenderer.svelte";
|
| 8 |
-
import CopyToClipBoardBtn from "../CopyToClipBoardBtn.svelte";
|
| 9 |
-
import CarbonRotate360 from "~icons/carbon/rotate-360";
|
| 10 |
-
import ThinkingPlaceholder from "./ThinkingPlaceholder.svelte";
|
| 11 |
-
import { hasThinkSegments, splitThinkSegments } from "$lib/utils/stripThinkBlocks";
|
| 12 |
-
|
| 13 |
-
interface Props {
|
| 14 |
-
personaResponses: PersonaResponse[];
|
| 15 |
-
loading?: boolean;
|
| 16 |
-
onretry?: (personaId: string) => void;
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
let { personaResponses, loading = false, onretry }: Props = $props();
|
| 20 |
-
|
| 21 |
-
let currentIndex = $state(0);
|
| 22 |
-
let expandedStates = $state<Record<string, boolean>>({});
|
| 23 |
-
let isDragging = $state(false);
|
| 24 |
-
let startX = $state(0);
|
| 25 |
-
let currentX = $state(0);
|
| 26 |
-
let dragOffset = $state(0);
|
| 27 |
-
|
| 28 |
-
// Detect if device has touch/coarse pointer (mobile/tablet)
|
| 29 |
-
let isTouchDevice = $state(false);
|
| 30 |
-
|
| 31 |
-
$effect(() => {
|
| 32 |
-
if (typeof window !== 'undefined') {
|
| 33 |
-
// Check if device has coarse pointer (touchscreen) or no pointer (touch-only)
|
| 34 |
-
isTouchDevice = window.matchMedia('(pointer: coarse)').matches ||
|
| 35 |
-
window.matchMedia('(pointer: none)').matches ||
|
| 36 |
-
'ontouchstart' in window;
|
| 37 |
-
}
|
| 38 |
-
});
|
| 39 |
-
|
| 40 |
-
// Track content heights for overflow detection
|
| 41 |
-
let contentElements = $state<Record<string, HTMLElement | null>>({});
|
| 42 |
-
const MAX_COLLAPSED_HEIGHT = 400;
|
| 43 |
-
|
| 44 |
-
// Track which version of each persona's response is being shown
|
| 45 |
-
let personaVersionIndices = $state<Record<string, number>>({});
|
| 46 |
-
|
| 47 |
-
// Get the currently displayed version of a persona response
|
| 48 |
-
function getDisplayedResponse(response: PersonaResponse): PersonaResponse {
|
| 49 |
-
const versionIndex = personaVersionIndices[response.personaId] ?? response.currentChildIndex ?? 0;
|
| 50 |
-
|
| 51 |
-
if (versionIndex === 0 || !response.children || response.children.length === 0) {
|
| 52 |
-
return response; // Show current response
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
// Show a previous version from children
|
| 56 |
-
const childIndex = versionIndex - 1;
|
| 57 |
-
return response.children[childIndex] ?? response;
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
// Get all versions of a persona response (current + children)
|
| 61 |
-
function getAllVersions(response: PersonaResponse): PersonaResponse[] {
|
| 62 |
-
const versions = [response];
|
| 63 |
-
if (response.children && response.children.length > 0) {
|
| 64 |
-
versions.push(...response.children);
|
| 65 |
-
}
|
| 66 |
-
return versions;
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
// Navigate to a different version of a persona's response
|
| 70 |
-
function navigateToVersion(personaId: string, versionIndex: number) {
|
| 71 |
-
personaVersionIndices[personaId] = versionIndex;
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
function next() {
|
| 75 |
-
if (currentIndex < personaResponses.length - 1) {
|
| 76 |
-
// Collapse current card if expanded before navigating
|
| 77 |
-
const currentPersonaId = personaResponses[currentIndex]?.personaId;
|
| 78 |
-
if (currentPersonaId && expandedStates[currentPersonaId]) {
|
| 79 |
-
expandedStates[currentPersonaId] = false;
|
| 80 |
-
}
|
| 81 |
-
currentIndex = currentIndex + 1;
|
| 82 |
-
}
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
function previous() {
|
| 86 |
-
if (currentIndex > 0) {
|
| 87 |
-
// Collapse current card if expanded before navigating
|
| 88 |
-
const currentPersonaId = personaResponses[currentIndex]?.personaId;
|
| 89 |
-
if (currentPersonaId && expandedStates[currentPersonaId]) {
|
| 90 |
-
expandedStates[currentPersonaId] = false;
|
| 91 |
-
}
|
| 92 |
-
currentIndex = currentIndex - 1;
|
| 93 |
-
}
|
| 94 |
-
}
|
| 95 |
-
|
| 96 |
-
function goToIndex(index: number) {
|
| 97 |
-
// Collapse current card if expanded before navigating
|
| 98 |
-
const currentPersonaId = personaResponses[currentIndex]?.personaId;
|
| 99 |
-
if (currentPersonaId && expandedStates[currentPersonaId]) {
|
| 100 |
-
expandedStates[currentPersonaId] = false;
|
| 101 |
-
}
|
| 102 |
-
currentIndex = index;
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
function handleDragStart(event: MouseEvent | TouchEvent) {
|
| 106 |
-
// Only allow mouse dragging on touch devices
|
| 107 |
-
if (!isTouchDevice && !('touches' in event)) {
|
| 108 |
-
return;
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
isDragging = true;
|
| 112 |
-
startX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
| 113 |
-
currentX = startX;
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
function handleDragMove(event: MouseEvent | TouchEvent) {
|
| 117 |
-
if (!isDragging) return;
|
| 118 |
-
|
| 119 |
-
// Only allow mouse dragging on touch devices
|
| 120 |
-
if (!isTouchDevice && !('touches' in event)) {
|
| 121 |
-
return;
|
| 122 |
-
}
|
| 123 |
-
|
| 124 |
-
event.preventDefault();
|
| 125 |
-
currentX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
| 126 |
-
dragOffset = currentX - startX;
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
function handleDragEnd() {
|
| 130 |
-
if (!isDragging) return;
|
| 131 |
-
|
| 132 |
-
isDragging = false;
|
| 133 |
-
const threshold = 50;
|
| 134 |
-
|
| 135 |
-
if (dragOffset < -threshold && currentIndex < personaResponses.length - 1) {
|
| 136 |
-
next();
|
| 137 |
-
} else if (dragOffset > threshold && currentIndex > 0) {
|
| 138 |
-
previous();
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
dragOffset = 0;
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
function toggleExpanded(personaId: string) {
|
| 145 |
-
expandedStates[personaId] = !expandedStates[personaId];
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
// Check if content has overflow
|
| 149 |
-
function hasOverflow(personaId: string): boolean {
|
| 150 |
-
const element = contentElements[personaId];
|
| 151 |
-
if (!element) return false;
|
| 152 |
-
return element.scrollHeight > MAX_COLLAPSED_HEIGHT;
|
| 153 |
-
}
|
| 154 |
-
|
| 155 |
-
let currentResponse = $derived(personaResponses[currentIndex]);
|
| 156 |
-
|
| 157 |
-
// Check if content has <think> blocks
|
| 158 |
-
function hasClientThink(content: string | undefined): boolean {
|
| 159 |
-
return content ? hasThinkSegments(content) : false;
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
let showLeftArrow = $derived(currentIndex > 0);
|
| 163 |
-
let showRightArrow = $derived(currentIndex < personaResponses.length - 1);
|
| 164 |
-
let showPositionIndicator = $derived(personaResponses.length > 3);
|
| 165 |
-
let personaCount = $derived(Math.max(personaResponses.length, 1));
|
| 166 |
-
let cardWidthPercent = $derived(100 / personaCount);
|
| 167 |
-
let trackWidthPercent = $derived(personaCount * 100);
|
| 168 |
-
let trackTranslatePercent = $derived(currentIndex * cardWidthPercent);
|
| 169 |
-
</script>
|
| 170 |
-
|
| 171 |
-
<!-- Outer wrapper for arrows -->
|
| 172 |
-
<div class="relative w-full">
|
| 173 |
-
<!-- Left Navigation Arrow - positioned outside cards -->
|
| 174 |
-
{#if personaResponses.length > 1 && showLeftArrow}
|
| 175 |
-
<button
|
| 176 |
-
onclick={previous}
|
| 177 |
-
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"
|
| 178 |
-
aria-label="Previous persona"
|
| 179 |
-
>
|
| 180 |
-
<CarbonChevronLeft class="text-3xl" />
|
| 181 |
-
</button>
|
| 182 |
-
{/if}
|
| 183 |
-
|
| 184 |
-
<!-- Right Navigation Arrow - positioned outside cards -->
|
| 185 |
-
{#if personaResponses.length > 1 && showRightArrow}
|
| 186 |
-
<button
|
| 187 |
-
onclick={next}
|
| 188 |
-
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"
|
| 189 |
-
aria-label="Next persona"
|
| 190 |
-
>
|
| 191 |
-
<CarbonChevronRight class="text-3xl" />
|
| 192 |
-
</button>
|
| 193 |
-
{/if}
|
| 194 |
-
|
| 195 |
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
| 196 |
-
<div
|
| 197 |
-
class="carousel-container relative w-full overflow-hidden transition-all duration-300"
|
| 198 |
-
onmousedown={handleDragStart}
|
| 199 |
-
onmousemove={handleDragMove}
|
| 200 |
-
onmouseup={handleDragEnd}
|
| 201 |
-
onmouseleave={handleDragEnd}
|
| 202 |
-
ontouchstart={handleDragStart}
|
| 203 |
-
ontouchmove={handleDragMove}
|
| 204 |
-
ontouchend={handleDragEnd}
|
| 205 |
-
>
|
| 206 |
-
<div
|
| 207 |
-
class="carousel-track flex transition-all duration-300 ease-out"
|
| 208 |
-
class:dragging={isDragging}
|
| 209 |
-
style={`width: ${trackWidthPercent}%; transform: translateX(calc(-${trackTranslatePercent}% + ${dragOffset}px));`}
|
| 210 |
-
>
|
| 211 |
-
{#each personaResponses as response, index (response.personaId)}
|
| 212 |
-
{@const isActive = index === currentIndex}
|
| 213 |
-
{@const isPrevious = index === currentIndex - 1}
|
| 214 |
-
{@const isNext = index === currentIndex + 1}
|
| 215 |
-
{@const isExpanded = expandedStates[response.personaId]}
|
| 216 |
-
{@const displayedResponse = getDisplayedResponse(response)}
|
| 217 |
-
{@const allVersions = getAllVersions(response)}
|
| 218 |
-
{@const currentVersionIndex = personaVersionIndices[response.personaId] ?? response.currentChildIndex ?? 0}
|
| 219 |
-
|
| 220 |
-
<div
|
| 221 |
-
class="carousel-card flex-shrink-0 transition-all duration-300"
|
| 222 |
-
class:active={isActive}
|
| 223 |
-
class:peek={!isActive}
|
| 224 |
-
style={`width: ${cardWidthPercent}%;`}
|
| 225 |
-
>
|
| 226 |
-
<div
|
| 227 |
-
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"
|
| 228 |
-
class:pointer-events-none={!isActive}
|
| 229 |
-
>
|
| 230 |
-
<!-- Persona Name at Top Left -->
|
| 231 |
-
<div class="mb-4 flex items-center justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
|
| 232 |
-
<div>
|
| 233 |
-
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
| 234 |
-
{response.personaName}
|
| 235 |
-
</h3>
|
| 236 |
-
{#if response.personaOccupation || response.personaStance}
|
| 237 |
-
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
| 238 |
-
{#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}
|
| 239 |
-
</div>
|
| 240 |
-
{/if}
|
| 241 |
-
</div>
|
| 242 |
-
|
| 243 |
-
<!-- Position Indicator Dots (inside card) -->
|
| 244 |
-
{#if personaResponses.length > 1}
|
| 245 |
-
<div class="flex items-center gap-2">
|
| 246 |
-
{#if showPositionIndicator}
|
| 247 |
-
<!-- Text indicator for N > 3 -->
|
| 248 |
-
<div class="text-sm text-gray-600 dark:text-gray-400">
|
| 249 |
-
{currentIndex + 1} of {personaResponses.length}
|
| 250 |
-
</div>
|
| 251 |
-
{/if}
|
| 252 |
-
|
| 253 |
-
<!-- Dot indicator -->
|
| 254 |
-
<div class="flex gap-1.5">
|
| 255 |
-
{#each personaResponses as _, idx}
|
| 256 |
-
<button
|
| 257 |
-
onclick={() => goToIndex(idx)}
|
| 258 |
-
class="size-2 rounded-full transition-all {idx === currentIndex
|
| 259 |
-
? 'bg-gray-700 dark:bg-gray-300'
|
| 260 |
-
: 'bg-gray-400 dark:bg-gray-600 hover:bg-gray-500 dark:hover:bg-gray-500'}"
|
| 261 |
-
aria-label={`Go to ${personaResponses[idx].personaName}`}
|
| 262 |
-
></button>
|
| 263 |
-
{/each}
|
| 264 |
-
</div>
|
| 265 |
-
</div>
|
| 266 |
-
{/if}
|
| 267 |
-
</div>
|
| 268 |
-
|
| 269 |
-
<!-- Persona Content -->
|
| 270 |
-
<div
|
| 271 |
-
bind:this={contentElements[response.personaId]}
|
| 272 |
-
class="content-wrapper relative"
|
| 273 |
-
style={isExpanded ? '' : `max-height: ${MAX_COLLAPSED_HEIGHT}px; overflow: hidden;`}
|
| 274 |
-
>
|
| 275 |
-
{#if hasClientThink(displayedResponse.content)}
|
| 276 |
-
{@const segments = splitThinkSegments(displayedResponse.content ?? "")}
|
| 277 |
-
{#each segments as part, _i}
|
| 278 |
-
{#if part && part.startsWith("<think>")}
|
| 279 |
-
{@const trimmed = part.trimEnd()}
|
| 280 |
-
{@const isClosed = trimmed.endsWith("</think>")}
|
| 281 |
-
|
| 282 |
-
{#if isClosed}
|
| 283 |
-
<!-- Skip closed think tags - don't show reasoning content -->
|
| 284 |
-
{:else}
|
| 285 |
-
<ThinkingPlaceholder />
|
| 286 |
-
{/if}
|
| 287 |
-
{:else if part && part.trim().length > 0}
|
| 288 |
-
<div
|
| 289 |
-
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"
|
| 290 |
-
>
|
| 291 |
-
<MarkdownRenderer content={part} {loading} />
|
| 292 |
-
</div>
|
| 293 |
-
{/if}
|
| 294 |
-
{/each}
|
| 295 |
-
{:else}
|
| 296 |
-
<div
|
| 297 |
-
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"
|
| 298 |
-
>
|
| 299 |
-
<MarkdownRenderer content={displayedResponse.content} {loading} />
|
| 300 |
-
</div>
|
| 301 |
-
{/if}
|
| 302 |
-
|
| 303 |
-
{#if displayedResponse.routerMetadata}
|
| 304 |
-
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
| 305 |
-
<span class="font-medium">{displayedResponse.routerMetadata.route}</span>
|
| 306 |
-
<span class="mx-1">•</span>
|
| 307 |
-
<span>{displayedResponse.routerMetadata.model}</span>
|
| 308 |
-
</div>
|
| 309 |
-
{/if}
|
| 310 |
-
</div>
|
| 311 |
-
|
| 312 |
-
<!-- Bottom Actions Row -->
|
| 313 |
-
<div class="mt-4 flex items-center justify-between">
|
| 314 |
-
<!-- Left Side: Show More Button or Version Navigation -->
|
| 315 |
-
<div class="flex items-center gap-2">
|
| 316 |
-
{#if hasOverflow(response.personaId)}
|
| 317 |
-
<button
|
| 318 |
-
onclick={() => toggleExpanded(response.personaId)}
|
| 319 |
-
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"
|
| 320 |
-
>
|
| 321 |
-
{#if isExpanded}
|
| 322 |
-
<CarbonChevronUp class="text-base" />
|
| 323 |
-
<span>Show less</span>
|
| 324 |
-
{:else}
|
| 325 |
-
<CarbonChevronDown class="text-base" />
|
| 326 |
-
<span>Show more</span>
|
| 327 |
-
{/if}
|
| 328 |
-
</button>
|
| 329 |
-
{/if}
|
| 330 |
-
|
| 331 |
-
<!-- Version Navigation (if multiple versions exist) -->
|
| 332 |
-
{#if allVersions.length > 1}
|
| 333 |
-
<div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
|
| 334 |
-
<button
|
| 335 |
-
class="rounded-md p-1 hover:bg-gray-100 dark:hover:bg-gray-700/50 disabled:opacity-30 disabled:cursor-not-allowed"
|
| 336 |
-
onclick={() => navigateToVersion(response.personaId, Math.max(0, currentVersionIndex - 1))}
|
| 337 |
-
disabled={currentVersionIndex === 0 || loading}
|
| 338 |
-
aria-label="Previous version"
|
| 339 |
-
>
|
| 340 |
-
<CarbonChevronLeft class="text-base" />
|
| 341 |
-
</button>
|
| 342 |
-
<span class="text-xs">
|
| 343 |
-
{currentVersionIndex + 1} / {allVersions.length}
|
| 344 |
-
</span>
|
| 345 |
-
<button
|
| 346 |
-
class="rounded-md p-1 hover:bg-gray-100 dark:hover:bg-gray-700/50 disabled:opacity-30 disabled:cursor-not-allowed"
|
| 347 |
-
onclick={() => navigateToVersion(response.personaId, Math.min(allVersions.length - 1, currentVersionIndex + 1))}
|
| 348 |
-
disabled={currentVersionIndex === allVersions.length - 1 || loading}
|
| 349 |
-
aria-label="Next version"
|
| 350 |
-
>
|
| 351 |
-
<CarbonChevronRight class="text-base" />
|
| 352 |
-
</button>
|
| 353 |
-
</div>
|
| 354 |
-
{/if}
|
| 355 |
-
</div>
|
| 356 |
-
|
| 357 |
-
<!-- Copy and Regenerate Icons (Bottom Right) -->
|
| 358 |
-
<div class="flex items-center gap-1">
|
| 359 |
-
<CopyToClipBoardBtn
|
| 360 |
-
classNames="!rounded-md !p-2 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-700/50"
|
| 361 |
-
value={displayedResponse.content}
|
| 362 |
-
/>
|
| 363 |
-
<!-- Regenerate button commented out - regeneration disabled -->
|
| 364 |
-
<!-- {#if onretry}
|
| 365 |
-
<button
|
| 366 |
-
type="button"
|
| 367 |
-
class="rounded-md p-2 text-gray-600 hover:bg-gray-200/50 dark:text-gray-400 dark:hover:bg-gray-700/50"
|
| 368 |
-
onclick={() => onretry?.(response.personaId)}
|
| 369 |
-
aria-label="Regenerate response"
|
| 370 |
-
>
|
| 371 |
-
<CarbonRotate360 class="text-base" />
|
| 372 |
-
</button>
|
| 373 |
-
{/if} -->
|
| 374 |
-
</div>
|
| 375 |
-
</div>
|
| 376 |
-
</div>
|
| 377 |
-
</div>
|
| 378 |
-
{/each}
|
| 379 |
-
</div>
|
| 380 |
-
</div>
|
| 381 |
-
</div>
|
| 382 |
-
|
| 383 |
-
<style>
|
| 384 |
-
.carousel-container {
|
| 385 |
-
touch-action: pan-y pinch-zoom;
|
| 386 |
-
-ms-overflow-style: none;
|
| 387 |
-
scrollbar-width: none;
|
| 388 |
-
user-select: none;
|
| 389 |
-
-webkit-user-select: none;
|
| 390 |
-
}
|
| 391 |
-
|
| 392 |
-
/* Only show grab cursor on touch devices */
|
| 393 |
-
@media (pointer: coarse) {
|
| 394 |
-
.carousel-container {
|
| 395 |
-
cursor: grab;
|
| 396 |
-
}
|
| 397 |
-
|
| 398 |
-
.carousel-container:active {
|
| 399 |
-
cursor: grabbing;
|
| 400 |
-
}
|
| 401 |
-
}
|
| 402 |
-
|
| 403 |
-
.carousel-container::-webkit-scrollbar {
|
| 404 |
-
display: none;
|
| 405 |
-
}
|
| 406 |
-
|
| 407 |
-
.carousel-track {
|
| 408 |
-
display: flex;
|
| 409 |
-
gap: 0;
|
| 410 |
-
transition: transform 0.3s ease-out, height 0.3s ease-out;
|
| 411 |
-
}
|
| 412 |
-
|
| 413 |
-
.carousel-track.dragging {
|
| 414 |
-
transition: height 0.3s ease-out;
|
| 415 |
-
}
|
| 416 |
-
|
| 417 |
-
.carousel-card {
|
| 418 |
-
transition: opacity 0.3s ease, transform 0.3s ease, filter 0.3s ease;
|
| 419 |
-
}
|
| 420 |
-
|
| 421 |
-
.carousel-card.active {
|
| 422 |
-
opacity: 1;
|
| 423 |
-
transform: scale(1);
|
| 424 |
-
z-index: 10;
|
| 425 |
-
}
|
| 426 |
-
|
| 427 |
-
.carousel-card.peek {
|
| 428 |
-
opacity: 1;
|
| 429 |
-
transform: scale(0.92);
|
| 430 |
-
pointer-events: none;
|
| 431 |
-
}
|
| 432 |
-
|
| 433 |
-
/* Additional styling for better peeking effect - subtle dimming */
|
| 434 |
-
.carousel-card.peek > div {
|
| 435 |
-
filter: brightness(0.92) saturate(0.9);
|
| 436 |
-
}
|
| 437 |
-
|
| 438 |
-
:global(.dark) .carousel-card.peek > div {
|
| 439 |
-
filter: brightness(0.75) saturate(0.9);
|
| 440 |
-
}
|
| 441 |
-
|
| 442 |
-
/* Content wrapper auto-sizing */
|
| 443 |
-
.content-wrapper {
|
| 444 |
-
transition: max-height 0.3s ease;
|
| 445 |
-
}
|
| 446 |
-
</style>
|
| 447 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|