Files
llm-multiverse-ui/src/routes/chat/+page.svelte
shahondin1624 38f5f31b92 refactor: code review improvements — fix bugs, extract shared utilities, add README
- 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>
2026-03-12 13:48:06 +01:00

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>