- Fix reactivity bug: use SvelteMap instead of Map in sessions store - Fix theme listener memory leak: guard against double-init, return cleanup function - Fix transport singleton ignoring different endpoints - Fix form/button type mismatch in MessageInput - Add safer retry validation in chat page - Extract shared utilities: date formatting, session config check, result source config - Extract shared components: Backdrop, PageHeader - Extract orchestration composable from chat page (310→85 lines of script) - Consolidate AuditTimeline switch functions into config record - Move sample data to dedicated module - Add dark mode support for LineageTree SVG colors - Memoize leaf count computation in LineageTree - Update README with usage guide and project structure Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
206 lines
9.1 KiB
Svelte
206 lines
9.1 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import { goto } from '$app/navigation';
|
|
import { resolveRoute } from '$app/paths';
|
|
import type { ChatMessage } from '$lib/types';
|
|
import { OverrideLevel } from '$lib/proto/llm_multiverse/v1/common_pb';
|
|
import type { SessionConfig } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
|
|
import { SessionConfigSchema } from '$lib/proto/llm_multiverse/v1/orchestrator_pb';
|
|
import { create } from '@bufbuild/protobuf';
|
|
import MessageList from '$lib/components/MessageList.svelte';
|
|
import MessageInput from '$lib/components/MessageInput.svelte';
|
|
import OrchestrationProgress from '$lib/components/OrchestrationProgress.svelte';
|
|
import ThinkingSection from '$lib/components/ThinkingSection.svelte';
|
|
import FinalResult from '$lib/components/FinalResult.svelte';
|
|
import SessionSidebar from '$lib/components/SessionSidebar.svelte';
|
|
import ConfigSidebar from '$lib/components/ConfigSidebar.svelte';
|
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
|
import ConnectionStatus from '$lib/components/ConnectionStatus.svelte';
|
|
import { sessionStore } from '$lib/stores/sessions.svelte';
|
|
import { isNonDefaultConfig } from '$lib/utils/sessionConfig';
|
|
import { createOrchestration } from '$lib/composables/useOrchestration.svelte';
|
|
|
|
let messages: ChatMessage[] = $state([]);
|
|
let sessionConfig: SessionConfig = $state(
|
|
create(SessionConfigSchema, { overrideLevel: OverrideLevel.NONE })
|
|
);
|
|
let showConfig = $state(false);
|
|
let showSessionSidebar = $state(false);
|
|
const lineageHref = resolveRoute('/lineage');
|
|
const memoryHref = resolveRoute('/memory');
|
|
const auditHref = resolveRoute('/audit');
|
|
|
|
const orchestration = createOrchestration();
|
|
const hasNonDefaultConfig = $derived(isNonDefaultConfig(sessionConfig));
|
|
|
|
function navigateToSession(sessionId: string, replace = false) {
|
|
const url = `${resolveRoute('/chat')}?session=${sessionId}`;
|
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
|
goto(url, { replaceState: replace });
|
|
}
|
|
|
|
$effect(() => {
|
|
const sessionParam = $page.url.searchParams.get('session');
|
|
const session = sessionStore.getOrCreateSession(sessionParam ?? undefined);
|
|
messages = [...session.messages];
|
|
if (!sessionParam || sessionParam !== session.id) {
|
|
navigateToSession(session.id, true);
|
|
}
|
|
});
|
|
|
|
function handleNewChat() {
|
|
const session = sessionStore.createSession();
|
|
messages = [];
|
|
orchestration.reset();
|
|
navigateToSession(session.id);
|
|
}
|
|
|
|
function handleSelectSession(id: string) {
|
|
sessionStore.switchSession(id);
|
|
const session = sessionStore.activeSession;
|
|
if (session) {
|
|
messages = [...session.messages];
|
|
orchestration.reset();
|
|
navigateToSession(id);
|
|
}
|
|
}
|
|
|
|
async function handleSend(content: string) {
|
|
const sessionId = sessionStore.activeSessionId!;
|
|
messages = await orchestration.send(sessionId, content, sessionConfig, messages);
|
|
}
|
|
|
|
function handleRetry() {
|
|
const sessionId = sessionStore.activeSessionId!;
|
|
const result = orchestration.retry(sessionId, sessionConfig, messages);
|
|
messages = result.messages;
|
|
}
|
|
</script>
|
|
|
|
<div class="flex h-screen overflow-hidden bg-white dark:bg-gray-900">
|
|
<!-- Desktop sidebar: always visible on md+. Mobile: controlled by showSessionSidebar -->
|
|
<div class="hidden md:flex">
|
|
<SessionSidebar onSelectSession={handleSelectSession} onNewChat={handleNewChat} />
|
|
</div>
|
|
<!-- Mobile sidebar drawer -->
|
|
<div class="md:hidden">
|
|
<SessionSidebar
|
|
onSelectSession={handleSelectSession}
|
|
onNewChat={handleNewChat}
|
|
open={showSessionSidebar}
|
|
onClose={() => (showSessionSidebar = false)}
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex min-w-0 flex-1 flex-col">
|
|
<header class="flex items-center justify-between border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-3 md:px-4">
|
|
<div class="flex items-center gap-2">
|
|
<!-- Hamburger menu button (mobile only) -->
|
|
<button
|
|
type="button"
|
|
onclick={() => (showSessionSidebar = true)}
|
|
class="flex h-10 w-10 items-center justify-center rounded-lg text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 md:hidden"
|
|
aria-label="Open sessions sidebar"
|
|
>
|
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
|
</svg>
|
|
</button>
|
|
<h1 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Chat</h1>
|
|
<ConnectionStatus />
|
|
</div>
|
|
<div class="flex items-center gap-1 md:gap-2">
|
|
<!-- eslint-disable svelte/no-navigation-without-resolve -- resolveRoute is resolve; plugin does not recognize the alias -->
|
|
<a
|
|
href={lineageHref}
|
|
class="hidden rounded-lg px-2.5 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-100 sm:inline-flex"
|
|
>
|
|
Lineage
|
|
</a>
|
|
<a
|
|
href={memoryHref}
|
|
class="hidden rounded-lg px-2.5 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-100 sm:inline-flex"
|
|
>
|
|
Memory
|
|
</a>
|
|
<a
|
|
href={auditHref}
|
|
class="hidden rounded-lg px-2.5 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-100 sm:inline-flex"
|
|
>
|
|
Audit
|
|
</a>
|
|
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
|
<ThemeToggle />
|
|
<button
|
|
type="button"
|
|
onclick={() => (showConfig = !showConfig)}
|
|
class="flex min-h-[44px] min-w-[44px] items-center justify-center gap-1 rounded-lg px-2.5 py-1.5 text-sm md:min-h-0 md:min-w-0
|
|
{showConfig ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'}"
|
|
>
|
|
{#if hasNonDefaultConfig}
|
|
<span class="h-2 w-2 rounded-full bg-amber-500"></span>
|
|
{/if}
|
|
<span class="hidden sm:inline">Config</span>
|
|
<!-- Config icon for mobile when text is hidden -->
|
|
<svg class="h-5 w-5 sm:hidden" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<MessageList {messages} />
|
|
|
|
{#if orchestration.isStreaming}
|
|
<OrchestrationProgress state={orchestration.orchestrationState} />
|
|
<ThinkingSection content={orchestration.intermediateResult} />
|
|
{/if}
|
|
|
|
{#if orchestration.isStreaming && messages.length > 0 && messages[messages.length - 1].content === ''}
|
|
<div class="flex justify-start px-4 pb-2">
|
|
<div class="flex items-center gap-1.5 rounded-2xl bg-gray-200 dark:bg-gray-700 px-4 py-2.5">
|
|
<span class="h-2 w-2 animate-bounce rounded-full bg-gray-500 dark:bg-gray-400 [animation-delay:0ms]"
|
|
></span>
|
|
<span
|
|
class="h-2 w-2 animate-bounce rounded-full bg-gray-500 dark:bg-gray-400 [animation-delay:150ms]"
|
|
></span>
|
|
<span
|
|
class="h-2 w-2 animate-bounce rounded-full bg-gray-500 dark:bg-gray-400 [animation-delay:300ms]"
|
|
></span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if orchestration.finalResult && !orchestration.isStreaming}
|
|
<FinalResult result={orchestration.finalResult} />
|
|
{/if}
|
|
|
|
{#if orchestration.error}
|
|
<div class="mx-4 mb-2 flex items-center justify-between gap-3 rounded-lg bg-red-50 dark:bg-red-900/30 px-4 py-2 text-sm text-red-600 dark:text-red-400">
|
|
<span>{orchestration.error}</span>
|
|
{#if orchestration.lastFailedContent}
|
|
<button
|
|
type="button"
|
|
onclick={handleRetry}
|
|
disabled={orchestration.isStreaming}
|
|
class="shrink-0 rounded-md bg-red-100 px-3 py-1 text-xs font-medium text-red-700 hover:bg-red-200 dark:bg-red-800/50 dark:text-red-300 dark:hover:bg-red-800 disabled:opacity-50"
|
|
>
|
|
Retry
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<MessageInput onSend={handleSend} disabled={orchestration.isStreaming} />
|
|
</div>
|
|
|
|
{#if showConfig}
|
|
<ConfigSidebar
|
|
config={sessionConfig}
|
|
onConfigChange={(c) => (sessionConfig = c)}
|
|
onClose={() => (showConfig = false)}
|
|
/>
|
|
{/if}
|
|
</div>
|